0

N个技巧,编写更高效 Dockerfile|云效工程师指北

 2 years ago
source link: https://segmentfault.com/a/1190000041357991
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

N个技巧,编写更高效 Dockerfile|云效工程师指北

发布于 今天 02:31

简介:云原生时代下软件的构建和部署离不开容器技术。提到容器,几乎大家下意识都会联想到 Docker 。而 Docker 中有两个非常重要的概念,一个是Image(镜像),一个是Container(容器)。前者是一个静态视图,打包了应用的目录结构、运行环境等;后者是一个动态视图(进程),展示的是程序的运行状态(cpu、memory、storage)等信息。接下来的文章主要分享的是如何编写能使 Dockerfile 构建过程更快速、构建镜像更小的技巧。

大家好,我是陈泽锋,我在云效负责Flow流水线编排、任务调度引擎相关的工作。在云效的产品体系下,我们服务了各种研发规模、技术深度的的企业用户,收到了非常多的用户反馈。对于使用 Flow 进行云上构建的用户来说,构建速度是大家普遍关心的关键要素,在深入分析用户案例的过程中,我们发现了许多通用问题,只需要修改优化自己的项目或工程配置,就可以大大提升构建的性能,从而进一步加速 CICD 的效率。今天我们会以容器镜像构建作为切入点,总结一些在实际工程中,非常实用的优化技巧。

云原生时代下软件的构建和部署离不开容器技术。提到容器,几乎大家下意识都会联想到 Docker 。而 Docker 中有两个非常重要的概念,一个是Image(镜像),一个是Container(容器)。前者是一个静态视图,打包了应用的目录结构、运行环境等;后者是一个动态视图(进程),展示的是程序的运行状态(cpu、memory、storage)等信息。接下来的文章主要分享的是如何编写能使 Dockerfile 构建过程更快速、构建镜像更小的技巧。

首先我们先来了解一下 Docker 镜像,它由多个只读层堆叠到一起,每一层是上一层的增量修改。基于镜像创建新容器时,将在基础层的顶部添加一个新的可写层。该层通常称为“容器层”。下图展示了一个基于 docker.io/centos 基础镜像构建的应用镜像,创建出容器时的视图。

从图中我们可以看到镜像构建、容器启动的过程。

  • 首先是拉取基础镜像 docker.io/centos;
  • 基于 docker.io/centos 来启动一个容器,运行指令 yum update 后进行 docker commit 提交出一个新的只读层 v1(可以理解为生成了一个新的临时镜像 A,只不过用户并不会直接引用到它);
  • 基于临时镜像A启动新的容器,运行安装和配置 http server等软件后,提交出一个新的只读层 v2,也生成了这里最终被开发者引用的镜像版本 B;
  • 基于镜像版本B运行的容器,会再追加一层读写层(对容器的文件创建、修改、删除等操作,都在这一层生效);

镜像主要是 Docker 通过读取、运行 Dockerfile 的指令来生成。举官网上的一个 Dockerfile 例子:

FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py

它的核心逻辑是定义引用的基础镜像 base image,执行如 COPY 指令从上下文 context 里复制文件到容器中,运行 RUN 执行用户自定义构建脚本,最后定义容器启动的 CMD 或 ENTRYPOINT。构建更高效的镜像也要围绕上述涉及到的概念进行优化。

Dockerfile 优化技巧

使用国内的基础镜像

Flow 作为云上构建产品,每次构建都会给用户提供全新的构建环境,以避免环境污染导致带来过高运维成本。正因为如此,Flow 每次构建都会重新去下载 Dockerfile 中指定的基础镜像。

如果 Dockerfile 中指定基础镜像来源于 Docker Hub,则有可能因为网络延时问题导致下载缓慢,比如:

  • From Nginx
  • From java:8
  • FROM openjdk:8-jdk-alpine

典型现象如下:

image.png

可以将自己的基础镜像文件转存至国内镜像仓库,并修改自己的 Dockerfile 文件,操作步骤如下:

  • 将境外镜像在 pull 到本地。docker pull openjdk:8-jdk-alpine;
  • 将基础镜像 push 到阿里云镜像仓库(cr.console.aliyun.com)的国内 region(比如北京、上海等)。docker tag openjdk:8-jdk-alpine registry.cn-beijing.aliyuncs.com/yournamespace/openjdk:8-jdk-alpinedocker push registry.cn-beijing.aliyuncs.com/yournamespace/openjdk:8-jdk-alpi;
  • 修改你的 dockerfile 中 FROM,从你自己的镜像仓库下载镜像 。From registry.cn-beijing.aliyuncs.com/yournamespace/openjdk:8-jdk-alpine;

