首页 > 云计算 > Docker/K8 > 快速掌握Dockerfile编写技巧
更快的构建速度
更小的Docker镜像大小
更少的Docker镜像层
充分利用镜像缓存
增加Dockerfile可读性
让Docker容器使用起来更简单
总结
编写
.dockerignore
文件
容器只运行单个应用
将多个RUN指令合并为一个
基础镜像的标签不要用latest
每个RUN指令后删除多余文件
选择合适的基础镜像(alpine版本最好)
设置WORKDIR和CMD
使用ENTRYPOINT (可选)
在entrypoint脚本中使用exec
COPY与ADD优先使用前者
合理调整COPY与RUN的顺序
设置默认的环境变量,映射端口和数据卷
使用LABEL设置镜像元数据
添加HEALTHCHECK
多阶段构建
FROM ubuntuADD . /appRUN apt-get update RUN apt-get upgrade -y RUN apt-get install -y nodejs ssh mysql RUN cd /app && npm install# this should start three processes, mysql and ssh# in the background and node app in foreground# isn't it beautifully terrible? <3CMD mysql & sshd & npm start
构建镜像:
docker build -t wtf .
.git/node_modules/
非常长的构建时间(修改前端之后,整个后端也需要重新构建)
非常大的镜像大小
多个应用的日志难以处理(不能直接使用stdout,否则多个应用的日志会混合到一起)
横向扩展时非常浪费资源(不同的应用需要运行的容器数并不相同)
僵尸进程问题 - 你需要选择合适的init进程
FROM ubuntuADD . /appRUN apt-get update RUN apt-get upgrade -y# we should remove ssh and mysql, and use# separate container for database RUN apt-get install -y nodejs # ssh mysql RUN cd /app && npm installCMD npm start
Docker镜像是分层的,下面这些知识点非常重要:
Dockerfile中的每个指令都会创建一个新的镜像层。
镜像层将被缓存和复用
当Dockerfile的指令修改了,复制的文件变化了,或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效
某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效
镜像层是不可变的,如果我们再某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件(只是这个文件在Docker容器中不可见了)。
apt-get upgrade
删除,因为它会使得镜像构建非常不确定(我们只需要依赖基础镜像的更新就好了)FROM ubuntuADD . /appRUN apt-get update \ && apt-get install -y nodejs \ && cd /app \ && npm installCMD npm start
记住一点,我们只能将变化频率一样的指令合并在一起。将node.js安装与npm模块安装放在一起的话,则每次修改源代码,都需要重新安装node.js,这显然不合适。因此,正确的写法是这样的:
FROM ubuntuRUN apt-get update && apt-get install -y nodejs ADD . /app RUN cd /app && npm installCMD npm start
FROM ubuntu:16.04 # it's that easy!RUN apt-get update && apt-get install -y nodejs ADD . /app RUN cd /app && npm installCMD npm start
FROM ubuntu:16.04RUN apt-get update \ && apt-get install -y nodejs \ # added lines && rm -rf /var/lib/apt/lists/*ADD . /app RUN cd /app && npm installCMD npm start
FROM nodeADD . /app # we don't need to install node # anymore and use apt-getRUN cd /app && npm installCMD npm start
FROM node:7-alpineADD . /app RUN cd /app && npm installCMD npm start
FROM node:7-alpineWORKDIR /app ADD . /app RUN npm installCMD ["npm", "start"]
#!/usr/bin/env sh_# $0 is a script name, # 2, $3 etc are passed arguments# 1case "$CMD" in "dev" ) npm install export NODE_ENV=development exec npm run dev ;; "start" ) _# we can modify files here, using ENV variables passed in _ # "docker create" command. It can't be done during build process. echo "db: $DATABASE_ADDRESS" >> /app/config.yml export NODE_ENV=production exec npm start ;; * ) _# Run custom command. Thanks to this line we can still use _ # "docker run our_image /bin/bash" and it will work exec {@:2} ;;esac
示例 Dockerfile:
FROM node:7-alpineWORKDIR /appADD . /appRUN npm installENTRYPOINT ["./entrypoint.sh"]CMD ["start"]
可以使用如下命令运行该镜像:
_# 运行开发版本_docker run our-app dev _# 运行生产版本_docker run our-app start _# 运行bash_docker run -it our-app /bin/bash
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,例如:
FROM alpine:3.7COPY popcorn.sh .RUN chmod +x popcorn.shENTRYPOINT ./popcorn.shCMD ["start"]
与其使用 shell 模式,不如使用 exec 模式,例如:
FROM alpine:3.7COPY popcorn.sh .RUN chmod +x popcorn.shENTRYPOINT ["./popcorn.sh"]
这样 PID 1 就是
./popcorn.sh
,它将负责响应所有发送到容器的信号,至于
./popcorn.sh
是否真的能捕捉到系统信号,那是另一回事。
举个例子,假设使用上面的 Dockerfile 来构建镜像,
popcorn.sh
脚本每过一秒打印一次日期:
#!/bin/shwhile truedodatesleep 1done
构建镜像并创建容器:
docker build -t truek8s/popcorn .docker run -it --name corny --rm truek8s/popcorn
打开另外一个终端执行停止容器的命令,并计时:
time docker stop corny
popcorn.sh
并没有实现捕获和处理
SIGTERM
信号的逻辑,所以需要 10s 左右才能停止容器。要想解决这个问题,就要往脚本中添加信号处理代码,让它捕获到
SIGTERM
信号时就终止进程:#!/bin/sh# catch the TERM signal and then exittrap "exit" TERMwhile truedo date sleep 1done
注意:下面这条指令与 shell 模式的 ENTRYPOINT 指令是等效的:
ENTRYPOINT ["/bin/sh", "./popcorn.sh"]
如果你就想使用
shell
模式的 ENTRYPOINT 指令,也不是不可以,只需将启动命令追加到
exec
后面即可,例如:
FROM alpine:3.7COPY popcorn.sh .RUN chmod +x popcorn.shENTRYPOINT exec ./popcorn.sh
SIGTERM
信号,又不能修改代码,这时候方案 1 和 2 都行不通了,只能在容器中添加一个
init
系统。init 系统有很多种,这里推荐使用 tini,它是专用于容器的轻量级 init 系统,使用方法也很简单:
安装
tini
将
tini
设为容器的默认应用
将
popcorn.sh
作为
tini
的参数
具体的 Dockerfile 如下:
FROM alpine:3.7COPY popcorn.sh .RUN chmod +x popcorn.shRUN apk add --no-cache tiniENTRYPOINT ["/sbin/tini", "--", "./popcorn.sh"]
现在
tini 就是 PID 1,它会将收到的系统信号转发给子进程 popcorn.sh
FROM node:7-alpineWORKDIR /appCOPY . /app RUN npm installENTRYPOINT ["./entrypoint.sh"] CMD ["start"]
如果引用的父镜像在构建缓存中,下一个命令将会和所有从该父进程派生的子镜像做比较,如果有子镜像使用相同的命令,那么缓存命中,否则缓存失效。
在大部分情况下,通过比较
Dockerfile
中的指令和子镜像已经足够了。
但是有些指令需要进一步的检查。
对于
ADD
和
COPY
指令, 文件的内容会被检查,并且会计算每一个文件的校验码。
但是文件最近一次的修改和访问时间不在校验码的考虑范围内。
在构建过程中,docker 会比对已经存在的镜像,只要有文件内容和元数据发生变动,那么缓存就会失效。
除了
ADD
和
COPY
指令,镜像缓存不会检查容器中文件来判断是否命中缓存。
例如,在处理
RUN apt-get -y update
命令时,不会检查容器中的更新文件以确定是否命中缓存,这种情况下只会检查命令字符串是否相同。
FROM node:7-alpineWORKDIR /appCOPY package.json /app RUN npm install COPY . /appENTRYPOINT ["./entrypoint.sh"] CMD ["start"]
ROM python:3.6# 创建 app 目录WORKDIR /app# 安装 app 依赖COPY src/requirements.txt ./RUN pip install -r requirements.txt# 打包 app 源码COPY src /appEXPOSE 8080CMD [ "python", "server.py" ]
dockerfile FROM node:7-alpine ENV PROJECT_DIR=/app WORKDIRPROJECT_DIR RUN npm install COPY .MEDIA_DIR EXPOSE $APP_PORT ENTRYPOINT ["./entrypoint.sh"] CMD ["start"] ``` [ENV](https://docs.docker.com/engine/reference/builder/#env)指令指定的环境变量在容器中可以使用。如果你只是需要指定构建镜像时的变量,你可以使用[ARG](https://docs.docker.com/engine/reference/builder/#arg)指令。
FROM node:7-alpine LABEL maintainer "jakub.skalecki@example.com" ...
FROM node:7-alpine LABEL maintainer "jakub.skalecki@example.com"ENV PROJECT_DIR=/app WORKDIR $PROJECT_DIRCOPY package.json $PROJECT_DIR RUN npm install COPY . $PROJECT_DIRENV MEDIA_DIR=/media \ NODE_ENV=production \ APP_PORT=3000VOLUME $MEDIA_DIR EXPOSE $APP_PORT HEALTHCHECK CMD curl --fail http://localhost:$APP_PORT || exit 1ENTRYPOINT ["./entrypoint.sh"] CMD ["start"]
当请求失败时,curl --fail 命令返回非0状态。
参考文档《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指令为构建阶段命名。
FROM golang:1.7.3WORKDIR /go/src/github.com/alexellis/href-counter/RUN go get -d -v golang.org/x/net/htmlCOPY app.go .RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .FROM alpine:latestRUN apk --no-cache add ca-certificatesWORKDIR /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应用。
FROM golang:1.7.3 as builderWORKDIR /go/src/github.com/alexellis/href-counter/RUN go get -d -v golang.org/x/net/htmlCOPY app.go .RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .FROM alpine:latestRUN apk --no-cache add ca-certificatesWORKDIR /root/COPY --from=builder /go/src/github.com/alexellis/href-counter/app .CMD ["./app"]
停在特定的构建阶段
构建镜像时,不一定需要构建整个 Dockerfile 中每个阶段,您也可以指定需要构建的阶段。比如:您只构建 Dockerfile 中名为 builder 的阶段
$ docker build --target builder -t alexellis2/href-counter:latest .
此功能适合以下场景:
调试特定的构建阶段。
在 Debug 阶段,启用所有程序调试模式或调试工具,而在生产阶段尽量精简。
在 Testing 阶段,您的应用程序使用测试数据,但在生产阶段则使用生产数据。
COPY --from
指令从单独的 Image 中复制,支持使用本地 Image 名称、本地或 Docker 注册中心可用的标记或标记 ID。COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
把前一个阶段作为一个新的阶段
在使用 FROM 指令时,您可以通过引用前一阶段停止的地方来继续。同样,采用此方式也可以方便一个团队中的不同角色,如何使用类似流水线的方式,一级一级提供基础镜像,同样更方便快速的复用团队其他人的基础镜像。例如:
FROM alpine:latest as builderRUN apk --no-cache add build-baseFROM builder as build1COPY source1.cpp source.cppRUN g++ -o /binary source.cppFROM builder as build2COPY source2.cpp source.cppRUN g++ -o /binary source.cpp
# ---- 基础 python 镜像 ----FROM python:3.6 AS base# 创建 app 目录WORKDIR /app# ---- 依赖 ----FROM base AS dependencies COPY gunicorn_app/requirements.txt ./# 安装 app 依赖RUN pip install -r requirements.txt# ---- 复制文件并 build ----FROM dependencies AS build WORKDIR /appCOPY . /app# 在需要时进行 Build 或 Compile# --- 使用 Alpine 发布 ----FROM python:3.6-alpine3.7 AS release # 创建 app 目录WORKDIR /appCOPY --from=dependencies /app/requirements.txt ./COPY --from=dependencies /root/.cache /root/.cache# 安装 app 依赖RUN pip install -r requirements.txtCOPY --from=build /app/ ./CMD ["gunicorn", "--config", "./gunicorn_app/conf/gunicorn_config.py", "gunicorn_app:
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70013542/viewspace-2968947/ ,如需转载,请注明出处,否则将追究法律责任。