-
更快的构建速度
-
更小的Docker镜像大小
-
更少的Docker镜像层
-
充分利用镜像缓存
-
增加Dockerfile可读性
-
让Docker容器使用起来更简单
总结
-
编写
.dockerignore
文件 -
容器只运行单个应用
-
将多个RUN指令合并为一个
-
基础镜像的标签不要用latest
-
每个RUN指令后删除多余文件
-
选择合适的基础镜像(alpine版本最好)
-
设置WORKDIR和CMD
-
使用ENTRYPOINT (可选)
-
在entrypoint脚本中使用exec
-
COPY与ADD优先使用前者
-
合理调整COPY与RUN的顺序
-
设置默认的环境变量,映射端口和数据卷
-
使用LABEL设置镜像元数据
-
添加HEALTHCHECK
-
多阶段构建
FROMubuntuADD./appRUNapt-getupdateRUNapt-getupgrade-yRUNapt-getinstall-ynodejssshmysqlRUNcd/app&&npminstall#thisshouldstartthreeprocesses,mysqlandssh#inthebackgroundandnodeappinforeground#isn'titbeautifullyterrible?<3CMDmysql&sshd&npmstart
构建镜像:
dockerbuild-twtf.
优化
1. 编写.dockerignore文件
.git/node_modules/
2. 容器只运行单个应用
-
非常长的构建时间(修改前端之后,整个后端也需要重新构建)
-
非常大的镜像大小
-
多个应用的日志难以处理(不能直接使用stdout,否则多个应用的日志会混合到一起)
-
横向扩展时非常浪费资源(不同的应用需要运行的容器数并不相同)
-
僵尸进程问题 - 你需要选择合适的init进程
FROMubuntuADD./appRUNapt-getupdateRUNapt-getupgrade-y#weshouldremovesshandmysql,anduse#separatecontainerfordatabaseRUNapt-getinstall-ynodejs#sshmysqlRUNcd/app&&npminstallCMDnpmstart
3. 将多个RUN指令合并为一个
Docker镜像是分层的,下面这些知识点非常重要:
-
Dockerfile中的每个指令都会创建一个新的镜像层。
-
镜像层将被缓存和复用
-
当Dockerfile的指令修改了,复制的文件变化了,或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效
-
某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效
-
镜像层是不可变的,如果我们再某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件(只是这个文件在Docker容器中不可见了)。
apt-get upgrade
删除,因为它会使得镜像构建非常不确定(我们只需要依赖基础镜像的更新就好了)FROMubuntuADD./appRUNapt-getupdate\&&apt-getinstall-ynodejs\&&cd/app\&&npminstallCMDnpmstart
记住一点,我们只能将变化频率一样的指令合并在一起。将node.js安装与npm模块安装放在一起的话,则每次修改源代码,都需要重新安装node.js,这显然不合适。因此,正确的写法是这样的:
FROMubuntuRUNapt-getupdate&&apt-getinstall-ynodejsADD./appRUNcd/app&&npminstallCMDnpmstart
4. 基础镜像的标签不要用latest
FROMubuntu:16.04#it'sthateasy!RUNapt-getupdate&&apt-getinstall-ynodejsADD./appRUNcd/app&&npminstallCMDnpmstart
5. 每个RUN指令后删除多余文件
FROMubuntu:16.04RUNapt-getupdate\&&apt-getinstall-ynodejs\#addedlines&&rm-rf/var/lib/apt/lists/*ADD./appRUNcd/app&&npminstallCMDnpmstart
6. 选择合适的基础镜像(alpine版本最好)
FROMnodeADD./app#wedon'tneedtoinstallnode#anymoreanduseapt-getRUNcd/app&&npminstallCMDnpmstart
FROMnode:7-alpineADD./appRUNcd/app&&npminstallCMDnpmstart
7. 设置WORKDIR和 CMD
FROMnode:7-alpineWORKDIR/appADD./appRUNnpminstallCMD["npm","start"]
8. 使用ENTRYPOINT (可选)
#!/usr/bin/envsh_#$0isascriptname,#2,$3etcarepassedarguments#1case"$CMD"in"dev")npminstallexportNODE_ENV=developmentexecnpmrundev;;"start")_#wecanmodifyfileshere,usingENVvariablespassedin_#"dockercreate"command.Itcan'tbedoneduringbuildprocess.echo"db:$DATABASE_ADDRESS">>/app/config.ymlexportNODE_ENV=productionexecnpmstart;;*)_#Runcustomcommand.Thankstothislinewecanstilluse_#"dockerrunour_image/bin/bash"anditwillworkexec{@:2};;esac
示例 Dockerfile:
FROM node:7-alpine
WORKDIR /app
ADD . /app
RUN npm install
ENTRYPOINT ["./entrypoint.sh"]
CMD ["start"]
可以使用如下命令运行该镜像:
_#运行开发版本_dockerrunour-appdev_#运行生产版本_dockerrunour-appstart_#运行bash_dockerrun-itour-app/bin/bash
9. 在entrypoint脚本中使用exec
init
系统不是必须的,当你通过命令
docker stop mycontainer
来停止容器时,docker CLI 会将 TERM 信号发送给 mycontainer 的 PID 为 1 的进程。-
如果 PID 1 是 init 进程- 那么 PID 1 会将 TERM 信号转发给子进程,然后子进程开始关闭,最后容器终止。
-
如果没有 init 进程- 那么容器中的应用进程(Dockerfile 中的ENTRYPOINT或CMD指定的应用)就是 PID 1,应用进程直接负责响应TERM信号。
这时又分为两种情况:
-
应用不处理 SIGTERM- 如果应用没有监听
SIGTERM
信号,或者应用中没有实现处理 SIGTERM 信号的逻辑,应用就不会停止,容器也不会终止。 -
容器停止时间很长- 运行命令
docker stop mycontainer
之后,Docker 会等待10s
,如果10s
后容器还没有终止,Docker 就会绕过容器应用直接向内核发送 SIGKILL,内核会强行杀死应用,从而终止容器。
(2).如果容器中的进程没有收到
SIGTERM
信号,很有可能是因为应用进程不是 PID 1,PID 1 是 shell,而应用进程只是 shell 的子进程。而 shell 不具备 init 系统的功能,也就不会将操作系统的信号转发到子进程上,这也是容器中的应用没有收到 SIGTERM 信号的常见原因。
问题的根源就来自 Dockerfile,例如:
FROMalpine:3.7COPYpopcorn.sh.RUNchmod+xpopcorn.shENTRYPOINT./popcorn.shCMD["start"]
方案 1:使用 exec 模式的 ENTRYPOINT 指令
与其使用 shell 模式,不如使用 exec 模式,例如:
FROMalpine:3.7COPYpopcorn.sh.RUNchmod+xpopcorn.shENTRYPOINT["./popcorn.sh"]
这样 PID 1 就是
./popcorn.sh
,它将负责响应所有发送到容器的信号,至于
./popcorn.sh
是否真的能捕捉到系统信号,那是另一回事。
举个例子,假设使用上面的 Dockerfile 来构建镜像,
popcorn.sh
脚本每过一秒打印一次日期:
#!/bin/sh
while true
do
date
sleep 1
done
构建镜像并创建容器:
dockerbuild-ttruek8s/popcorn.dockerrun-it--namecorny--rmtruek8s/popcorn
打开另外一个终端执行停止容器的命令,并计时:
timedockerstopcorny
popcorn.sh
并没有实现捕获和处理
SIGTERM
信号的逻辑,所以需要 10s 左右才能停止容器。要想解决这个问题,就要往脚本中添加信号处理代码,让它捕获到
SIGTERM
信号时就终止进程:#!/bin/sh#catchtheTERMsignalandthenexittrap"exit"TERMwhiletruedodatesleep1done
注意:下面这条指令与 shell 模式的 ENTRYPOINT 指令是等效的:
ENTRYPOINT["/bin/sh","./popcorn.sh"]
方案 2:直接使用 exec 命令
如果你就想使用
shell
模式的 ENTRYPOINT 指令,也不是不可以,只需将启动命令追加到
exec
后面即可,例如:
FROMalpine:3.7COPYpopcorn.sh.RUNchmod+xpopcorn.shENTRYPOINTexec./popcorn.sh
方案 3:使用 init 系统
SIGTERM
信号,又不能修改代码,这时候方案 1 和 2 都行不通了,只能在容器中添加一个
init
系统。init 系统有很多种,这里推荐使用 tini,它是专用于容器的轻量级 init 系统,使用方法也很简单:-
安装
tini
-
将
tini
设为容器的默认应用 -
将
popcorn.sh
作为tini
的参数
具体的 Dockerfile 如下:
FROMalpine:3.7COPYpopcorn.sh.RUNchmod+xpopcorn.shRUNapkadd--no-cachetiniENTRYPOINT["/sbin/tini","--","./popcorn.sh"]
现在
tini就是PID1,它会将收到的系统信号转发给子进程popcorn.sh
10. COPY与ADD优先使用前者
FROMnode:7-alpineWORKDIR/appCOPY./appRUNnpminstallENTRYPOINT["./entrypoint.sh"]CMD["start"]
11. 合理调整COPY与RUN的顺序
-
如果引用的父镜像在构建缓存中,下一个命令将会和所有从该父进程派生的子镜像做比较,如果有子镜像使用相同的命令,那么缓存命中,否则缓存失效。
-
在大部分情况下,通过比较
Dockerfile
中的指令和子镜像已经足够了。但是有些指令需要进一步的检查。
-
对于
ADD
和COPY
指令, 文件的内容会被检查,并且会计算每一个文件的校验码。但是文件最近一次的修改和访问时间不在校验码的考虑范围内。
在构建过程中,docker 会比对已经存在的镜像,只要有文件内容和元数据发生变动,那么缓存就会失效。
-
除了
ADD
和COPY
指令,镜像缓存不会检查容器中文件来判断是否命中缓存。 例如,在处理RUN apt-get -y update
命令时,不会检查容器中的更新文件以确定是否命中缓存,这种情况下只会检查命令字符串是否相同。
FROMnode:7-alpineWORKDIR/appCOPYpackage.json/appRUNnpminstallCOPY./appENTRYPOINT["./entrypoint.sh"]CMD["start"]
ROMpython:3.6#创建app目录WORKDIR/app#安装app依赖COPYsrc/requirements.txt./RUNpipinstall-rrequirements.txt#打包app源码COPYsrc/appEXPOSE8080CMD["python","server.py"]
12. 设置默认的环境变量,映射端口和数据卷
dockerfile FROM node:7-alpine ENV PROJECT_DIR=/app WORKDIR
PROJECT_DIR RUN npm install COPY .
MEDIA_DIR EXPOSE $APP_PORT ENTRYPOINT ["./entrypoint.sh"] CMD ["start"] ``` [ENV]()指令指定的环境变量在容器中可以使用。如果你只是需要指定构建镜像时的变量,你可以使用[ARG]()指令。
13. 使用LABEL设置镜像元数据
FROMnode:7-alpineLABELmaintainer"jakub.skalecki@example.com"...
14. 添加HEALTHCHECK
FROMnode:7-alpineLABELmaintainer"jakub.skalecki@example.com"ENVPROJECT_DIR=/appWORKDIR$PROJECT_DIRCOPYpackage.json$PROJECT_DIRRUNnpminstallCOPY.$PROJECT_DIRENVMEDIA_DIR=/media\NODE_ENV=production\APP_PORT=3000VOLUME$MEDIA_DIREXPOSE$APP_PORTHEALTHCHECKCMDcurl--fail||exit1ENTRYPOINT["./entrypoint.sh"]CMD["start"]
当请求失败时,curl --fail 命令返回非0状态。
15. 多阶段构建
参考文档《https://docs.docker.com/develop/develop-images/multistage-build/
在docker不支持多阶段构建的年代,我们构建docker镜像时通常会采用如下两种方法:
方法A.将所有的构建过程编写在同一个Dockerfile中,包括项目及其依赖库的编译、测试、打包等流程,可能会有如下问题:
-
Dockerfile可能会特别臃肿
-
镜像层次特别深
-
存在源码泄露的风险
方法B.事先在外部将项目及其依赖库编译测试打包好后,再将其拷贝到构建目录中执行构建镜像。
方法B较方法A略显优雅一些,而且可以很好地规避方法A存在的风险点,但仍需要我们编写两套或多套Dockerfile或者一些脚本才能将其两个阶段自动整合起来,例如有多个项目彼此关联和依赖,就需要我们维护多个Dockerfile,或者需要编写更复杂的脚本,导致后期维护成本很高。
为解决以上问题, Docker v17.05 开始支持多阶段构建 (multistage builds)。使用多阶段构建我们就可以很容易解决前面提到的问题,并且只需要编写一个 Dockerfile。
你可以在一个 Dockerfile 中使用多个 FROM 语句。每个 FROM 指令都可以使用不同的基础镜像,并表示开始一个新的构建阶段。你可以很方便的将一个阶段的文件复制到另外一个阶段,在最终的镜像中保留下你需要的内容即可。
默认情况下,构建阶段是没有命令的,我们可以通过它们的索引来引用它们,第一个 FROM 指令从0开始,我们也可以用AS指令为构建阶段命名。
案例1
FROM golang:1.7.3
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
通过
docker build
构建后,最终结果是产生与之前相同大小的 Image,但复杂性显著降低。您不需要创建任何中间 Image,也不需要将任何编译结果临时提取到本地系统。
哪它是如何工作的呢?关键就在
COPY --from=0
这个指令上。Dockerfile 中第二个 FROM 指令以 alpine:latest 为基础镜像开始了一个新的构建阶段,并通过
COPY --from=0
仅将前一阶段的构建文件复制到此阶段。前一构建阶段中产生的 Go SDK 和任何中间层都会在此阶段中被舍弃,而不是保存在最终 Image 中。
使用多阶段构建一个python应用。
案例2
FROM golang:1.7.3 as builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]
案例3
停在特定的构建阶段
构建镜像时,不一定需要构建整个 Dockerfile 中每个阶段,您也可以指定需要构建的阶段。比如:您只构建 Dockerfile 中名为 builder 的阶段
$dockerbuild--targetbuilder-talexellis2/href-counter:latest.
此功能适合以下场景:
-
调试特定的构建阶段。
-
在 Debug 阶段,启用所有程序调试模式或调试工具,而在生产阶段尽量精简。
-
在 Testing 阶段,您的应用程序使用测试数据,但在生产阶段则使用生产数据。
案例4
COPY --from
指令从单独的 Image 中复制,支持使用本地 Image 名称、本地或 Docker 注册中心可用的标记或标记 ID。COPY--from=nginx:latest/etc/nginx/nginx.conf/nginx.conf
案例5
把前一个阶段作为一个新的阶段
在使用 FROM 指令时,您可以通过引用前一阶段停止的地方来继续。同样,采用此方式也可以方便一个团队中的不同角色,如何使用类似流水线的方式,一级一级提供基础镜像,同样更方便快速的复用团队其他人的基础镜像。例如:
FROMalpine:latestasbuilderRUNapk--no-cacheaddbuild-baseFROMbuilderasbuild1COPYsource1.cppsource.cppRUNg++-o/binarysource.cppFROMbuilderasbuild2COPYsource2.cppsource.cppRUNg++-o/binarysource.cpp
#----基础python镜像----FROMpython:3.6ASbase#创建app目录WORKDIR/app#----依赖----FROMbaseASdependenciesCOPYgunicorn_app/requirements.txt./#安装app依赖RUNpipinstall-rrequirements.txt#----复制文件并build----FROMdependenciesASbuildWORKDIR/appCOPY./app#在需要时进行Build或Compile#---使用Alpine发布----FROMpython:3.6-alpine3.7ASrelease#创建app目录WORKDIR/appCOPY--from=dependencies/app/requirements.txt./COPY--from=dependencies/root/.cache/root/.cache#安装app依赖RUNpipinstall-rrequirements.txtCOPY--from=build/app/./CMD["gunicorn","--config","./gunicorn_app/conf/gunicorn_config.py","gunicorn_app:app"]
来源:本文转自公众号运维开发故事