# Dockerfile 最佳实践

# ENTRYPOINT vs CMD

ENTRYPOINT 和 CMD 都用来指定一个可执行程序,这个可执行程序在 container 启动后自动运行。如果想要让自己制作的镜像自动运行程序(不需要在 docker run 后面添加命令),就需要使用这两个命令。

大部分基础镜像例如 ubuntu 等都内置了 CMD 指令,一般是 CMD /bin/bash 或者 CMD /bin/sh 等。

在基于基础镜像构建新的镜像的时候,只要在 Dockerfile 里指定这两个命令就能覆盖掉原镜像中的命令。

# CMD

CMD 有三种形式:

  • exec 形式,必须是一个JSON Array 的格式:
FROM ubuntu:14.04
CMD ["ping", "localhost"]
1
2
  • ENTRYPOINT 存在,作为其默认参数,必须是一个JSON Array 的格式:
FROM ubuntu:14.04
ENTRYPOINT ["ping", "localhost"]
CMD ["-c", "3"]
1
2
3
  • shell 形式:
FROM ubuntu:14.04
CMD ping localhost
1
2

shell 形式在容器启动执行命令时会自动在指令前拼接 /bin/sh -c,也就是 sh 进程会作为容器的 PID 1 进程。而 exec 进程它还会将用户指定的进程作为容器内的 PID 1 进程运行,这样方便传递信号给该进程。所以再编写 Dockerfile 文件时优先使用 exec 模式。

CMD 命令可以被 docker run image 后面的命令行参数替换掉:

  • 对于没有 ENTRYPOINT 的情况,比如执行 docker run demo ls,将忽略原有的 CMD 命令执行 ls 列出容器内的目录。
  • 对于包含了 ENTRYPOINT 的情况,docker run demo 后面的参数将作为 ENTRYPOINT 的参数覆盖掉默认的 CMD 中的默认参数。
  • 注意,包含了 ENTRYPOINT 后,CMD 不能是一个 shell 形式,否则会自动拼接上 /bin/sh -c 传给 ENTRYPOINT,显然是不对的。

# ENTRYPOINT

ENTRYPOINT 指令有两种形式:

  • exec 模式:
FROM ubuntu:14.04
ENTRYPOINT ["ping", "localhost"]
1
2

以这种模式定义的镜像在容器启动时会自动执行 ENTRYPOINT 指令,并且会将 docker run image 后的命令行参数附加在 ENTRYPOINT 的指令之后,例如 docker run demo -c 3,容器内实际执行的指令就是 ping localhost -c 3。如果需要在命令行覆盖 ENTRYPOINT 指令,可以使用 docker run --entrypoint 选项。

  • shell 模式:
FROM ubuntu:14.04
ENTRYPOINT ping localhost
1
2

# 注意事项

  • 基础镜像里指定了 CMD 指令,新镜像基于该镜像构建,并且重新指定了 ENTRYPOINT,此时,基础镜像里的 CMD 指令会失效,以 ENTRYPOINT 为准。
  • 基础镜像里指定了 ENTRYPOINT 指令,新镜像基于该镜像构建,并且重新指定了 CMD,此时,此时新镜像执行的命令依旧以 ENTRYPOINT 为准,CMD 作为参数传入。

# 参考

# ADD vs COPY

# ADD

  • ADD 允许使用一个 URL 作为 src 参数,例如:

    • ADD http://foo.com/bar.go /tmp/main.go,这句指令将从远程下载 bar.go 文件,并拷贝成 /tmp/main.go
    • ADD http://foo.com/bar.go /tmp/,这句指令将从远程下载 bar.go 指令,并拷贝到 /tmp/ 目录下(文件名仍然是 bar.go)。Docker 会自动根据 dst 的结尾是不是斜杠来推断拷贝到一个目录还是文件。
  • ADD 允许自动解压被压缩的文件,如果 src 是一个被压缩的格式,比如 targzipbzip2 等,将自动解压到 dst 目录下。

    • ADD /foo.tar.gz /tmp/,这句将解压 foo.tar.gz/tmp 目录下。
  • 值得注意的是,ADD 的从 URL 下载文件 和解压缩两个特性不能同时使用,如果从远程下载一个压缩文件,ADD 不会解压,只会直接拷贝。

# COPY

  • 由于 ADD 功能的复杂性(做了太多事情)和行为的不可预测性,docker 增加了 COPY 命令,COPY 不支持上面 ADD 的那些功能,仅仅用来实现本地文件或者目录的拷贝。
  • 在多阶段构建功能出现后,COPY 又增加了一个功能是从构建镜像中拷贝文件到新镜像中。

