前一篇文章中,讲了一些Docker的基本操作。
这里我们就只讲Docker的镜像构成、用Dockerfile定制镜像。
看这篇文章的前提是,假设你已经理解了Docker的基础概念。
这篇文章的主要内容如下:
- 理解镜像构成
- 使用Dockerfile定制镜像
- Dockerfile指令详解
- Dockerfile多阶段构建
初学者的话,建议还是看这篇:
一、理解镜像构成
前面讲过,Docker的镜像是一层一层构建的,前一层是后一层的基础。
Docker镜像它由文件系统叠加而成。
最底端是引导文件系统bootfs,它像Linux/Unix的引导文件系统。第二层是root文件系统rootfs,它位于引导文件系统之上,rootfs可以是一种或多种操作系统(如Ubuntu文件系统)。然后是一个又一个的中间镜像,最后在镜像的最顶层加载一个读写文件系统,我们在Docker中运行的程序就是在这个读写层中执行的。
可以用下面这张图来表示 —— 来源于 第一本Docker书。
当Docker第一次启动一个容器时,初始的读写层是空的。当文件系统发生变化,这些变化都会应用到读写层。比如我们想修改一个文件,这个文件首先会从该读写层下面的只读层复制到该读写层,然后再修改。该文件的只读版本依然存在,但是已经被读写层中的该文件副本所隐藏。
通常这种机制称为写时复制,这也是使Docker如此强大的技术之一。
利用docker commit理解镜像构成
注意:docker commit命令除了学习之外,还可以用于保存被入侵现场。但是不要使用docker commit定制镜像,定制镜像应该使用Dockerfile来完成。
镜像是容器的基础,每次执行docker run都会指定用哪个镜像作为容器运行的基础。
之前我们讲到了,镜像是多层存储,每一层都是在前一层的基础上进行了修改。
容器呢,则是以镜像为基础层,在其基础上增加了一层读写层。
接下来我们定制一个web服务器,来理解镜像的构成。
1. 用nginx镜像启动一个容器
1 2 3 4 5 6 7 8 9 10 11 |
[xiong@AMDServer ~]$ docker run --name webserver -d -p 5002:80 nginx Unable to find image 'nginx:latest' locally latest: Pulling from library/nginx bf5952930446: Pull complete ba755a256dfe: Pull complete c57dd87d0b93: Pull complete d7fbf29df889: Pull complete 1f1070938ccd: Pull complete Digest: sha256:36b74457bccb56fbf8b05f79c85569501b721d4db813b684391d63e02287c0b2 Status: Downloaded newer image for nginx:latest 6fd64857cabc558fce325cd7c52a53070936833a25032b30b00d3d99b3c64797 |
这条命令用nginx镜像启动一个容器,命名为webserver,并且绑定本地5002端口到容器80端口。
然后我们使用浏览器访问本地的5002端口,就可以看到nginx的欢迎页面(这里不截图了)
2. 修改nginx的欢迎页面
1 2 3 4 5 6 7 8 9 |
[xiong@AMDServer ~]$ docker exec -it webserver bash root@6fd64857cabc:/# cd /usr/share/nginx/html/ 50x.html index.html root@6fd64857cabc:/# cd /usr/share/nginx/html/ root@6fd64857cabc:/usr/share/nginx/html# vi index.html bash: vi: command not found root@6fd64857cabc:/usr/share/nginx/html# echo '<h1>Hello, Docker!</h1>' > index.html root@6fd64857cabc:/usr/share/nginx/html# exit exit |
我们以交互式终端进入webserver容器,执行了bash命令,获得一个可操作的Shell。
本来想试试vi命令修改文件的,结果发现…没安装。
所以就用echo命令修改了 /usr/local/nginx/html/index.html 文件的内容。
接下来我们再刷新浏览器的内容,就发现内容改变了。
3. 用 docker diff 命令查看具体改动
我们修改了容器的读写层,可以看到具体改动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
[xiong@AMDServer ~]$ docker diff webserver C /usr C /usr/share C /usr/share/nginx C /usr/share/nginx/html C /usr/share/nginx/html/index.html C /var C /var/cache C /var/cache/nginx A /var/cache/nginx/client_temp A /var/cache/nginx/fastcgi_temp A /var/cache/nginx/proxy_temp A /var/cache/nginx/scgi_temp A /var/cache/nginx/uwsgi_temp C /run A /run/nginx.pid C /root A /root/.bash_history C /etc C /etc/nginx C /etc/nginx/conf.d C /etc/nginx/conf.d/default.conf |
现在我们定制好了这些改动,希望将它保存下来形成镜像。
4. 用 docker commit 保存镜像
语法格式使用:docker commit –help 命令查看。
1 2 3 4 5 6 7 8 9 10 11 |
[xiong@AMDServer ~]$ docker commit --help Usage: docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]] Create a new image from a container's changes Options: -a, --author string Author (e.g., "John Hannibal Smith <hannibal@a-team.com>") -c, --change list Apply Dockerfile instruction to the created image -m, --message string Commit message -p, --pause Pause container during commit (default true) |
我们可以用下面的命令保存当前的webserver:
1 2 3 4 5 6 |
[xiong@AMDServer ~]$ docker commit \ > --author "tkxiong<xiong@tk-xiong.com>" \ > --message "修改了默认网页" \ > webserver \ > nginx:v2 sha256:9bc7f84b8f15103ee44e9d5c2f95ddadb0bc99a71e09b6ad1211f49db75fd49e |
这里 — author 是修改的作者, –message 则是本次修改的内容。
5. 用docker images命令查看新镜像
1 2 3 4 5 6 7 |
[xiong@AMDServer ~]$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE nginx v2 9bc7f84b8f15 12 seconds ago 132MB nginx latest 08393e824c32 39 hours ago 132MB ubuntu latest 1e4467b07108 13 days ago 73.9MB tkxiong/ubuntu latest 1e4467b07108 13 days ago 73.9MB training/webapp latest 6fae60ef3446 5 years ago 349MB |
6. 查看新镜像 nginx:v2 的修改记录
使用 docker history 命令就可以查看镜像的历史记录了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
[xiong@AMDServer ~]$ docker history nginx:v2 IMAGE CREATED CREATED BY SIZE COMMENT 9bc7f84b8f15 41 seconds ago nginx -g daemon off; 1.23kB 修改了默认网页 08393e824c32 39 hours ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon… 0B <missing> 39 hours ago /bin/sh -c #(nop) STOPSIGNAL SIGTERM 0B <missing> 39 hours ago /bin/sh -c #(nop) EXPOSE 80 0B <missing> 39 hours ago /bin/sh -c #(nop) ENTRYPOINT ["/docker-entr… 0B <missing> 39 hours ago /bin/sh -c #(nop) COPY file:0fd5fca330dcd6a7… 1.04kB <missing> 39 hours ago /bin/sh -c #(nop) COPY file:1d0a4127e78a26c1… 1.96kB <missing> 39 hours ago /bin/sh -c #(nop) COPY file:e7e183879c35719c… 1.2kB <missing> 39 hours ago /bin/sh -c set -x && addgroup --system -… 63.3MB <missing> 39 hours ago /bin/sh -c #(nop) ENV PKG_RELEASE=1~buster 0B <missing> 39 hours ago /bin/sh -c #(nop) ENV NJS_VERSION=0.4.2 0B <missing> 39 hours ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.19.1 0B <missing> 39 hours ago /bin/sh -c #(nop) LABEL maintainer=NGINX Do… 0B <missing> 47 hours ago /bin/sh -c #(nop) CMD ["bash"] 0B <missing> 47 hours ago /bin/sh -c #(nop) ADD file:3af3091e7d2bb40bc… 69.2MB |
可以和 nginx:latest 比较,会发现新增的就是我们刚刚提交的这一层。
7. 运行我们定制的新镜像
1 2 |
[xiong@AMDServer ~]$ docker run --name web2 -d -p 5003:80 nginx:v2 9b648de6550117e559b422ff4cea73b8fd0b5fdf96c0df93499152d02173c239 |
这里新的服务命名为web2,并且映射到本地5003端口。
到这里,我们就第一次完成了定制镜像。
慎用 docker commit
使用docker commit命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。
用 docker diff webserver 命令(这里我发现 docker history 和 docker diff 都可以显示差异)可以发现,除了我们想要修改的 /usr/share/nginx/html/index.html 文件外,还有很多文件被改动或添加了。
这还仅仅是最简单的操作,如果是安装软件包、编译构建,那么会有大量无关的内容被添加进来。如果不小心清理,会导致镜像极为臃肿。
回忆之前提到的镜像分层存储的概念,除当前层外,之前的每一层都是不会发生改变的。如果使用docker commit制作镜像、后期修改,每一次修改都会让镜像臃肿一次,所删除的上一层的东西并不会丢失,而是一直跟着这个镜像,即使这个东西根本无法访问到。
此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,换句话说就是除了制作镜像的人知道执行过什么命令外,别人根本无从得知。而且制作的人,一段时间后也无法记清具体的操作,维护工作非常痛苦。
二、使用Dockerfile定制镜像
从docker commit的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提到的重复、构建透明性、镜像体积的问题都可以解决。
这个脚本就是Dockerfile。
FROM指定基础镜像
定制镜像,也要以一个镜像为基础,在其上进行定制。比如我们之前运行了一个nginx镜像,然后再进行修改。
FROM就用来指定基础镜像,它是Dockerfile里的第一条指令。
比如我们可以以nginx为基础镜像:FROM nginx
如果不想选择现有镜像作为基础镜像,还可以选择特殊镜像scratch,它表示空白镜像,那么接下来所写的指令就是镜像的第一层。
RUN执行命令
RUN指令是用来执行命令的,最常用的指令格式有两种:
- Shell格式,比如:RUN echo ‘<h1>Hello, Docker!</h1>’ > /usr/share/nginx/html/index.html
- exec格式,RUN [“可执行文件”, “参数1”, “参数2”]
接下来我们以debian操作系统镜像为基础,安装redis进行举例。
1 2 3 4 5 6 7 8 9 |
FROM debian:stretch RUN apt-get update RUN apt-get install -y gcc libc6-dev make wget RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" RUN mkdir -p /usr/src/redis RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 RUN make -C /usr/src/redis RUN make -C /usr/src/redis install |
Dockerfile的每一条指令都会建立一层镜像,每一个RUN都会建立一层读写层。
这种写法就建立了7层镜像,而且很多编译的文件,更新的软件包等都被写入了镜像。
UnionFS是有最大层数限制的,目前是不能超过127层。
所以正确的Dockerfile写法应该是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FROM debian:stretch RUN buildDeps='gcc libc6-dev make wget' \ && apt-get update \ && apt-get install -y $buildDeps \ && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \ && mkdir -p /usr/src/redis \ && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \ && make -C /usr/src/redis \ && make -C /usr/src/redis install \ && rm -rf /var/lib/apt/lists/* \ && rm redis.tar.gz \ && rm -r /usr/src/redis \ && apt-get purge -y --auto-remove $buildDeps |
这样就只使用了一个RUN指令,将7层简化为1层。
完整流程就是:先更新软件包,然后安装Redis,之后还要进行相关清理工作。
Docker大体上会按如下流程执行Dockerfile中的指令:
- Docker从基础镜像运行一个容器
- 执行一条指令,对容器进行修改
- 执行类似docker commit的操作,提交新镜像层
- Docker再基于刚提交的镜像运行一个新容器
- 执行下一条指令,直到所有指令都执行完毕。
Dockerfile也支持注释,以 # 开头的行都会被认为是注释。
构建镜像
我们有了自己的Dockerfile之后,使用docker build命令构建镜像。
我们以下面的Dockerfile为例,构建简单的 nginx:v3 版本(它和前面的v2其实一样)
1 2 |
FROM nginx RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html |
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[xiong@AMDServer Docker]$ mkdir nginx-v3 [xiong@AMDServer Docker]$ cd nginx-v3/ [xiong@AMDServer nginx-v3]$ vim Dockerfile [xiong@AMDServer nginx-v3]$ docker build -t nginx:v3 . Sending build context to Docker daemon 2.048kB Step 1/2 : FROM nginx ---> 08393e824c32 Step 2/2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html ---> Running in 882db29ddc12 Removing intermediate container 882db29ddc12 ---> 3ee4b531ad4d Successfully built 3ee4b531ad4d Successfully tagged nginx:v3 |
-t 参数指定镜像名称和标签为 nginx:v3
然后我们接下来可以像运行 nginx:v2一样运行它。
镜像构建上下文(Context)
先看看docker build –help,这里给出使用格式:
1 2 3 4 5 |
[xiong@AMDServer nginx-v3]$ docker build --help Usage: docker build [OPTIONS] PATH | URL | - Build an image from a Dockerfile |
这里可以指定PATH或者URL,其实它指定的不是Dockerfile所在的路径,而是上下文路径。
认真看参数,-f才是指定Dockerfile路径的,如果不指定,默认PATH目录下的Dockerfile文件。
1 |
-f, --file string Name of the Dockerfile (Default is 'PATH/Dockerfile') |
那上下文路径是干什么用的呢?节选部分前面的代码
1 2 |
[xiong@AMDServer nginx-v3]$ docker build -t nginx:v3 . Sending build context to Docker daemon 2.048kB |
该目录的内容被打包发送给Docker引擎,用来帮助构建镜像。
举个例子,假设我们使用COPY命令将文件拷贝到镜像内,代码如下:
1 |
COPY ./package.json /app/ |
它拷贝的并不是执行docker build命令当前目录下的package.json文件,也不是Dockerfile所在目录的文件,而是我们这里指定的上下文目录里的package.json文件。
简单来讲,这个上下文路径存储的是docker生成镜像需要的所有文件。
其他 docker build 用法
使用 git repo 进行构建
1 |
$ docker build https://github.com/twang2218/gitlab-ce-zh.git#:11.1 |
这行命令置顶了构建所需的git repo,并且指定默认的master分支,构建目录为/11.1/
然后Dcoker会自己去git clone这个项目,切换指定分支,进入指定目录进行构建
使用给定压缩文件进行构建
1 |
$ docker build http://server/context.tar.gz |
如果给出的URL是一个tar压缩包,那么Docker引擎会自动下载这个包,并且解压缩,作为上下文开始构建。
从标准输入中读取Dockerfile进行构建
从标准输入中读取有两种方法:
- docker build – < Dockerfile
- cat Dockerfile | docker build –
传入的文本文件被视为Dockerfile文件,这种方法没有上下文,所以不能将本地文件COPY到镜像内。
从标准输入中读取上下文压缩包进行构建
1 |
$ docker build - < context.tar.gz |
如果标准输入的是一个压缩包文件,则直接将其解压缩,将里面视为上下文开始构建。
三、Dockerfile指令详解
前面我们介绍了FROM用来指定基础镜像,RUN执行命令,提到了COPY拷贝文件。
接着我们继续讲讲详细命令。
COPY 复制文件
格式:
- COPY [–chown=<user>:<group>] <源路径>… <目标路径>
- COPY [–chown=<user>:<group>] [“<源路径1>”,… “<目标路径>”]
源路径可以是多个,通配符规则要满足Go的filepath.Match规则,如:
1 2 |
COPY hom* /mydir/ COPY hom?.txt /mydir/ |
目标路径是容器内的绝对路径,也可以是相对于工作目录的相对路径。
工作目录可以用WORKDIR指令来指定
使用该指令的时候还可以加上 –chown=<user>:<group> 来改变文件所属用户及用户组。
ADD 更高级的复制文件
ADD指令和COPY指令的格式、性质基本一致。但是在COPY的基础上增加了一些功能。
ADD指令的源路径可以是一个URL,这种情况下Docker引擎会尝试下载文件放到目标路径去,下载之后文件权限自动设置为600(rw-|—|—),如果这不是想要的权限,则还需要额外的一层RUN去调整权限。如果下载的是个压缩包,还需要RUN解压缩。
所以还不如直接用RUN,wget或者curl下载文件,处理权限,解压缩,清理无用文件。
如果源路径为一个 tar 压缩文件的话,压缩格式为gzip, bzip2以及xz的情况下,ADD 指令会自动解压缩文件到目标路径。所以希望复制一个压缩文件到容器,不需要解压缩的情况,就不能使用ADD命令了。
所以建议就是:复制文件使用COPY命令,需要解压缩则使用ADD命令。
除此之外,可以使用 –chown=<user>:<group> 来改变文件所属用户及用户组。
CMD 容器启动命令
CMD指令的格式和RUN相似,也是两种格式:
- shell格式:CMD <命令>
- exec格式:CMD [“可执行文件”, “参数1”, “参数2″…]
- 参数列表格式:CMD[“参数1”, “参数2″…]
在指定了ENTRYPOINT指令后,用CMD指定具体的参数。
在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如ubuntu镜像默认的CMD是/bin/bash,如果我们直接运行ubuntu容器 docker run -it ubuntu 的话,会直接进入bash。我们也可以在运行时指定运行别的命令。
如果我们执行 docker run -it ubuntu cat /etc/os-release,那么就用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令,输出了系统版本信息。
在指令格式上,一般推荐使用exec模式,这类格式在解析时会被解析为Json数组,因此一定要用双引号,而不是单引号。
如果使用shell格式的话,实际的命令会被包装为 sh -c 的参数形式进行执行。比如:
1 |
CMD echo $HOME |
在实际执行中,会将其变更为:
1 |
CMD [ "sh", "-c", "echo $HOME" ] |
这就是为什么可以使用环境变量的原因,因为这些环境变量会被shell进行解析处理。
前台执行
提到CMD就不得不说容器中应用在前台执行和后台执行的问题。
Docker不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机一样用systemd去启动后台服务,容器内没有后台服务的概念。
下面是一个错误的示例:
1 |
CMD service nginx start |
这样执行的话,容器执行后就立即退出了,甚至在容器内去使用systemctl命令结果却发现根本执行不了。这就是因为没有搞明白前台后台的概念,没有区分容器和虚拟机的差异,依旧用传统虚拟机的角度去理解容器。
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,一旦主进程退出,容器就失去了存在的意义从而退出,其他辅助进程不是它需要关心的东西。
那么我们怎么理解上面的示例呢?它会被解析成下面代码:
1 |
CMD [ "sh", "-c", "service nginx start"] |
因此,主进程实际上是sh,当”service nginx start”执行完之后,sh就结束了,sh作为主进程退出了,容器自然就退出了。
正确的做法是:执行nginx可执行文件,并以前台形式运行。
1 |
CMD ["nginx", "-g", "daemon off;"] |
ENTRYPOINT 入口点
ENTRYPOINT的格式和RUN指令格式一样,分为exec格式和shell格式。其目的和CMD一样,都是在指定容器启动程序和参数。
入口点(ENTRYPOINT)在运行时也可以替代,不过比CMD要稍微麻烦,需要通过 docker run –entrypoint来指定。
当我们指定了入口点之后,CMD的含义就发生了改变,不再是直接运行其命令,而是将CMD内容作为参数传递给ENTRYPOINT指令,实际执行时变为:
1 |
<ENTRYPOINT> "<CMD>" |
这样做的好处是什么呢?
场景一:让镜像像命令一样使用
假设我们需要一个得知自己当前公网IP的镜像,那么我们先用CMD来实现:
1 2 3 4 5 6 7 |
FROM ubuntu:latest RUN apt-get update \ && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/* CMD [ "curl", "-s", "https://ip.cn" ] |
构建镜像命令如下:docker build -t myip .
最后输出成功即可:
1 2 |
Successfully built 08d29c0be1cb Successfully tagged myip:latest |
然后我们运行这个镜像:docker run
呃…我这边出问题了,没有返回内容,跟书上的内容不一致。
这里我们需要把域名地址: ip.cn 改为 cip.cc 。
所以新的Dockerfile最后一行应该就是:
1 |
CMD [ "curl", "-s", "https://cip.cc" ] |
然后再重新生成镜像,这次生成镜像的速度比上次快了很多很多,原因看下面:
先看当前目录结构:
12345678 [xiong@AMDServer Docker]$ tree -L 2.├── myip│ └── Dockerfile└── nginx-v3└── Dockerfile2 directories, 2 files然后在当前目录下重新生成镜像:
12345678910111213 [xiong@AMDServer Docker]$ docker build -t myip ./myipSending build context to Docker daemon 2.048kBStep 1/3 : FROM ubuntu:latest---> 1e4467b07108Step 2/3 : RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*---> Using cache---> ad1fa47b8166Step 3/3 : CMD [ "curl", "-s", "cip.cc" ]---> Running in febae3ebe76bRemoving intermediate container febae3ebe76b---> 77186d00a645Successfully built 77186d00a645Successfully tagged myip:latest这次的生成速度极快,具体原因可以看 —> Using cache 这里,它使用了缓存镜像,所以相比上次的生成速度快了很多。
然后镜像生成完毕后,我们再运行一次:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
[xiong@AMDServer Docker]$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE myip latest 77186d00a645 3 minutes ago 90MB <none> <none> 08d29c0be1cb About an hour ago 90MB nginx v3 3ee4b531ad4d 26 hours ago 132MB nginx v2 9bc7f84b8f15 39 hours ago 132MB nginx latest 08393e824c32 3 days ago 132MB ubuntu latest 1e4467b07108 2 weeks ago 73.9MB tkxiong/ubuntu latest 1e4467b07108 2 weeks ago 73.9MB training/webapp latest 6fae60ef3446 5 years ago 349MB [xiong@AMDServer Docker]$ docker run myip IP : 14.154.31.135 地址 : 中国 广东 深圳 运营商 : 电信 数据二 : 广东省深圳市 | 电信 数据三 : URL : http://www.cip.cc/14.154.31.135 |
这里有一个<none>镜像,它是虚悬镜像。因为新旧镜像同名,旧镜像的名称就被取消,从而出现了这类仓库名、标签都是<none>的镜像。它们已经失去了存在的价值,可以随意删除。
我们关注下面的 docker run myip 这个指令的结果。
接下来,我们希望能够把http的Header信息显示出来,根据curl –help,需要加上 -i 参数。
1 2 3 4 |
[xiong@AMDServer Docker]$ curl --help Usage: curl [options...] <url> ...... -i, --include Include protocol response headers in the output |
那么我们试试运行 docker run myip -i 命令。
1 2 3 |
[xiong@AMDServer Docker]$ docker run myip -i docker: Error response from daemon: OCI runtime create failed: container_linux.go:349: starting container process caused "exec: \"-i\": executable file not found in $PATH": unknown. ERRO[0000] error waiting for container: context canceled |
发现 executable file not found。
因为: 跟在镜像名称后面的 -i 被认为是command,运行时会替换CMD的默认值。
所以: 这里的-i替换了原来的curl命令,而不是添加在curl后面,所以就找不到了。
如果要加入-i参数,就必须完整输入命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
[xiong@AMDServer Docker]$ docker run myip curl -s https://cip.cc -i HTTP/2 200 server: openresty date: Sat, 08 Aug 2020 06:29:32 GMT content-type: text/html; charset=UTF-8 vary: Accept-Encoding x-cip-c: M IP : 14.154.29.128 地址 : 中国 广东 深圳 运营商 : 电信 数据二 : 广东省深圳市 | 电信 数据三 : URL : http://www.cip.cc/14.154.29.128 |
很明显这个解决方案一点都不好,但是我们可以用ENTRYPOINT解决这个问题。
修改Dockerfile,然后重新构建镜像
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
[xiong@AMDServer Docker]$ cat myip/Dockerfile FROM ubuntu:latest RUN apt-get update \ && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/* ENTRYPOINT [ "curl", "-s", "cip.cc" ] [xiong@AMDServer Docker]$ docker build -t myip myip/ Sending build context to Docker daemon 2.048kB Step 1/3 : FROM ubuntu:latest ---> 1e4467b07108 Step 2/3 : RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* ---> Using cache ---> ad1fa47b8166 Step 3/3 : ENTRYPOINT [ "curl", "-s", "cip.cc" ] ---> Running in f496a7061910 Removing intermediate container f496a7061910 ---> 9f2b93230f4e Successfully built 9f2b93230f4e Successfully tagged myip:latest |
然后执行新的命令:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
[xiong@AMDServer Docker]$ docker run myip -i HTTP/1.1 200 OK Server: openresty Date: Sat, 08 Aug 2020 06:31:23 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive Vary: Accept-Encoding X-cip-c: H IP : 14.154.31.135 地址 : 中国 广东 深圳 运营商 : 电信 数据二 : 广东省深圳市 | 电信 数据三 : 中国广东深圳 | 电信 URL : http://www.cip.cc/14.154.31.135 |
这次就成功了,因为ENTRYPOINT存在,CMD的内容就会作为参数传递的ENTRYPOINT。
这里的 -i 就是CMD,它作为参数传递给curl,达到预期效果。
场景二:应用运行前的准备工作
启动容器就是启动主进程,但是有些时候,启动主进程之前要准备一些工作。
比如mysq类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的mysql服务运行之前解决。
此外,可能希望比年使用root用户去启动服务,从而提高安全性,而在启动服务前还需要以root身份执行一些必要的准备工作,最终切换到服务用户身份启动服务。或者除了服务之外,其他命令依旧以root用户执行,方便调试。
这些准备工作和容器的CMD无关,无论CMD是什么,都需要先进行一个预处理的工作。这种情况下可以写一个脚本放入到ENTRYPOINT里去执行,这个脚本会将接收到的参数(CMD)作为命令,在脚本最后执行。
比如官方镜像redis就是这样做的:
1 2 3 4 5 6 7 8 |
FROM alpine:3.4 ... RUN addgroup -S redis && adduser -S -G redis redis ... ENTRYPOINT ["docker-entrypoint.sh"] EXPOSE 6379 CMD [ "redis-server" ] |
可以看到先创建了redis用户,然后指定了入口点为 docker-entrypoint.sh 脚本文件。
脚本文件内容如下:
1 2 3 4 5 6 7 8 9 |
#!/bin/sh ... # allow the container to be started with `--user` if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then chown -R redis . exec su-exec redis "$0" "$@" fi exec "$@" |
而且脚本内容是根据CMD内容判断,如果传入的是redis-server,则切换到redis用户来执行,否则以root身份来执行。
可以运行一下nginx的镜像试试:
1 2 |
[xiong@AMDServer Docker]$ docker run -it redis id uid=0(root) gid=0(root) groups=0(root) |
总结入口点的两个用法:
- 让镜像像命令一样使用
- 为应用运行前做准备工作
ENV 设置环境变量
ENV指令的格式很简单,就是设置环境变量而已。
- ENV <key> <value>
- ENV <key1>=<value1> <key2>=<value2>…
后面的指令,无论是RUN还是运行时的应用,都可以直接使用这里的环境变量。
举个例子:
1 2 |
ENV VERSION=1.0 DEBUG=on \ NAME="Happy Feet" |
它的写法也是支持换行的,对于含有空格的值,就用双引号括起来。
ARG 构建参数
ARG设置的是构建时使用的环境变量,容器运行时不会保存这些信息,指令格式如下:
ARG <参数名>[=<默认值>]
不要使用ARG保存密码信息,因为docker history是可以看见的。
Dockerfile中的ARG指令是定义参数名称和默认值。
这个默认值可以在构建命令 docker build 中用 –build-arg <参数名>=<值> 来覆盖。
VOLUME 定义匿名数据卷
容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,它们的数据库文件应该保存于数据卷中。
定义匿名数据卷的方式有两种:
- VOLUME <路径>
- VOLUME [“<路径1>”, “<路径2>” …]
举个例子: VOLUME /data
当容器运行时,容器的目录 /data 就会自动挂载为匿名数据卷,任何向 /data 中写入的信息都不会记录进容器的存储层,从而保证了容器存储层的无状态化。
当然了,运行的时候,我们可以覆盖这个挂载数据卷的设置,比如:
docker run -d -v mydata:/data xxxx
这里参数 -d 的意思是让容器在后台运行,参数 -v 的意思就是挂载数据卷。
在运行xxxx镜像的时候,我们将mydata这个数据卷挂载到了/data,这样就替代了Dockerfile中定义的匿名数据卷。
EXPOSE 暴露端口
指令格式: EXPOSE <端口1> [<端口2>…]
声明容器运行时提供服务端口,这只是一个声明。
运行时,应用并不会因为这个声明就开启对应端口的服务。
在Dockerfile中这样写有两个好处:
- 帮助镜像使用者理解这个镜像服务的守护端口,方便配置映射。
- 在运行是使用随机端口映射时,会自动随机映射EXPOSE端口。
这里使用的时候要注意,即使Dockerfile里面有这个指令,也需要配置端口映射。
要将EXPOSE和运行时的-p参数区分开。-p参数是映射宿主机和容器的端口,而EXPOSE声明的是容器打算使用的端口,与宿主机无关,也不会自动配置映射。
WORKDIR 指定工作目录
指令格式: WORKDIR <工作目录路径>
WORKDIR 指令用来指定工作目录,如果目录不存在,则会自动创建。而且这个路径会覆盖后面各层的工作目录。P.s. 回忆一下,Dockerfile的每个命令都是一层镜像。
所以如果把Dockerfile像Shell脚本一样书写:
1 2 |
RUN cd /app RUN echo "hello" > world.txt |
这样构建之后,要么就是找不到 /app/world.ext 文件,要么就是它的内容不是 “hello”。
原因就是两个RUN运行时不在同一个环境,它们根本就是不同的容器。
所以如果需要改变以后各层的工作目录的位置,应该使用WORKDIR命令。
USER 切换当前用户
USER指令和WORKDIR是类似的,它改变了当前的用户并且影响以后的层。
USER改变的是执行 RUN、 CMD、 ENTRYPOINT 这类命令的身份。
存在的问题是USER只是切换用户,需要先建立好,如果用户不存在,则无法切换。
以 redis 为例:先创建用户,再切换用户,再执行命令。
1 2 3 |
RUN groupadd -r redis && useradd -r -g redis redis USER redis RUN [ "redis-server" ] |
如果以root执行的脚本,在执行期间希望改变身份,比如想以某个已经建立好的用户来运行某个服务进程,不要使用su或者sudo,他们在TTY缺失的环境下经常出错,建议使用gosu。
1 2 3 4 5 6 7 8 9 10 11 12 |
# 建立 redis 用户,并使用 gosu 换另一个用户执行命令 # 添加redis用户 RUN groupadd -r redis && useradd -r -g redis redis # 下载 gosu RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" \ && chmod +x /usr/local/bin/gosu \ && gosu nobody true # 设置 CMD,并以另外的用户执行 CMD [ "exec", "gosu", "redis", "redis-server" ] |
HEALTHCHECK 健康检查
HEALTHCHECK在Docker 1.12版本引入,用来告诉Docker如何判断容器的状态是否正常。
- HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
- HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
在没有HEALTHCHECK指令前,Dcoker引擎只能通过容器内主进程是否退出来判断容器是否状态异常,很多情况下这样是没问题的,但是如果一旦进入死锁、死循环这种应用程序不会退出的情况,就没办法了。
在1.12以前,Docker无法检查容器的这种状态,从而不会重新调度,导致可能部分容器已经无法正常提供服务了,却还在接受用户请求。
从1.12版本以后,Docker提供了HEALTHCHECK指令,通过该指令指定一行命令,用这行命令判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当一个镜像指定了HEALTHCHECK指令后,用其启动容器,初始状态会为starting,在HEALTHCHECK指令检查成功后变为healthy,如果连续一定次数失败,则会变为unhealthy。
HEALTHCHECK支持下列选项:
- –interval=<间隔>:两次健康检查的间隔,默认为30秒
- –timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间本次检查就会被视为失败,默认30秒。
- –retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认3次。
HEALTHCHECK 的 <命令> 分为shell格式和exec格式,命令的返回值决定了健康检查的成功与否,0表示成功,1表示失败,2表示保留,不要使用这个值。
如果有多个HEALTHCHECK,那么和CMD、ENTRYPOINT一样,只有最后一个生效。
假设我们有个镜像是最简单的web服务,我们希望增加健康检查来判断web服务是否正常工作,我们可以用curl来帮助判断,Dockerfile里的HEALTHCHECK可以这么写:
1 2 3 4 5 6 7 8 |
FROM nginx RUN apt-get update \ && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5s --timeout=3s \ CMD curl -fs http://localhost/ || exit 1 |
这里我们设置每5秒检查1次,超过3秒则视为失败,并且使用 curl -fs http://localhost/ || exit 1 作为检查命令。
使用 docker build 构建这个镜像:
1 |
[xiong@AMDServer Docker]$ docker build -t myweb:v1 myweb/ |
然后启动一个容器:
1 |
[xiong@AMDServer Docker]$ docker run -d --name web -p 5004:80 myweb:v1 |
再运行命令 docker container ls 查看健康状态变化:
1 2 3 4 5 6 |
[xiong@AMDServer Docker]$ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 8616d61164a9 myweb:v1 "/docker-entrypoint.…" 26 seconds ago Up 24 seconds (healthy) 0.0.0.0:5004->80/tcp web 9b648de65501 nginx:v2 "/docker-entrypoint.…" 2 days ago Up 2 days 0.0.0.0:5003->80/tcp web2 6fd64857cabc nginx "/docker-entrypoint.…" 2 days ago Up 2 days 0.0.0.0:5002->80/tcp webserver 8db7611ba42c ubuntu "/bin/bash" 3 days ago Up 3 days heuristic_kilby |
如果健康检查连续失败超过重试次数,状态就会变为unhealthy。
健康检查命令的输出都会被存储在健康状态里(包括stdout和stderr信息),可以用docker inspect 命令来查看。
1 2 3 4 5 6 7 8 9 10 |
[xiong@AMDServer Docker]$ docker inspect --help Usage: docker inspect [OPTIONS] NAME|ID [NAME|ID...] Return low-level information on Docker objects Options: -f, --format string Format the output using the given Go template -s, --size Display total file sizes if the type is container --type string Return JSON for specified type |
我这边输出的信息有点多,就只给出命令了:
1 |
[xiong@AMDServer Docker]$ docker inspect --format '{{json .State.Health}}' web | python -m json.tool |
ONBUILD 构建下层镜像时执行指令
ONBUILD是一个特殊指令,它后面跟着其他指令:
格式就是: ONBUILD <指令>
这里的指令可以是 RUN、COPY等等。
ONBUILD表明这些指令在当前镜像构建时不会执行,仅在以当前镜像为基础构建的下级镜像中才会执行。
举个例子,我们在镜像A中指定了ONBUILD指令(称为OA),镜像B FROM 镜像A,那么构造镜像B时就会执行我们在A中指定的指令OA,但是如果镜像C FROM 镜像B,构造C的时候是不会执行镜像A中的OA指令,但是如果镜像B中有ONBUILD指令OB,那么镜像C会执行指令OB。
Dockerfile中的指令大都是为定制当前镜像准备的,唯有ONBUILD是为了下层镜像准备的。
这里以Node.js项目为例,理解ONBUILD指令的用法。
Node.js使用npm进行包管理,所有的依赖、配置、启动信息都会放到package.json文件里。在拿到程序代码后,需要先进行npm install 才可以获得所有需要的依赖,然后再通过npm start来启动应用。
因此,我们会这样写Dockerfile:
1 2 3 4 5 6 7 |
FROM node:slim RUN mkdir /app WORKDIR /app COPY ./package.json /app RUN [ "npm", "install" ] COPY . /app/ CMD [ "npm", "start" ] |
然后我们将这个Dockerfile拷贝到我们的Node.js项目的根目录,构建镜像之后就可以运行了。
如果我们还有其他的Node.js项目,就再复制一份。
假设这个时候老板来了个变态需求,要求在 RUN mkdir /app 前面加上一行公司名字。
那完了,我们所有的项目都需要重新修改Dockerfile,构建镜像。
那我们能不能做一个基础镜像,我们各个Node.js项目基于这个基础镜像生成独立镜像,然后这种变态需求就可以更新在基础镜像里面,不需要依次修改Dockerfile,只需要重新构建就好了。
比如将我们的Dockerfile改成这样,构建基础镜像my-node:
1 2 3 4 5 6 7 8 |
FROM node:slim RUN echo "company" RUN mkdir /app WORKDIR /app COPY ./package.json /app RUN [ "npm", "install" ] COPY . /app/ CMD [ "npm", "start" ] |
这里我们在执行 RUN mkdir /app 前加入了 RUN echo “company” 完成了老板的要求。
然后我我们基于这个基础镜像my-node生成自己的镜像,Dockerfile就很简单:
1 |
FROM my-node |
这样看起来OK吗?好像可以,但是实际上有问题。
这样构建出来的镜像使用的 ./package.json 文件并不是我们项目的文件,而是当时我们生成这个基础镜像时使用的 package.json 文件。
我们需要将后面与项目相关的三行指令放到项目镜像Dockerfile中去。
基础镜像my-node的Dockerfile如下:
1 2 3 4 5 |
FROM node:slim RUN echo "company" RUN mkdir /app WORKDIR /app CMD [ "npm", "start" ] |
项目的Dockerfile如下:
1 2 3 4 |
FROM my-node COPY ./package.json /app RUN [ "npm", "install" ] COPY . /app/ |
这样我们轻松地完成了老板的需求,又能够使用自己的项目文件用于生成镜像。
看起来好像OK了?不不不,新修改又来了!!!
这次我们要修改 RUN [ “npm”, “install” ] 命令,加上一个编译参数。别管加什么参数,反正就是要加吧(主要是我也不会Node.js…胡编乱造不好)
那现在没办法,难道又要一个项目接一个项目地去修改Dockerfile文件?
我们最初的目的不就是:不愿意依次修改项目的Dockerfile文件吗?
于是我们使用ONBUILD命令重写一下基础镜像my-node的Dockerfile文件:
1 2 3 4 5 6 7 8 |
FROM node:slim RUN echo "company" RUN mkdir /app WORKDIR /app ONBUILD COPY ./package.json /app ONBUILD RUN [ "npm", "install" ] ONBUILD COPY . /app/ CMD [ "npm", "start" ] |
使用 ONBUILD 表明在构建基础镜像的时候这些指令不会运行,基于基础镜像再构建新镜像的时候它们才会执行。前面我们提到的对 RUN [ “npm”, “install” ] 命令的修改也可以在基础镜像中修改。
我们项目的Dockerfile就变成了简单的:
1 |
FROM my-node |
只需要这样一行,就既实现了老板修改的需求,又能够在构建镜像的时候成功地将当前项目的代码复制进镜像,执行 npm install命令,生成应用镜像。
一句话总结就是:通过ONBUILD命令,让拷贝的文件都是我们自己项目的文件。
四、Dockerfile多阶段构建
在Docker 17.05版本以前,我们构建Docker镜像时,通常会采用两种方式:
- 全部放入一个Dockerfile
- 分散到多个Dockerfile
全部放入一个Dockerfile的问题是镜像层次多,体积大,部署时间长,而且源代码存在泄漏风险。
单文件构建方式
比如我们以构建go语言 grpc网络Hello程序为例,编写 helloservice.go 文件,该程序监听5001网络端口提供服务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package main import ( "log" "net" "net/rpc" "net/rpc/jsonrpc" ) const HelloServiceName = "HelloService" type HelloServiceInterface = interface { Hello(request string, reply *string) error } func RegisterHelloService(service HelloServiceInterface) error { return rpc.RegisterName(HelloServiceName, service) } type HelloService struct{} func (p *HelloService) Hello(request string, reply *string) error { *reply = "hello: " + request return nil } func main() { RegisterHelloService(new(HelloService)) listener, err := net.Listen("tcp", ":5001") if err != nil { log.Fatal("ListenTCP error:", err) } for { conn, err := listener.Accept() if err != nil { log.Fatal("Accept error:", err) } go rpc.ServeCodec(jsonrpc.NewServerCodec(conn)) } } |
基于上面的程序,我们编写一个dockerfile运行它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FROM golang:1.9-alpine RUN apk --no-cache add git ca-certificates WORKDIR /go/src/github.com/go/helloworld/ COPY helloservice.go . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . \ && cp /go/src/github.com/go/helloworld/app /root WORKDIR /root/ CMD ["./app"] |
以这种方式构建镜像,时间较长,而且生成的镜像大小也很大。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
[xiong@AMDServer Docker]$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE go/helloservice 1 7c55179eac97 5 hours ago 271MB myweb v1 a583d1d3cbf7 9 days ago 132MB myip latest 9f2b93230f4e 10 days ago 90MB nginx v3 3ee4b531ad4d 11 days ago 132MB nginx v2 9bc7f84b8f15 11 days ago 132MB redis latest 1319b1eaa0b7 13 days ago 104MB nginx latest 08393e824c32 13 days ago 132MB ubuntu latest 1e4467b07108 3 weeks ago 73.9MB tkxiong/ubuntu latest 1e4467b07108 3 weeks ago 73.9MB golang 1.9-alpine b0260be938c6 24 months ago 240MB training/webapp latest 6fae60ef3446 5 years ago 349MB |
看第一行就是我们最新生成的镜像,它的大小是271MB。
如果想运行这个镜像,对应的命令应该是:
1 |
docker run -d -p 5001:5001 go/helloservice:1 |
另外附上测试客户端代码,可能需要做一定修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
package main import ( "fmt" "log" "net" "net/rpc" "net/rpc/jsonrpc" "os" ) const HelloServiceName = "HelloService" func main() { //虚拟机服务器ip是192.168.1.101, 本机IP地址是102 //这个版本用Json作为客户端服务端交换数据格式 address := "192.168.1.101" port := "1234" if len(os.Args) > 1 { address = os.Args[1] } if len(os.Args) > 2 { port = os.Args[2] } fmt.Println("Server Address: ", address+":"+port) conn, err := net.Dial("tcp", address+":"+port) if err != nil { log.Fatal("net.Dial: ", err) } client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn)) var reply string err = client.Call( HelloServiceName+".Hello", "NewJsonClient, ip:192.168.1.102", &reply) if err != nil { log.Fatal(err) } fmt.Println(reply) } |
这里可以手动修改服务端ip地址端口号,也可以通过命令行输入进来。
多文件构建方式
或者我们可以采用多个Dockerfile,一个Dockerfile负责将项目及其依赖库编译测试打包,另一个Dockerfile负责运行。
这样的话,我们需要两个Dockerfile和一个部署脚本文件,脚本文件负责将两个阶段整合起来。
helloservice.go 文件不做修改。
Dockerfile.build负责编译项目,内容如下:
1 2 3 4 5 6 7 8 9 |
FROM golang:1.9-alpine RUN apk --no-cache add git WORKDIR /go/src/github.com/go/helloworld COPY app.go . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . |
还需要一个Dockerfile.copy负责拷贝文件,运行程序,内容如下:
1 2 3 4 5 6 7 8 9 |
FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY app . CMD ["./app"] |
然后我们用一个build.sh文件将这两个步骤整合。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/bin/sh echo Building go/helloservice:build docker build -t go/helloservice:build . -f Dockerfile.build docker create --name extract go/helloservice:build docker cp extract:/go/src/github.com/go/helloworld/app ./app docker rm -f extract echo Building go/helloservice:2 docker build --no-cache -t go/helloservice:2 . -f Dockerfile.copy rm ./app |
接下来我们给脚本加上可执行权限,运行它。
1 2 |
$ chmod +x build.sh $ ./build.sh |
P.s. 这里我碰上了一个问题,需要修改Docker的镜像源,修改方法看前面的基础教程,仓库部分。
可以使用 docker image ls 命令查看我们生成的镜像大小。
1 2 3 4 5 6 |
[xiong@AMDServer gohello2]$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE go/helloservice 2 9a3dc58466fc About a minute ago 14.1MB go/helloservice build f12ab53ae1d3 47 minutes ago 263MB go/helloservice 1 7c55179eac97 7 hours ago 271MB alpine latest a24bb4013296 2 months ago 5.57MB |
最终我们使用的应该是 go/helloservice:2 ,它的大小是 14.1MB。
可以看到,alpine的大小是6MB,那么多出来的8MB是什么呢?
1 2 3 4 5 6 7 8 9 |
[xiong@AMDServer Docker]$ ls -alh gohello2/ total 7.8M drwxrwxr-x 2 xiong xiong 4.0K 8月 18 23:30 . drwxrwxr-x 8 xiong xiong 4.0K 8月 18 21:04 .. -rwxr-xr-x 1 xiong xiong 7.7M 8月 18 22:50 app -rwxrwxr-x 1 xiong xiong 349 8月 18 21:09 build.sh -rw-rw-r-- 1 xiong xiong 187 8月 18 21:10 Dockerfile.build -rw-rw-r-- 1 xiong xiong 101 8月 18 21:07 Dockerfile.copy -rw-rw-r-- 1 xiong xiong 771 8月 18 21:17 helloservice.go |
这里存在的问题就是文件太多,如果可以的话,还是希望一个Dockerfile完成所有。
然后运行这个程序的方式:
1 2 3 4 5 6 7 8 |
[xiong@AMDServer Docker]$ docker run -d -p 5001:5001 go/helloservice:2 ddc5353a20f2bab40aee93c1b6fcf0d5f429e9fc04d5a78451b6d739b6a9f793 [xiong@AMDServer Docker]$ docker run -d -p 5002:5001 go/helloservice:2 1334a90a20aa30cc7deb1fcb553969f7643e512cc6bbbb5bb9b9e8b292551c36 [xiong@AMDServer Docker]$ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 1334a90a20aa go/helloservice:2 "./app" 19 seconds ago Up 18 seconds 0.0.0.0:5002->5001/tcp naughty_spence ddc5353a20f2 go/helloservice:2 "./app" About a minute ago Up About a minute 0.0.0.0:5001->5001/tcp trusting_gates |
我运行了两个容器,都用来跑这个程序。
接下来是我的客户端程序,它运行在Windows环境:
1 2 3 4 5 6 7 |
[F:\Code\AdvancedGo\ch4]$ go run helloJsonClient.go 192.168.1.101 5001 Server Address: 192.168.1.101:5001 hello: NewJsonClient, ip:192.168.1.102 [F:\Code\AdvancedGo\ch4]$ go run helloJsonClient.go 192.168.1.101 5002 Server Address: 192.168.1.101:5002 hello: NewJsonClient, ip:192.168.1.102 |
一切OK…那就开始下一阶段,一个Dockerfile完成所有。
Docker的多阶段构建
Docker自从v17.05版本开始支持多阶段构建(multistage builds)。
使用多阶段构建,就可以解决前面提到的一系列问题,并且只需要编写一个Dockerfile。
我基于原文精简了一些东西,编译阶段修改如下:
- 修改基础builder为 golang:alpine,它拥有最新的golang v1.15
- 对于 golang:alpine 环境,应该自带有git,不需要安装
- 工作路径修改为 $GOPATH/src ,它是 GO111MODULE=”auto” 下的工作路径
- 不需要的mysql驱动,不执行go get
- 编译参数不需要那么多,修改为 RUN go build -o app . 即可。
运行阶段修改如下:
- ca-certificates是CA证书,还是正常安装吧。
- COPY语句可以改为 –from=builder
- 编译阶段工作路径为 $GOPATH/src ,生成的app文件在builder的/go/src目录下
原文Dockerfile内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
FROM golang:1.9-alpine as builder RUN apk --no-cache add git WORKDIR /go/src/github.com/go/helloworld/ RUN go get -d -v github.com/go-sql-driver/mysql COPY app.go . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM alpine:latest as prod RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=0 /go/src/github.com/go/helloworld/app . CMD ["./app"] |
修改后Dockerfile如下(我使用的是这一版本):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
FROM golang:alpine as builder WORKDIR $GOPATH/src COPY helloservice.go . RUN go build -o app . FROM alpine:latest as prod RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /go/src/app . CMD ["./app"] |
执行命令,构建镜像
1 |
$ docker build -t go/helloservice:3 . |
命令 docker images 查看对比3个镜像的大小。
1 2 3 4 5 6 7 |
[xiong@AMDServer gohello3]$ docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE <none> <none> 33311504896e 6 seconds ago 410MB go/helloservice 3 d9fea92e90de 6 seconds ago 13.5MB go/helloservice 2 9a3dc58466fc 36 hours ago 14.1MB go/helloservice build f12ab53ae1d3 37 hours ago 263MB go/helloservice 1 7c55179eac97 44 hours ago 271MB |
这里忽然多了一个虚悬镜像,创建时间是6秒前,就是刚刚生成的,它对应的应该是 go/helloservice:build 这个镜像吧,就是编译阶段的镜像。
很明显使用多阶段构建的镜像体积小(比build2更小是因为换了编译源),同时也完美解决了前面的问题,删除虚悬镜像可以用 docker image prune 命令。
最后讲解两个命令
构建某一阶段的镜像:
$ docker build –target builder -t go/helloservice:builder .
它可以单独构建 builder阶段的镜像。比如之前我修改Dockerfile的时候,没有找到builder阶段的gopath路径,就先构建了builder然后进入bash,去查找app文件位置。
从其他镜像复制文件:
COPY –from=builder /go/src/app .
它的意思是从builder镜像拷贝文件到当前镜像。
我们也可以从其他镜像复制文件过来,比如:
COPY –from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
OK。 以上是Dockerfile相关的全部内容。