尽量小的、够用的基础镜像

大镜像除了占用更多的磁盘空间外,在应用部署时也会占用更多的网络消耗,导致更长的服务启动耗时。使用更小的基础镜像,例如使用 alpine 作为 base image。这里我们看一个打包 mysql-client 二进制的镜像,基于 alpine 和 ubuntu 的镜像大小对比。

FROM alpine:3.14
RUN apk add --no-cache mysql-client
ENTRYPOINT ["mysql"]

image.png

FROM ubuntu:20.04
RUN apt-get update \
    && apt-get install -y --no-install-recommends mysql-client \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["mysql"]

image.png

由此可以看到使用尽量小的 base 镜像有利于大幅度减少镜像的大小。

减少上下文关联目录文件

docker 是 c/s 的架构设计,当用户执行 docker build 时并不是在 client 直接进行构建,而是将 build 指定的目录作为上下文传递到 server 端,再执行上述提到的镜像构建的过程。如果执行镜像构建的上下文中关联大量不必要的文件,那可以使用 .dockerignore 来忽略这些文件(与 .gitignore 类似,定义的文件不会被跟踪、传输)。

以下举一个官网上的例子,通过构建日志可以观察看 context 的大小只有几十 byte:

mkdir myproject && cd myproject
 echo "hello" > hello
 echo -e "FROM busybox\nCOPY / /\nRUN cat /hello" > Dockerfile
 docker build -t helloapp:v1 --progress=plain .
#7 [internal] load build context
#7 sha256:6b998f8faef17a6686d03380d6b9a60a4b5abca988ea7ea8341adfae112ebaec
#7 transferring context: 26B done
#7 DONE 0.0s

当我们在 myproject 下放置一个与程序无关的大文件(或无关小文件,如应用构建的依赖包等)时,重新构建 helloapp:v3 时发现需要传输 70 MB的内容到服务端,并且镜像大小到 71MB。

#5 [internal] load build context
#5 sha256:746b8f3c5fdd5aa11b2e2dad6636627b0ed8d710fe07470735ae58682825811f
#5 transferring context: 70.20MB 1.0s done
#5 DONE 1.1s

image.png

减少层的数量、控制层的大小

如果把镜像构建的简单等同为 bash 等脚本指令执行的过程,往往就会踩中镜像层过多,镜像层包含无用文件的坑。下面让我们看三个 dockerfile 的写法和它们分别构建出来的镜像大小。

  • 首先是 centos_git_nginx:normal 镜像,它基于 centos 基础镜像增加了两层,分别安装了 git 和 nginx两个二进制,可以看到镜像的大小大概在 402MB。
FROM centos
RUN yum install -y git
RUN yum install -y nginx

image.png

  • 接着我们对 dockerfile 做一下优化,将它改成以下只增加一层的写法,可以看到镜像的大小缩减到 384 MB,证明了层的减少能减少镜像的大小。
FROM centos
RUN yum install -y git &&  yum install -y nginx

image.png

由于 yum install 过程会生成一些缓存数据,这些在应用运行过程中是不需要的,我们在安装完软件后立即将其删除后观察镜像再次缩小到 357 MB。