# 使用场景

  • 本地拷贝文件的场景都应该使用 COPY

  • 需要自动解压的场景可以选择 ADD

  • 从远程 URL 下载文件也不应该使用 ADD,例如下面的例子:
    这样会使用两条指令,一条下载,一条解压安装并清理无用文件。看起来没啥问题,但实际上这样做并不会减少镜像的大小,因为 rm 操作在单独的镜像层。

    ADD http://foo.com/package.tar.bz2 /tmp/
    RUN tar -xjf /tmp/package.tar.bz2 \
        && make -C /tmp/package \
        && rm /tmp/package.tar.bz2
    
    1
    2
    3
    4

    更好的做法是,将整个操作写在一行,这样只会在同一个镜像层处理。

    RUN curl http://foo.com/package.tar.bz2 \
        | tar -xjC /tmp/package \
        && make -C /tmp/package
    
    1
    2
    3

# 参考

# 编写 Dockerfile 的最佳实践

# 使用 .dockerignore

.dockerignore 文件可以用来排除那些不需要在构建镜像中复制的文件,类似于 .gitignore

# 使用 multi-stage 构建

对于 go,c 这种编译型语言,使用多阶段构建可以显著的减少最终生成的镜像的体积。下面是一个多阶段构建的例子,通过使用 COPY 命令从 build 镜像中拷贝编译好的二进制文件到一个崭新的基础镜像中运行,可以直接越过中间的各种编译的层,减少镜像的体积。

FROM golang:1.11-alpine AS build

# Install tools required for project
# Run `docker build --no-cache .` to update dependencies
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep

# List project dependencies with Gopkg.toml and Gopkg.lock
# These layers are only re-built when Gopkg files are updated
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# Install library dependencies
RUN dep ensure -vendor-only

# Copy the entire project and build it
# This layer is rebuilt when a file changes in the project directory
COPY . /go/src/project/
RUN go build -o /bin/project

# This results in a single layer image
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 不要安装不必要的包

# 解耦应用

一个 docker 容器不应该做太多的事,可以使用多个 docker 容器来分别部署不相关的应用,让他们通过容器网络来交互。

# 对多行参数排序

举例如下,多个包的名字应该通过 \ 来换行,并且和包名称前空一个空格,使得结构看起来更加清晰。

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion
1
2
3
4
5
6

# 利用构建缓存

Dockerfile 在构建时按照其中的指令顺序构建,每次构建都会生成一个中间镜像。下一条指令会根据上一个中间镜像的结果来构建。所以每次检查指令的时候, docker 会寻找是否有镜像缓存可以复用,这样就不需要创建一个新的中间镜像。如果不想使用缓存,可以在构建的时候使用 --no-cache

docker daemnon 通过 Dockerfile 构建镜像时,当发现即将新构建出的镜像与已有的某镜像重复时,可以选择放弃构建新的镜像,而是选用已有的镜像作为构建结果,也就是采取本地已经 cache 的镜像作为结果。

缓存的命中规则如下:

  • 基本原则是从已在缓存中的父镜像开始,将下一条指令与从该基本镜像派生的所有子映像进行比较,以查看是否其中一个是使用完全相同的指令构建的。如果不是,则缓存无效。
  • 对于 ADDCOPY 指令,镜像中文件的内容被检查,然后对每个文件计算一个校验和,校验和中不关注上次修改时间和上次访问时间。在检查缓存的时候,将校验和和已有的镜像做比较,如果相等,则使用缓存,如果不相等,该镜像及后续所有的构建缓存全部无效。
  • 除却这两个命令的其他命令,缓存都不会通过检查文件来确定缓存是否改变。举个例子,RUN apt-get -y update 这个指令不会通过比较文件变化来确定缓存,仅仅通过比较指令的字符串有没有发生变化来确定是否使用缓存。
  • 一旦缓存无效,dockerfile 中后续所有的指令都会生成新的镜像来构建。

# 利用缓存的最佳实践

由于 docker 缓存的机制,要将不长变更的改动放在 Dockerfile 的最前面,比如 RUN apt update 或者 RUN curl xxx 等,同时,针对不同语言,例如 nodejs,应该将 package.json 文件先拷贝进来再执行 npm install,这样当 nodejs 里其他文件变更并不会导致 npm install 的执行,从而加快构建。对于 golang 来说,同样的道理,应该先拷贝 go.modgo.sum,再执行 go mod download, 这样可以有效的缓存安装的依赖,加快构建。

# 不要在 Dockerfile 中映射公有端口

例如下面这种,这样会给应用的部署带来不确定性,应该通过 -p 来在运行时指定。

#private and public mapping
EXPOSE 80:8080

#private only
EXPOSE 80
1
2
3
4
5

# 尽量减少镜像大小

# 参考

上次更新: 5/25/2020, 4:33:40 PM