可能每个人都听说过Docker,并且大多数开发人员都熟悉并使用过Docker,诸如构建Docker镜像之类的基本操作。一般而言,构建镜像非常简单,只需运行docker built -t name:tag .,但其实还有很多其他可优化的东西,尤其是在优化构建过程和所创建的最终镜像方面。
因此,在本文中,我们将研究如何优化Docker镜像的构建过程,以使我们能够在最短构建时间内构建最小、最安全的满足生产需求的Docker镜像。
缓存以加快构建速度
镜像的构建时间大都花在系统软件包和应用程序依赖包的下载和安装。但是,这些通常不会经常变更,因此推荐进行缓存。
从系统包和工具开始——通常在FROM后运行,以确保已将其缓存。无论您使用哪个Linux发行版作为基本镜像,都应该得到如下所示的结果:
- FROM ... # any viable base image like centos:8, ubuntu:21.04 or alpine:3.12.3
- # RHEL/CentOS
- RUN yum install ...
- # Debian
- RUN apt-get install ...
- # Alpine
- RUN apk add ...
- # Rest of the Dockerfile (COPY, RUN, CMD...)
另外,您甚至可以将这些相关命令提取到独立的Dockerfile以构建自己的基础镜像。然后可以将该镜像推送到镜像仓库,以便您和其他人可以在其他的Dockerfile中引用。
这样,您无需再去担心系统包以及相关的依赖项,除非您需要升级它们或添加与删除某些内容。
在系统包之后,我们通常要安装应用程序依赖项。这些可能是来自Maven存储库中的Java库(默认存储在.m2目录中),JavaScript模块node_modules或Python库venv。
与系统依赖项相比,这些更改的频率更高,但不足以保证每次构建都能进行完整的重新下载和重新安装。但是如果对应Dockerfile写得不好,您会注意到,即使未修改依赖项,也不会使用缓存:
- FROM ... # any viable base image like python:3.8, node:15 or openjdk:15.0.1
- # Copy everything at once
- COPY . .
- # Java
- RUN mvn clean package
- # Or Python
- RUN pip install -r requirements.txt
- # Or JavaScript
- RUN npm install
- # ...
- CMD [ "..." ]
这是为什么?问题出在COPY . .,Docker在构建的每个步骤中都使用缓存,直到它遇到新的或已修改的命令/层。
在这种情况下,当我们将所有内容复制到镜像中时—包括未更改的依赖关系列表以及已修改的源代码。
Docker会继续进行并重新下载且重新安装所有依赖关系。因为修改过源码文件,它不再能够在该层使用缓存。为避免这种情况,我们必须分两个步骤复制文件:
- FROM ... # any viable base image like python:3.8, node:15 or openjdk:15.0.1
- COPY pom.xml ./pom.xml # Java
- COPY requirements.txt ./requirements.txt # Python
- COPY package.json ./package.json # JavaScript
- RUN mvn dependency:go-offline -B # Java
- RUN pip install -r requirements.txt # Python
- RUN npm install # JavaScript
- COPY ./src ./src/
- # Rest of Dockerfile (build application; set CMD...)
首先,我们添加列出所有应用程序依赖项的文件并安装它们。如果此文件没有更改,则将缓存所有更改。只有这样,我们才能将其余(修改过的)源码复制到镜像中,并运行应用程序代码的测试和构建。对于更多的“高级”方法,我们使用Docker的BuildKit及其实验功能进行相同的操作:
- # syntax=docker/dockerfile:experimental
- FROM ... # any viable base image like python:3.8, openjdk:15.0.1
- COPY pom.xml ./pom.xml # Java
- COPY requirements.txt ./requirements.txt # Python
- RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline -B # Java
- RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt # Python
上面的代码显示了如何使用命令--mount选项RUN来选择缓存目录。如果您要显式使用非默认缓存位置,这将很有帮助。
但是,如果要使用此功能,则必须包括指定语法版本的标题行(如上所述),并使用来运行构建,比如:DOCKER_BUILDKIT=1 docker build name:tag .。
在这些文档(https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/syntax.md#run---mounttypecache)中可以找到有关实验功能的更多信息。
到目前为止,所有内容仅适用于本地构建—对于CI,情况则不同,并且通常每个工具/提供程序都会有所不同,但对于其中的任何一个,您将需要一些持久性卷来存储缓存/依赖项 。例如,对于Jenkins,您可以在代理中使用存储。
对于在Kubernetes上运行的Docker构建(无论是使用JenkinsX,Tekton还是其他),您将需要Docker守护进程,该守护进程可以在Docker(DinD)中使用Docker进行部署,DinD是在Docker容器中运行的Docker守护进程。
至于构建本身,您将需要一个连接到DinD socket的pod(容器)来运行docker build命令。
为了演示和简化操作,我们可以使用以下pod进行操作:
- apiVersion: v1
- kind: Pod
- metadata:
- name: docker-build
- spec:
- containers:
- - name: dind # Docker in Docker container
- image: docker:19.03.3-dind
- securityContext:
- privileged: true
- env:
- - name: DOCKER_TLS_CERTDIR
- value: ''
- volumeMounts:
- - name: dind-storage
- mountPath: /var/lib/docker
- - name: docker # Builder container
- image: docker:19.03.3-git
- securityContext:
- privileged: true
- command: ['cat']
- tty: true
- env:
- - name: DOCKER_BUILDKIT
- value: '1'
- - name: DOCKER_HOST
- value: tcp://localhost:2375
- volumes:
- - name: dind-storage
- emptyDir: {}
- - name: docker-socket-volume
- hostPath:
- path: /var/run/docker.sock
- type: File
上面的容器由2个容器组成—一个用于DinD,一个用于镜像构建。要使用构建容器运行构建,可以访问其shell,克隆一些存储库并运行构建流程:
- ~ $ kubectl exec --stdin --tty docker-build -- /bin/sh # Open shell session
- ~ # git clone https://github.com/username/reponame.git # Clone some repository
- ~ # cd reponame
- ~ # docker build --build-arg BUILDKIT_INLINE_CACHE=1 -t name:tag --cache-from username/reponame:latest .
- ...
- => importing cache manifest from martinheinz/python-project-blueprint:flask
- ...
- => => writing image sha256:...
- => => naming to docker.io/library/name:tag
- => exporting cache
- => => preparing build cache for export
最终docker build使用了一些新选项—--cache-from image:tag,来告诉Docker它应该使用(远程)仓库中的指定镜像作为缓存源。这样,即使缓存的层未存储在本地文件系统中,我们也可以利用缓存的优点。
另一个选项----build-arg BUILDKIT_INLINE_CACHE=1用于在创建缓存元数据时将其写入镜像。这必须用于--cache-from工作,有关更多信息,请参阅文档(https://docs.docker.com/engine/reference/commandline/build/#specifying-external-cache-sources)。
最小镜像
快速构建确实很让人高兴,但是如果您拥有真正的“thick”图像,则仍然需要花费很长的时间才能push/pull它们,而且胖镜像很可能还包含许多无用的库,工具以及诸如此类的东西,这些都使镜像变得更加臃肿。
易受攻击,因为它会造成更大的攻击面。
制作更小的镜像的最简单方法是使用Alpine Linux之类的基础镜像,而不是基于Ubuntu或RHEL的镜像。另一个好的方法是使用多步骤Docker构建,其中您使用一个镜像进行构建(第一个FROM命令),而使用另一个更小的镜像来运行应用程序(第二个/最后一个FROM),例如:
- # 332.88 MB
- FROM python:3.8.7 AS builder
- COPY requirements.txt /requirements.txt
- RUN /venv/bin/pip install --disable-pip-version-check -r /requirements.txt
- # only 16.98 MB
- FROM python:3.8.7-alpine3.12 as runner
- # copy only the dependencies installation from the 1st stage image
- COPY --from=builder /venv /venv
- COPY --from=builder ./src /app
- CMD ["..."]
上面显示了我们首先在基本的Python 3.8.7镜像中准备了应用程序及其依赖项,该镜像很大,为332.88 MB。在此处,我们安装了应用程序所需的虚拟环境和库。
然后,我们切换到更小的基于Alpine的镜像,该镜像仅为16.98 MB。我们将先前创建的整个虚拟环境以及源代码复制到该镜像。这样,我们最终得到的图像要小得多,镜像层更少,同时也有更少的不必要的工具和二进制文件。
要记住的另一件事是我们在每次构建过程中产生的层数。FROM,COPY,RUN以及CMD是都会生成新的层。至少在RUN的情况下,我们可以通过将所有RUN命令合并成这样的一个命令来轻松地减少它创建的层的数量:
- # Bad, Creates 4 layers
- RUN yum --disablerepo=* --enablerepo="epel"
- RUN yum update
- RUN yum install -y httpd
- RUN yum clean all -y
- # Good, creates only 1 layer
- RUN yum --disablerepo=* --enablerepo="epel" && \
- yum update && \
- yum install -y httpd && \
- yum clean all -y
我们可以更进一步,完全摆脱可能很重的基础镜像。为此,我们将使用特殊的FROM scratch信号通知Docker应使用最小的基本镜像,而下一个命令将是最终镜像的第一层。
这对于以二进制文件运行且不需要大量工具的应用程序特别有用,例如Go,C ++或Rust应用程序。但是,这种方法要求二进制文件是静态编译的,因此它不适用于Java或Python之类的语言。FROM scratchDockerfiles的示例可能像这样:
- FROM golang as builder
- WORKDIR /go/src/app
- COPY . .
- # Static build is required so that we can safely use 'scratch' base image
- RUN CGO_ENABLED=0 go install -ldflags '-extldflags "-static"'
- FROM scratch
- COPY --from=builder /go/bin/app /app
- ENTRYPOINT ["/app"]
很简单,对吧?借助这种Dockerfile,我们可以生成仅约3MB的镜像!
锁定版本
速度和大小是大多数人关注的两件事,而镜像的安全性成为人们的事后考虑。有几种简单的方法可以将镜像锁定下来,并限制攻击者可以利用的攻击面。
最基本的建议是锁定所有库、包、工具和基本镜像的版本,这不仅对安全性很重要,而且对镜像的稳定性也很重要。如果您对镜像使用最新标记,或者您没有在Python的requirements.txt或JavaScript的package.json中指定版本,您在构建期间下载的镜像/库可能与应用程序代码不兼容,或者使容器暴露于漏洞中。
当您想将所有内容锁定到特定版本时,还应该定期更新所有这些依赖项,以确保您拥有所有可用的最新安全补丁程序和修补程序。
即使您真的很努力地避免所有依赖中的任何漏洞,仍然会有一些您错过或尚未修复/发现的漏洞。所以,为了减轻任何可能的攻击的影响,最好避免以根用户身份运行容器。
因此,应该在Dockerfiles中包含用户1001,以表示从Dockerfiles创建的容器应该并且可以作为非根用户(理想情况下是任意用户)运行。当然,这可能需要您修改应用程序并选择正确的基本镜像,因为一些常见的基本映像(如nginx)需要根权限(例如,由于特权端口)。
通常很难在Docker镜像中找到与避免漏洞,但是如果镜像仅包含运行应用程序所需的最低限度,则可能会更容易一些。Google发行的Distroless(https://github.com/GoogleContainerTools/distroless)是一个这样的镜像。
将Distroless镜像修剪到甚至没有shell或软件包管理器的程度,这使得它们比Debian或基于Alpine的镜像在安全性方面要好得多。如果您使用的是多步骤Docker构建,那么大多数情况下,切换到Distroless runner映像非常简单:
- FROM ... AS builder
- # Build the application ...
- # Python
- FROM gcr.io/distroless/python3 AS runner
- # Golang
- FROM gcr.io/distroless/base AS runner
- # NodeJS
- FROM gcr.io/distroless/nodejs:10 AS runner
- # Rust
- FROM gcr.io/distroless/cc AS runner
- # Java
- FROM gcr.io/distroless/java:11 AS runner
- # Copy application into runner and set CMD...
- # More examples at https://github.com/GoogleContainerTools/distroless/tree/master/examples
除了最终镜像及其容器中可能存在的漏洞外,我们还必须考虑用于构建镜像的Docker守护程序和容器运行时。因此,与我们的所有镜像一样,我们不应允许Docker与root用户一起运行,而应使用所谓的rootless模式。
这个文档(https://docs.docker.com/engine/security/rootless/)是关于如何在Docker中进行设置的完整指南,如果您不想调整配置,那么您可能要考虑切换到podman,podman默认情况下在rootless 和 daemonless 下运行的。
结论
容器和Docker已有很长的历史了,每个人都可以学习和使它用,而不仅仅是简简单单的使用。本文中的技巧和示例应该可以提高您的Docker知识并改善所使用的Docker镜像质量。
但是,在构建Docker镜像之外,还有许多其他事情可以改善我们处理镜像和容器的方式。例如,应用seccomp策略,使用cgroups或可能使用完全不同的容器运行时与引擎来限制资源消耗。