前面我们已经介绍了如何拉取已经构建好的带有定制内容的Docker镜像,那么如何构建自己的镜像呢?
构建Docker镜像有以下两种方法:
- 使用docker commit命令。
- 使用docker build命令和 Dockerfile 文件。
在这里并不推荐使用docker commit来构建镜像,而应该使用更灵活、更强大的Dockerfile来构建Docker镜像。但是为了对Docker有一个更全面的了解,还是会先介绍以下如何使用docker commit构建Docker镜像。之后将重点介绍Docker所推荐的镜像构建方法:编写Dockerfile之后使用docker build命令。
一般来说,我们不是真正的“创建”新镜像,而是基于一个已有的基础镜像,如ubuntu或centos等,构建新镜像而已。如果真的想从零构建一个全新的镜像,也可以参考https://docs.docker.com/engine/userguide/eng-image/baseimages/。
通过commit命令创建镜像
docker commit 构建镜像可以想象为是在往版本控制系统里提交变更。我们先创建一个容器,并在容器里做出修改,就像修改代码一样,最后再将修改提交为一个镜像。
# docker run -i -t ubuntu /bin/bash root@b437ffe4d630:/# apt-get -yqq update root@b437ffe4d630:/# apt-get -y install apache2
我们启动了一个容器,并在里面安装了Apache。我们会将这个容器作为一个Web服务器来运行,所以我们想把它的当前状态保存下来。这样我们就不必每次都创建一个新容器并再次在里面安装Apache了。为了完成此项工作,需要先使用exit命令从容器里退出,之后再运行docker commit命令:
# docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES b437ffe4d630 ubuntu "/bin/bash" 45 minutes ago Exited (0) 10 seconds ago clever_pare b87f9dde62b0 devopsil/puppet "/bin/bash" 2 days ago Up 2 days evil_archimedes # docker commit b437ffe4d630 test/apache2 9c30616364f44a519571709690e3c92a5cad4ad01c007d8126eb6d63670d33f4 # docker images test/apache2 REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE test/apache2 latest 9c30616364f4 36 seconds ago 254.4 MB
在使用docker commit命令中,指定了要提交的修改过的容器的ID(可以通过docker ps命令得到刚创建的容器ID),以及一个目标镜像仓库和镜像名,这里是test/apahce2。需要注意的是,docker commit提交的只是创建容器的镜像与容器的当前状态之间有差异的部分,这使得该更新非常轻量。通过docker images 可以查看新创建的镜像信息。
也可以在提交镜像时指定更多的数据(包括标签)来详细描述所做的修改。
# docker commit -m="A new custom image" --author="Bourbon Tian" b437ffe4d630 test/apache2:webserver 27fc508c41d1180b1a421380d755cf00f9dfb6b0d354b9eccaec94ae58a06675
这条命令里,我们指定了更多的信息选项:
- -m 用来指定创建镜像的提交信息;
- --author 用来列出该镜像的作者信息;
- 最后在test/apache2后面增加了一个webserver标签。
通过使用docker inspect命令来查看新创建的镜像的详细信息:
# docker inspect test/apache2:webserver [ { "Id": "27fc508c41d1180b1a421380d755cf00f9dfb6b0d354b9eccaec94ae58a06675", "Parent": "f5bb94a8fac47aaf15fb4e4ceb138d59ac2fcf004cd3f277cebe2174fd7a6c70", "Comment": "A new custom image", "Created": "2017-05-17T07:29:46.000512241Z", "Container": "b437ffe4d63047dd34653f5256bb6eda54acfd3db99f72f2262a9b9af7f31334", ...
如果想从刚创建的镜像运行一个容器,可以使用docker run命令:
# docker run -t -i test/apache2:webserver /bin/bash
创建Dockerfile文件
下面将介绍如何通过Dockerfile的定义文件和docker build命令来构建镜像。
Dockerfile使用基本的基于DSL语法的指令来构建一个Docker镜像,之后使用docker build命令基于该Dockerfile中的指令构建一个新的镜像。
# mkdir /opt/static_web # cd /opt/static_web/ # vim Dockerfile
首先创建一个名为static_web的目录用来保存Dockerfile,这个目录就是我们的构建环境(build environment),Docker则称此环境为上下文(context)或者构建上下文(build context)。Docker会在构建镜像时将构建上下文和该上下文中的文件和目录上传到Docker守护进程。这样Docker守护进程就能直接访问你想在镜像中存储的任何代码、文件或者其他数据。这里我们还创建了一个Dockerfile文件,我们将用它构建一个能作为Web服务器的Docker镜像。
# Version: 0.0.1 FROM ubuntu:latest MAINTAINER Bourbon Tian "bourbon@1mcloud.com" RUN apt-get update RUN apt-get install -y nginx RUN echo 'Hi, I am in your container' > /usr/share/nginx/html/index.html EXPOSE 80
Dockerfile由一系列指令和参数组成。每条指令都必须为大写字母,切后面要跟随一个参数。Dockerfile中的指令会按照顺序从上到下执行,所以应该根据需要合理安排指令的顺序。每条指令都会创建一个新的镜像层并对镜像进行提交。Docker大体上按照如下流程执行Dockerfile中的指令。
- Docker从基础镜像运行一个容器。
- 执行第一条指令,对容器进行修改。
- 执行类似docker commit的操作,提交一个新的镜像层。
- Docker再基于刚提交的镜像运行一个新的容器。
- 执行Dockerfile中的下一条命令,直到所有指令都执行完毕。
从上面可以看出,如果你的Dockerfile由于某些原因(如某条指令失败了)没有正常结束,那你也可以得到一个可以使用的镜像。这对调试非常有帮助:可以基于该镜像运行一个具备交互功能的容器,使用最后创建的镜像对为什么你的指令会失败进行调试。
Dockerfile也支持注释。以#开头的行都会被认为是注释,# Version: 0.0.1这就是个注释
FROM:
每个Dockerfile的第一条指令都应该是FROM。FROM指令指定一个已经存在的镜像,后续指令都是将基于该镜像进行,这个镜像被称为基础镜像(base iamge)。在这里ubuntu:latest就是作为新镜像的基础镜像。也就是说Dockerfile构建的新镜像将以ubuntu:latest操作系统为基础。在运行一个容器时,必须要指明是基于哪个基础镜像在进行构建。
MAINTAINER:
MAINTAINER指令,这条指令会告诉Docker该镜像的作者是谁,以及作者的邮箱地址。这有助于表示镜像的所有者和联系方式
RUN:
在这些命令之后,我们指定了三条RUN指令。RUN指令会在当前镜像中运行指定的命令。这里我们通过RUN指令更新了APT仓库,安装nginx包,并创建了一个index.html文件。像前面说的那样,每条RUN指令都会创建一个新的镜像层,如果该指令执行成功,就会将此镜像层提交,之后继续执行Dockerfile中的下一个指令。
默认情况下,RUN指令会在shell里使用命令包装器/bin/sh -c 来执行。如果是在一个不支持shell的平台上运行或者不希望在shell中运行(比如避免shell字符串篡改),也可以使用exec格式的RUN指令,通过一个数组的方式指定要运行的命令和传递给该命令的每个参数:
RUN ["apt-get", "install", "-y", "nginx"]
EXPOSE:
EXPOSE指令是告诉Docker该容器内的应用程序将会使用容器的指定端口。这并不意味着可以自动访问任意容器运行中服务的端口。出于安全的原因,Docker并不会自动打开该端口,而是需要你在使用docker run运行容器时来指定需要打开哪些端口。
可以指定多个EXPOSE指令来向外部公开多个端口,Docker也使用EXPOSE指令来帮助将多个容器链接,在后面的学习过程中我们会接触到。
基于Dockerfile构建新镜像
执行docker build命令时,Dockerfile中的所有指令都会被执行并且提交,并且在该命令成功结束后返回一个新镜像。
# cd static_web # docker build -t="test/static_web" . Sending build context to Docker daemon 2.048 kB Sending build context to Docker daemon ... Successfully built 94728651ce15
- -t选项为新镜像设置了仓库和名称,这里仓库为test,镜像名为static_web。建议为自己的镜像设置合适的名字方便以后追踪和管理
也可以在构建镜像的过程当中为镜像设置一个标签:
# docker build -t="test/static_web:v1" .
上面命令中最后的“.”告诉Docker到当前目录中去找Dockerfile文件。也可以指定一个Git仓库地址来指定Dockerfile的位置,这里Docker假设在Git仓库的根目录下存在Dockerfile文件:
# docker build -t="test/static_web:v1" git@github.com:test/static_web
再回到docker build过程。可以看到构建上下文已经上传到Docker守护进程:
Sending build context to Docker daemon 2.048 kB Sending build context to Docker daemon
提示:如果在构建上下文的根目录下存在以.dockerignore命名的文件的话,那么该文件内容会被按行进行分割,每一行都是一条文件过滤匹配模式。这非常像.gitignore文件,该文件用来设置哪些文件不会被上传到构建上下文中去。该文件中模式的匹配规则采用了Go语言中的filepath。
之后,可以看到Dockerfile中的每条指令会被顺序执行,而作为构建过程中最终结果,返回了新镜像的ID,即94728651ce15。构建的每一步及其对应指令都会独立运行,并且在输出最终镜像ID之前,Docker会提交每步的构建结果。
指令失败时会怎样?
假设我们将安装的软件包名字弄错,比如写成ngin,再次运行docker build:
# docker build -t="test/static_web" . Sending build context to Docker daemon 2.048 kB Sending build context to Docker daemon Step 0 : FROM ubuntu:latest ---> f5bb94a8fac4 Step 1 : MAINTAINER Bourbon Tian "bourbon@1mcloud.com" ---> Using cache ---> ce64f2e75a74 Step 2 : RUN apt-get update ---> Using cache ---> e98d2c152d1d Step 3 : RUN apt-get install -y ngin ---> Running in 2f16c5f11250 Reading package lists... Building dependency tree... Reading state information... E: Unable to locate package ngin The command '/bin/sh -c apt-get install -y ngin' returned a non-zero code: 100
这时我们需要调试一下这次失败,我们可以通过docker run命令来基于这次构建到目前为止已经成功的最后一步创建一个容器,这里它的ID是e98d2c152d1d:
# docker run -t -i e98d2c152d1d /bin/bash root@55aee4322f77:/# apt-get install -y ngin Reading package lists... Done Building dependency tree Reading state information... Done E: Unable to locate package ngin
再次运行出错的指令apt-get install -y ngin,发现这里没有找到ngin包,我们执行安装nginx包时,包命输错了。这时退出容器使用正确的包名修改Dockerfile文件,之后再尝试进行构建。
构建缓存:
在上面执行构建镜像的过程中,我们发现当执行apt-get update时,返回Using cache。Docker会将之前的镜像层看做缓存,因为在安装nginx前并没有做其他的修改,因此Docker会将之前构建时创建的镜像当做缓存并作为新的开始点。然后,有些时候需要确保构建过程不会使用缓存。可以使用docker build 的 --no-cache标志。
# docker build --no-cache -t="test/static_web" .
构建缓存带来的一个好处就是,我们可以实现简单的Dockerfile模板(比如在Dockerfile文件顶部增加包仓库或者更新包,从而尽可能确保缓存命中)。
# cat Dockerfile # Version: 0.0.1 FROM ubuntu:latest MAINTAINER Bourbon Tian "bourbon@1mcloud.com" ENV REFRESHED_AT 2017-05-18 RUN apt-get update RUN apt-get install -y nginx RUN echo 'Hi, I am in your container' > /usr/share/nginx/html/index.html EXPOSE 80
- ENV 在镜像中设置环境变量,在这里设置了一个名为REFRESHED_AT的环境变量,这个环境变量用来表明该镜像模板最后的更新时间,这样只需要修改ENV指令中的日期,这使Docker在命中ENV指令时开始重置这个缓存,并运行后续指令而无需依赖该缓存。也就是说,RUN apt-get update这条指令将会被再次执行,包缓存也将会被刷新为最新内容。
查看新镜像:
现在来看一下新构建的镜像,使用docker image命令,如果想深入探求镜像如何构建出来的,可以使用docker history命令看到新构建的test/static_web镜像的每一层,以及创建这些层的Dockerfile指令。
# docker images test/static_web REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE test/static_web latest 94728651ce15 20 hours ago 212.1 MB # docker history 94728651ce15 IMAGE CREATED CREATED BY SIZE COMMENT 94728651ce15 20 hours ago /bin/sh -c #(nop) EXPOSE 80/tcp 0 B 09e999b131f4 20 hours ago /bin/sh -c echo 'Hi, I am in your container' 27 B 4af2ef04fb91 20 hours ago /bin/sh -c apt-get install -y nginx 56.52 MB e98d2c152d1d 20 hours ago /bin/sh -c apt-get update 38.29 MB ...
从新镜像启动容器
下面基于新构建的镜像启动一个新容器,来检查之前的构建工作是否一切正常:
# docker run -d -p 80 --name static_web test/static_web nginx -g "daemon off;" a4ad951b2ef91275bb918d11964d7d60889608efa3958e699030d38a681ba35e
- -d选项,告诉Docker以分离(detached)的方式在后台运行。这种方式非常适合运行类似Nginx守护进程这样的需要长时间运行的进程。
- 这里也指定了需要在容器中运行的命令:nginx -g "daemon off;"。这将以前台运行的方式启动Nginx,来作为我们的Web服务器。
- -p选项,控制Docker在运行时应该公开哪些网络端口给外部(宿主机)。运行一个容器时,Docker可通过两种方法在宿主机上分配端口。
- Docker可以在宿主机上通过/proc/sys/net/ipv4/ip_local_port_range文件随机一个端口映射到容器的80端口。
- 可以在Docker宿主机中指定一个具体的端口号来映射到容器的80端口上。
这将在Docker宿主机上随机打开一个端口,这个端口会连接到容器中的80端口上。可以使用docker ps命令查看容得的端口分配情况:
# docker ps -l CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 0b422bbcce10 test/static_web "nginx -g 'daemon of 5 seconds ago Up 5 seconds 0.0.0.0:32772->80/tcp static_web
如果没有启动成功,则通过交互的方式进入我们新创建的镜像中,尝试启动nginx,通过分析错误日志查出不能正常启动的原因,在这里我遇到的问题是:
nginx: [emerg] socket() [::]:80 failed (97: Address family not supported by protocol)
我们需要删除/etc/nginx/sites-enabled/default 中 listen [::]:80 ipv6only=on default_server;定位到问题,我们退出容器,重新修改我们的Dockerfile:
# Version: 0.0.1 FROM ubuntu:latest MAINTAINER Bourbon Tian "bourbon@1mcloud.com" ENV REFRESHED_AT 2017-05-18 RUN apt-get update RUN apt-get install -y nginx RUN echo 'Hi, I am in your container' > /usr/share/nginx/html/index.html RUN sed -i '22d' /etc/nginx/sites-enabled/default EXPOSE 80
重新尝试构建我们的容器,再次启动我们新建的容器,通过docker ps -l查看是否正常启动了。
我们也可以通过docker port 来查看容器的端口映射情况:
# docker port 0b422bbcce10 80 0.0.0.0:32772
在上面的命令中我们指定了想要查看映射情况的容器ID和容器的端口号,这里是80。该命令返回了宿主机中映射的端口,即32772。
-p选项还让我们可以灵活地管理容器和宿主机之间的端口映射关系。比如,可以指定将容器中的端口映射到Docker宿主机的某一个特定的端口上:
# docker run -d -p 80:80 --name static_web test/static_web nginx -g "daemon off;" ee09ef811a9865d9bd50c71b3ddcbd414194031f14145fdbaf339d92e3ccd1bd
上面的命令会将容器内的80端口绑定到本地宿主机的80端口上。我们也可以将端口绑定限制在特定的网络接口(即ip地址)上:
# docker run -d -p 127.0.0.1:80:80 --name static_web test/static_web nginx -g "daemon off;"
我们也可以使用类似的方法将容器内的80端口绑定到一个特定网络接口的随机端口上:
# docker run -d -p 127.0.0.1::80 --name static_web test/static_web nginx -g "daemon off;"
Docker还提供了一个更简单的方式,即-P参数,该参数可以用来对外公开在Dockfile中的EXPOSE指令中设置的所有端口:
# docker run -d -P --name static_web test/static_web nginx -g "daemon off;" 4fd632e975ad5e47a487e5e23790124da0826886dc24b2497a561d274e4e698e # docker ps -l CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 4fd632e975ad test/static_web "nginx -g 'daemon of 4 seconds ago Up 3 seconds 0.0.0.0:32773->80/tcp static_web
该命令会将容器内的80端口对本地宿主机公开,并且绑定到宿主机的一个随机端口上。该命令会将用来构建该镜像的Dockerfile文件中EXPOSE指令指定的其他端口也一并公开。
# curl localhost:32773 Hi, I am in your container
到这,就完成了一个非常简单的基于Docker的Web服务器。
删除镜像
我们可以通过docker rmi命令来删除一个镜像
# docker rmi test/webapp Untagged: test/webapp:latest Deleted: 36ae30d2e972f6651b29127266d68783290e3a861b974c5a491e04ae7e9a9d3d Deleted: 8cecce09465bc0f2679fd96e1c6e1af03af9c4589b62113d319f24ca969d9164 Deleted: 29c803cce363f84801bd8b8c768bba8767c37947e803c8ae58541d163622ccfa Deleted: 92a79034071552c09f45ffb1afc455150edc438d4c7da48b28ca6a2dba44d15b
这里我们删除了test/webapp(在附录Dockerfile指令这个章节中构建的)镜像。在这里也可以看到Docker的分层文件系统:每个Deleted都代表一个镜像层被删除。该操作只会将本地的镜像删除。如果我们想删除本地的所有镜像可以像这样:
# docker rmi `docker images -a -q`
运行自己的Docker Registry
前面我们已经介绍了Docker有公共的Docker Registry就是Docker Hub。但是有时我们可能希望构建和存储包含不想被公开的信息或数据的镜像。这时候我们有以下两种选择:
- 利用Docker Hub上的私有仓库;
- 在防火墙后面运行自己的Registry。
从Docker容器安装一个Registry非常简单
## 拉去registry镜像 # docker pull registry ## 搭建本地镜像源 # docker run -d -v /opt/registry:/var/lib/registry -p 5000:5000 --restart=always --name registry registry:latest ## 查看容器状态 # docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES f570fab5d67d registry:latest "/entrypoint.sh /etc 3 seconds ago Up 3 seconds 0.0.0.0:5000->5000/tcp registry
接下来将我们的镜像上传到本地的Docker Registry
## 找到我们要上传的镜像 # docker images test/apache2 REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE test/apache2 latest 9c30616364f4 7 days ago 254.4 MB ## 使用新的Registry给该镜像打上标签 # docker tag 9c30616364f4 docker.example.com:5000/test/apache2 ## 通过docker push 命令将它推送到新的Registry中去 # docker push docker.example.com:5000/test/apache2 The push refers to a repository [docker.example.com:5000/test/apache2] (len: 1) 9c30616364f4: Image already exists f5bb94a8fac4: Image successfully pushed 2e36b30057ab: Image successfully pushed 0346cecb4e51: Image successfully pushed 274da7f89b05: Image successfully pushed b5ce920a148c: Image successfully pushed 576b12d1aa01: Image successfully pushed Digest: sha256:0c22a559f8dea881bca046e0ca27a01f73aa5f3c153b08b8bdf3306082e48b72 ## 测试我们上传的镜像 # docker run -it docker.example.com:5000/test/apache2 /bin/bash root@5088a0fd20e8:/#