FROM centos
RUN yum install -y git && \
    yum install -y nginx && \
    yum clean all && rm -rf /var/cache/yum/*

image.png

TIPS: 我们知道了镜像构建过程生成每一层为只读层是不能再被修改的,以下的写法并不能对减少镜像的大小起到作用,反而还增加了一层无用镜像层。

FROM centos
RUN yum install -y git && \
    yum install -y nginx
RUN yum clean all && rm -rf /var/cache/yum/*

需要注意的是过于追求层次的少也不一定是好的做法,这样会使得构建或拉取镜像时减少了层被缓存的概率。

将不变层放到前面,可变层放到后面

当我们在同个时间内多次执行 docker build 可以发现,在构建完一次镜像后再次构建,docker 会利用缓存中的镜像数据直接进行复用。

事实上 Docker 会逐步完成 Dockerfile 中的指令,并按指定的顺序执行每个指令。在检查每条指令时,Docker在其缓存中查找可以重用的现有镜像。Docker 从缓存中已存在的父镜像开始,将下一条指令与从该基本镜像派生的所有子镜像进行比较,以查看其中是否有一条是使用完全相同的指令生成的。否则,缓存将无效。

举个例子,我们可以将简单、经常被依赖到的基本软件如 git、make等不常变化却常用的指令放到前面执行,这样镜像构建的过程层就能直接利用前面生成的缓存,而不是重复的下载软件,即浪费带宽又消耗时间。

这里我们对两种写法进行对比,首先初始化相关目录与文件:

mkdir myproject && cd myproject
echo "hello" > hello
  • 第一种 dockerfile 的写法为先 COPY 文件,再进行 RUN 安装软件操作。
FROM ubuntu:18.04
COPY /hello /
RUN apt-get update --fix-missing && apt-get install -y \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    libcap-dev \
    libsqlite3-dev \
    mercurial \
    reprepro \
    ruby1.9.1 \
    && rm -rf /var/lib/apt/lists/*

通过对 time docker build -t cache_test -f Dockerfile . 进行镜像构建,构建成功再多次执行可以发现后续构建直接命中缓存生成镜像。

time docker build -t cache_test -f Dockerfile .
[+] Building 59.8s (8/8) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                                                                     0.0s
 => => transferring dockerfile: 35B                                                                                                                                                                                      0.0s
 => [internal] load .dockerignore                                                                                                                                                                                        0.0s
 => => transferring context: 2B                                                                                                                                                                                          0.0s
 => [internal] load metadata for docker.io/library/ubuntu:18.04                                                                                                                                                          0.0s
 => [internal] load build context                                                                                                                                                                                        0.0s
 => => transferring context: 26B                                                                                                                                                                                         0.0s
 => [1/3] FROM docker.io/library/ubuntu:18.04                                                                                                                                                                            0.0s
 => CACHED [2/3] COPY /hello /                                                                                                                                                                                           0.0s
 => [3/3] RUN apt-get update && apt-get install -y     aufs-tools     automake     build-essential     curl     dpkg-sig     && rm -rf /var/lib/apt/lists/*                                                             58.3s
 => exporting to image                                                                                                                                                                                                   1.3s
 => => exporting layers                                                                                                                                                                                                  1.3s
 => => writing image sha256:5922b062e65455c75a74c94273ab6cb855f3730c6e458ef911b8ba2ddd1ede18                                                                                                                             0.0s
 => => naming to docker.io/library/cache_test                                                                                                                                                                            0.0s

docker build -t cache_test -f Dockerfile .  0.33s user 0.31s system 1% cpu 1:00.37 total
time docker build -t cache_test -f Dockerfile .
docker build -t cache_test -f Dockerfile .  0.12s user 0.08s system 34% cpu 0.558 total

修改 hello 文件的内容, echo "world" >> hello ,再次执行 time docker build -t cache_test -f Dockerfile . , 此时镜像构建的耗时又回到了1分钟左右。

  • 第二种写法的 dockerfile 如下,我们将基本不变的基础软件安装放到上面,将可能变化的 hello 文件放到下面。
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    && rm -rf /var/lib/apt/lists/*
COPY /hello /

通过对 time docker build -t cache_test -f Dockerfile . 进行镜像构建,第一次构建耗时在1分钟左右(构建成功再多次执行一样命中缓存生成镜像)。

修改 hello 文件的内容, date >> hello ,再次执行 time docker build -t cache_test -f Dockerfile . , 此时镜像构建的耗时在1s内,即成功复用第二层构建过的缓存层。

使用多阶段来分离 build 和 runtime

这里举一个 golang 的例子,首先将 example 代码库 https://github.com/golang/exa... clone 到本地,添加一个 dockerfile 进行构建应用镜像。

FROM golang:1.17.6
ADD . /go/src/github.com/golang/example
WORKDIR /go/src/github.com/golang/example
RUN go build -o /go/src/github.com/golang/example/hello /go/src/github.com/golang/example/hello/hello.go
ENTRYPOINT ["/go/src/github.com/golang/example/hello"]

我们可以看到镜像的大小是 943 MB,程序正常输出 Hello, Go examples!

image.png
image.png

接着让我们使用多阶段构建和尽量小的 runtime 来优化以上的过程。

FROM golang:1.17.6 AS BUILDER
ADD . /go/src/github.com/golang/example
RUN go build -o /go/src/github.com/golang/example/hello /go/src/github.com/golang/example/hello/hello.go

FROM golang:1.17.6-alpine
WORKDIR /go/src/github.com/golang/example
COPY --from=BUILDER /go/src/github.com/golang/example/hello /go/src/github.com/golang/example/hello
ENTRYPOINT ["/go/src/github.com/golang/example/hello"]

可以看到目前的镜像大小只有 317 MB。通过多阶段构建将应用构建和运行时依赖进行分离,只有将 runtime 依赖的软件会最终打到应用镜像中去。

image.png

原文链接
本文为阿里云原创内容,未经允许不得转载。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK