24条Dockerfile及指令最佳实践

云计算 云原生
如果服务不需要特权来运行,使用USER​指令切换到非root用户。使用RUN groupadd -r mysql && useradd -r -g mysql mysql​之后用USER mysql切换用户。

构建缓存

在镜像的构建过程中,Docker会根据Dockerfile指定的顺序执行每个指令。Dockerfile的每条指令都会将结果提交为新的镜像。然后,下一条指令基于上一条指令的镜像进行构建。

在执行每条指令之前,Docker都会在缓存中查找是否已经存在可重用的镜像,如果存在就使用现存的镜像,不再重复创建。

因此,为了有效地利用缓存,尽量保持Dockerfile一致,并且尽量在末尾修改:

FROM ubuntu

MAINTAINER author <somebody@company.com>

RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe"

RUN apt-get update

RUN apt-get upgrade -y

更改MAINTAINER指令会使Docker强制执行run指令来更新apt,而不是使用缓存。

如不希望使用缓存,在执行 docker build 时需加上参数--no-cache=true。

Docker中,构建缓存遵循的基本规则如下:

  1. 从缓存中存在的基础镜像(FROM指令指定)开始,下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效。
  2. 多数情况中,使用其中一个子镜像来比较Dockerfile中的指令是足够的。然而,特定的指令需要做更多的判断。
  3. 对于ADD和COPY指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验值,通常是检查文件的校验和(checksum)。在缓存的查找过程中,会将这些校验和已存在镜像中的文件校验值进行对比。如果文件有任何改变,则缓存失效。
  4. 除了ADD和COPY指令,缓存匹配检查并不检查临时容器中的文件。例如,当使用RUN apt-get -y update命令更新了容器中的文件,并不会被缓存检查策略作为缓存匹配的依据。
  5. 一旦缓存失效,所有后续的Dockerfile指令都将产生新的镜像,缓存不会被使用。

使用多阶段构建

多阶段构建可以大幅度减小最终的镜像大小,而不需要去想办法减少中间层和文件的数量。因为镜像是在生成过程的最后阶段生成的,所以可以利用生成缓存来最小化镜像层。

例如,如果构建包含多个层,则可以将它们从变化频率较低(以确保生成缓存可重用)到变化频率较高的顺序排序:

  • 安装构建应用程序所需的依赖工具
  • 安装或更新依赖项
  • 构建你的应用

比如构建一个Go应用程序的Dockerfile可能类似于这样:

FROM golang:1.11-alpine AS build

# 安装项目需要的工具
# 运行 `docker build --no-cache .` 来更新依赖
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep

# 通过 Gopkg.toml 和 Gopkg.lock 获取项目的依赖
# 仅在更新 Gopkg 文件时才重新构建这些层
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# 安装依赖库
RUN dep ensure -vendor-only

# 拷贝整个项目进行构建
# 当项目下面有文件变化的时候该层才会重新构建
COPY . /go/src/project/
RUN go build -o /bin/project

# 将打包后的二进制文件拷贝到 scratch 镜像下面,将镜像大小降到最低
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]

使用标签

除非是在用Docker做实验,否则你应当通过 -t 选项来 docker build 新的镜像以便于标记构建的镜像。一个简单可读的标签可以帮助管理每个创建的镜像。

docker build -t="tuxknight/luckypython"

始终通过 -t 标记来构建镜像。

公开端口

Docker的核心概念是可重复和可移植,镜像应该可以运行在任何主机上并运行尽可能多的次数。在Dockerfile中可以映射私有和公有端口,但永远不要通过Dockerfile映射公有端口。这样运行多个镜像的情况下会出现端口冲突的问题。

EXPOSE 80:8080  # 80映射到host的8080,不提倡这种用法 
EXPOSE 80 # 80会被docker随机映射一个端口

EXPOSE指令用于声明容器将监听的端口。在EXPOSE指令中,端口号的格式为<容器端口>/<协议>。其中,容器端口是指在容器内部应用程序监听的端口,而协议是可选的,默认为TCP。

示例中,EXPOSE 80:8080表示容器将监听容器端口80,而宿主机可以使用端口8080来访问容器的80端口。也就是,容器的80端口映射到了宿主机的8080端口。

请注意,EXPOSE指令仅仅是声明容器将监听的端口,并不会自动进行端口映射。要实际进行端口映射,需要在运行容器时使用-p或-P选项。

CMD ENTRYPOINT语法

CMD和ENTRYPOINT支持两种语法:

CMD /bin/echo 

CMD ["/bin/echo"]

在第一种方式下,Docker会在命令前加上 /bin/sh -c,可能会导致一些意想不到的问题。在第二种方式下,CMD ENTRYPOINT是一个数组,执行的命令完全和期待的一样。

容器是短暂的

容器模型是进程而不是机器,不需要开机初始化。在需要时运行,不需要时停止,能够删除后重建,并且配置和启动的最小化。

.dockerignore 文件

在docker build的时候,对于一些不需要提交构建的文件用.dockerignore来进行忽略。忽略部分无用的文件和目录可以提高构建的速度。

不要在构建中升级版本

不在容器中更新,更新交给基础镜像来处理。

应用解耦

每个容器只运行一个进程,每个容器应用只关心一个方面的事情。将多个应用解耦到不同容器中,容器起到了隔离应用隔离数据的作用,可以更轻松地保证容器的横向扩展和复用。

例如一个Web应用程序可能包含三个独立的容器:Web应用、数据库、缓存,每个容器都是独立的镜像,分开运行。但这并不是说一个容器就只能跑一个进程,因为有的程序可能会自行产生其他进程,比如Celery就可以有很多个工作进程。

虽然每个容器跑一个进程是一条很好的法则,但这并不是一条硬性的规定。主要是希望一个容器只关注一件事情,尽量保持干净和模块化。如果容器互相依赖,你可以使用 Docker 容器网络 来把这些容器连接起来。

最小化镜像层数

在很早之前的版本中尽量减少镜像层数是非常重要的,不过现在的版本已经有了一定的改善了:

  • 只有RUN、COPY和ADD指令会创建层,其他指令会创建临时的中间镜像,但是不会直接增加构建的镜像大小了。
  • 多阶段构建的支持,允许我们把需要的数据直接复制到最终的镜像中,这就允许在中间阶段包含一些工具或者调试信息了,而且不会增加最终的镜像大小。

需要掌握好Dockerfile的可读性和文件系统层数之间的平衡。控制文件系统层数时会降低Dockerfile的可读性。而Dockerfile可读性高时,往往会导致更多的文件系统层数。

避免安装不必要的包

为了降低复杂性、减少依赖、减小文件大小和构建时间,应该避免安装额外的或者不必要的软件包。例如,不要在数据库镜像中包含一个文本编辑器。

使用特定标签

Dockerfile中FROM应始终包含依赖的基础镜像的完整仓库名和标签,如使用FROM debian:jessie而不是FROM debian。

多行参数排序

只要有可能,就将多行参数按字母顺序排序。这可以避免重复包含同一个包,更新包列表时也更容易,也更容易阅读和审查。建议在反斜杠符号 \ 之前添加一个空格,可以增加可读性。

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion

Dockerfile指令最佳实践

关于这些指令的使用建议可以帮助我们创建高效且可维护的Dockerfile。以下内容为Dockerfile指令部分的最佳实践。

FROM

尽可能使用当前的官方镜像作为基础镜像。推荐使用Debian镜像,大小保持在100MB上下,且仍是完整的发行版。

另外,根据情况也可考虑使用Alpine映像,因为它受到严格控制且较小(当前小于5MB),同时仍是完整的Linux发行版。

LABEL标签

可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由LABEL开头加上一个或多个标签对。

下面的示例展示了各种不同的可能格式。#开头的行是注释内容。

# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor="ACME Incorporated"
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""

一个镜像可以包含多个标签,当然以上内容也可以写成下面这样,但是不是必须的:

# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
      com.example.is-production="" \
      com.example.version="0.0.1-beta" \
      com.example.release-date="2015-02-12"

PS:如果字符串包含空格,那么它必须被引用或者空格必须被转义。如果字符串包含内部引号字符("),则也可以将其转义。

RUN

为了保持Dockerfile文件的可读性以及可维护性,建议将过长的或复杂的RUN指令用反斜杠\分割成多行,以提高可读性和可维护性。

RUN指令最常见的用法是安装包用的apt-get。因为RUN apt-get指令会安装包,所以有几个问题需要注意。

  • 避免运行apt-get upgrade或dist-upgrade,在无特权的容器中,很多必要的包不能正常升级。如果基础镜像过时了,应当联系维护者。如果你确定某个特定的包,比如foo,需要升级,使用apt-get install -y foo就行,该指令会自动升级foo包。
  • 永远将apt-get update和apt-get install一起执行,否则apt-get install会出现异常。
  • 推荐apt-get update && apt-get install -y package-a package-b这种方式,先更新,之后安装最新的软件包。
RUN apt-get update && apt-get install -y \
    aufs-tools \
    automake \
    btrfs-tools \
    build-essential \
    curl \
    dpkg-sig \
    git \
    iptables \
    libapparmor-dev \
    libcap-dev \
    libsqlite3-dev \
    lxc=1.0* \
    mercurial \
    parallel \
    reprepro \
    ruby1.9.1 \
    ruby1.9.1-dev \
    s3cmd=1.1.0*

将apt-get update放在一条单独的RUN声明中会导致缓存问题以及后续的apt-get install失败。比如,假设有一个Dockerfile文件:

FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl

构建镜像后,所有的层都在Docker的缓存中。假设后来又修改了其中的apt-get install添加了一个包:

FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl nginx

Docker发现修改后的RUN apt-get update指令和之前的完全一样。所以,apt-get update不会执行,而是使用之前的缓存镜像。因为apt-get update没有运行,后面的apt-get install可能安装的是过时的curl和nginx版本。

使用RUN apt-get update && apt-get install -y可以确保Dockerfiles每次安装的都是包的最新的版本,而且这个过程不需要进一步的编码或额外干预。这项技术叫做cache busting(缓存破坏)。

EXPOSE 指令

EXPOSE指令用于指定容器将要监听的端口。因此,要为应用程序使用常见的端口。

例如,提供Apache web服务的镜像应该使用EXPOSE 80,而提供MongoDB服务的镜像使用EXPOSE 27017。

对于外部访问,用户可以在执行docker run时使用一个-p参数来指示如何将指定的端口映射到所选择的端口。

ENV 指令

为了方便新程序运行,可以使用ENV指令来为容器中安装的程序更新PATH环境变量。例如使用ENV PATH /usr/local/nginx/bin:$PATH来确保CMD ["nginx"]能正确运行。

ENV指令也可用于为容器化的服务提供必要的环境变量,比如Postgres需要的PGDATA。最后,ENV也能用于设置常见的版本号,比如下面的示例:

ENV PG_MAJOR 9.3
ENV PG_VERSION 9.3.4
RUN curl -SL http://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgress && …
ENV PATH /usr/local/postgres-$PG_MAJOR/bin:$PATH

类似于程序中的常量,这种方法可以只需改变ENV指令来自动的改变容器中的软件版本。

CMD

CMD指令是容器启动以后,默认的执行命令,需要重点理解下这个默认的含义,意思就是如果我们执行docker run没有指定任何的执行命令或者Dockerfile里面也没有指定ENTRYPOINT,那么就会使用CMD指定的执行命令执行了。这也说明了ENTRYPOINT才是容器启动以后真正要执行的命令。

所以经常遇到CMD会被覆盖的情况。为什么会被覆盖呢?主要还是因为CMD的定位就是默认,如果不额外指定,那么才会执行CMD命令,但是如果我们指定了的话那就不会执行CMD命令了,也就是说CMD会被覆盖。

CMD总共有三种用法:

CMD ["executable", "param1", "param2"]  # exec 形式
CMD ["param1", "param2"] # 作为 ENTRYPOINT 的默认参数
CMD command param1 param2  # shell 形式

CMD推荐使用CMD ["executable","param1","param2"]这样的格式。如果镜像是用来运行服务,需要使用CMD["apache2","-DFOREGROUND"],这种格式的指令适用于任何服务性质的镜像。

ENTRYPOINT 指令

根据官方定义来说ENTRYPOINT才是用于定义容器启动以后的执行程序的,允许将镜像当成命令本身来运行(用CMD提供默认选项),从名字也可以理解,是容器的入口。

ENTRYPOINT 一共有两种用法:

ENTRYPOINT ["executable", "param1", "param2"] (exec 形式)
ENTRYPOINT command param1 param2 (shell 形式)

对应命令行exec模式,也就是带中括号的,和CMD的中括号形式是一致的。但是这里貌似是在shell的环境下执行的,与cmd有区别。

如果run命令后面有执行命令,那么后面的全部都会作为ENTRYPOINT的参数。如果run后面没有额外的命令,但是定义了CMD,那么CMD的全部内容就会作为ENTRYPOINT的参数,这同时是上面我们提到的CMD的第二种用法。

所以说ENTRYPOINT不会被覆盖。当然如果要在run里面覆盖,也是有办法的,使用--entrypoint参数即可。

一般会用ENTRYPOINT的中括号形式作为Docker容器启动以后的默认执行命令,里面放的是不变的部分,可变部分比如命令参数可以使用CMD的形式提供默认版本,也就是run里面没有任何参数时使用的默认参数。如果我们想用默认参数,就直接run,否则想用其他参数,就run里面加上参数。

ADD COPY

虽然ADD与COPY功能类似,但推荐使用COPY。因为它比 ADD 更透明。COPY只支持基本的文件拷贝功能,更加的可控。而ADD具有更多特定,比如tar文件自动提取,支持URL。通常需要提取tarball中的文件到容器的时候才会用到ADD。

如果在Dockerfile中使用多个文件,每个文件应使用单独的COPY指令。这样,只有出现文件变化的指令才会不使用缓存。

为了控制镜像的大小,不建议使用ADD指令获取URL文件。正确的做法是在RUN指令中使用wget或curl来获取文件,并且在文件不需要的时候删除文件。

RUN mkdir -p /usr/src/things \
    && curl -SL http://example.com/big.tar.gz \
    | tar -xJC /usr/src/things \
    && make -C /usr/src/things all

VOLUME

VOLUME指令用于声明容器中的目录将被持久化保存,即在容器中创建的目录将被挂载到宿主机或其他容器中,以便数据可以在容器之间共享。

VOLUME指令应当暴露出数据库的存储位置,配置文件的存储以及容器中创建的文件或目录。由于容器结束后并不保存任何更改,应该把所有数据通过VOLUME保存到host中。

强烈建议使用VOLUME来管理镜像中的可变部分和用户可以改变的部分。

USER

如果服务不需要特权来运行,使用USER指令切换到非root用户。使用RUN groupadd -r mysql && useradd -r -g mysql mysql之后用USER mysql切换用户。

要避免使用sudo来提升权限,因为它不可预期的TTY和信号转发行为可能造成的问题比它能解决的问题还多。如果你真的需要和sudo类似的功能(例如,以root权限初始化某个守护进程,以非root权限执行它),你可以使用gosu。我们可以去查看官方的一些镜像,很多都是使用的gosu。

最后,不要反复地切换用户,减少不必要的layers。

WORKDIR

为了清晰性和可靠性,WORKDIR的路径应该始终使用绝对路径。同时,使用WORKDIR来替代RUN cd ... && do-something这样难以维护的指令。后者难以阅读、排错和维护。

责任编辑:武晓燕 来源: 程序新视界
相关推荐

2013-12-12 10:20:00

JavaScript学习

2023-07-25 11:22:31

2015-10-10 09:35:38

swift规范

2015-10-10 10:05:03

Swift2.0实践

2010-08-25 08:58:32

HTML

2009-01-15 09:57:00

2011-12-21 09:38:31

HTML 5

2018-01-03 11:22:45

2024-01-05 09:08:48

代码服务管理

2015-01-09 11:29:45

DockerDockerFile创建镜像

2013-09-25 09:25:52

2011-09-14 10:38:39

2021-10-12 10:43:38

区块链技术智能

2011-08-18 11:05:21

jQuery

2023-07-21 01:12:30

Reactfalse​变量

2016-02-17 09:26:09

数据中心

2013-03-19 09:57:43

2012-04-13 14:03:19

SOA

2013-09-30 09:33:33

云治理云安全斯诺登事件

2015-08-03 09:49:24

点赞
收藏

51CTO技术栈公众号