可能大家都听说过Docker,大多数开发者都熟悉并使用过Docker,比如构建Docker镜像等基本操作。通常,构建镜像就像运行dockerbuilt-tname:tag一样简单,但还有许多其他可以优化的东西,特别是在优化构建过程和创建的最终图像方面。因此,在本文中,我们将研究如何优化Docker镜像的构建过程,从而在最短的构建时间内构建出满足生产需求的最小、最安全的Docker镜像。缓存以加快构建时间镜像的大部分构建时间都花在了下载和安装系统软件包和应用程序依赖项上。但是,这些通常不会经常更改,因此建议使用缓存。从系统包和工具开始——通常在FROM之后运行以确保它们被缓存。无论您使用哪个Linux发行版作为基础映像,您都应该得到如下内容:FROM...#anyviablebaseimagelikecentos:8,ubuntu:21.04oralpine:3.12.3#RHEL/CentOSRUNyuminstall...#DebianRUNapt-getinstall...#AlpineRUNapkadd...#RestoftheDockerfile(COPY,RUN,CMD...)或者,您甚至可以将这些相关命令提取到独立的Dockerfile中以构建您自己的基础映像。然后可以将该映像推送到注册表,以便您和其他人可以在其他Dockerfile中引用它。这样,您就不再需要担心系统包和关联的依赖项,除非您需要升级它们或添加和删除某些东西。在系统包之后,我们通常要安装应用程序依赖项。这些可以是Maven存储库中的Java库(默认情况下存储在.m2目录中)、JavaScript模块node_modules或Python库venv。这些更改比系统依赖项更频繁,但不足以保证每次构建都完全重新下载和重新安装。但是如果对应的Dockerfile写的不好,你会发现即使不修改依赖也没有使用缓存:FROM...#anyviablebaseimagelikepython:3.8,node:15oropenjdk:15.0.1#CopyeverythingatonceCOPY..#JavaRUNmvncleanpackage#OrPythonRUNpipinstall-rrequirements.txt#OrJavaScriptRUNnpminstall#...CMD["..."]这是为什么呢?问题出在COPY上。.,Docker在构建的每一步都使用缓存,直到遇到新的或修改的命令/层。在这种情况下,当我们将所有内容复制到镜像中时——包括未更改的依赖项列表以及修改后的源代码。Docker将继续并重新下载并重新安装所有依赖项。由于源代码文件已被修改,因此无法再使用该层的缓存。为避免这种情况,我们必须分两步复制文件:FROM...#anyviablebaseimagelikepython:3.8,node:15oropenjdk:15.0.1COPYpom.xml./pom.xml#JavaCOPYrequirements.txt./requirements.txt#PythonCOPYpackage。json./package.json#JavaScriptRUNmvndependency:go-offline-B#JavaRUNpipinstall-rrequirements.txt#PythonRUNnpminstall#JavaScriptCOPY./src./src/#RestofDockerfile(buildapplication;setCMD...)首先,我们添加列出所有应用程序依赖项'文件并安装它们。如果这个文件没有改变,所有的改变都会被缓存。只有这样,我们才能将其余(修改后的)源代码复制到映像中,并运行应用程序代码的测试和构建。对于更“高级”的方法,我们使用Docker的BuildKit及其实验性功能做同样的事情:#syntax=docker/dockerfile:experimentalFROM...#anyviablebaseimagelikepython:3.8,openjdk:15.0.1COPYpom.xml./pom.xml#JavaCOPYrequirements.txt./requirements.txt#PythonRUN--mount=type=cache,target=/root/.m2mvndependency:go-offline-B#JavaRUN--mount=type=cache,target=/root/.cache/pippipinstall-rrequirements.txt#Python上面的代码展示了如何使用命令--mount选项RUN来选择缓存目录。如果您想明确使用非默认缓存位置,这将很有帮助。但是,如果你想使用这个特性,你必须包含一个标题行来指定语法版本(如上所述),并运行构建,例如:DOCKER_BUILDKIT=1dockerbuildname:tag。有关实验性功能的更多信息,请参阅这些文档(https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/syntax.md#run---mounttypecache)。到目前为止,一切都只适用于本地构建——对于CI,情况就不同了,通常每个工具/提供者都会有所不同,但是对于其中任何一个,您都需要一些持久卷来存储缓存/依赖项。例如,对于Jenkins,您可以在代理中使用存储。对于在Kubernetes上运行的Docker构建(无论是使用JenkinsX、Tekton还是其他东西),您将需要一个可以使用DockerinDocker(DinD)部署的Docker守护进程,它是在Docker容器守护进程中运行的Docker。至于构建本身,您将需要一个连接到DinD套接字的pod(容器)来运行dockerbuild命令。为了演示和简单起见,我们可以使用以下pod进行操作:apiVersion:v1kind:Podmetadata:name:docker-buildspec:containers:-name:dind#DockerinDockercontainerimage:docker:19.03.3-dindsecurityContext:privileged:trueenv:-name:DOCKER_TLS_CERTDIRvalue:''volumeMounts:-name:dind-storagemountPath:/var/lib/docker-name:docker#Buildercontainerimage:docker:19.03.3-gitsecurityContext:privileged:truecommand:['cat']tty:trueenv:-name:DOCKER_BUILDKITvalue:'1'-name:DOCKER_HOSTvalue:tcp://localhost:2375volumes:-name:dind-storageemptyDir:{}-name:docker-socket-volumehostPath:path:/var/run/docker.socktype:Fileabove容器由2个容器组成-一个用于DinD,一个用于图像构建。要使用构建容器运行构建,访问它的shell,克隆一些存储库并运行构建过程:~$kubectlexec--stdin--ttydocker-build--/bin/sh#Openshellsession~#gitclonehttps://github.com/username/reponame.git#Clonesomerepository~#cdreponame~#dockerbuild--build-argBUILDKIT_INLINE_CACHE=1-tname:tag--cache-fromusername/reponame:latest....=>importingcachemanifestfrommartinheinz/python-project-blueprint:flask。..=>=>writingimagesha256:...=>=>=>namingtodocker.io/library/name:tag=>exportingcache=>=>preparingbuildcacheforexport最终的docker构建使用了一些新选项---cache-fromimage:tag,to告诉Docker它应该使用(远程)存储库中的指定图像作为缓存源。这样,即使缓存层未存储在本地文件系统中,我们也可以利用缓存。另一个选项--build-argBUILDKIT_INLINE_CACHE=1用于在创建图像时将缓存元数据写入图像。这必须用于--cache-from才能工作,请参阅文档(https://docs.docker.com/engine/reference/commandline/build/#specifying-external-cache-sources)了解更多信息。快速构建最小镜像固然不错,但如果你有非常“厚”的镜像,推/拉它们仍然需要很长时间,而胖镜像很可能还包含许多无用的库、工具等等这些东西使图像更加臃肿。易受攻击,因为它会产生更大的攻击面。制作较小镜像的最简单方法是使用像AlpineLinux这样的基础镜像,而不是基于Ubuntu或RHEL的镜像。另一种好方法是使用多步Docker构建,您可以使用一个图像来构建(第一个FROM命令),然后使用另一个较小的图像来运行应用程序(第二个/最后一个FROM),例如:#332.88MBFROMpython:3.8.7ASbuilderCOPYrequirements.txt/requirements.txtRUN/venv/bin/pipinstall--disable-pip-version-check-r/requirements.txt#only16.98MBFROMpython:3.8.7-alpine3.12asrunner#copyonlythedependencies-fromimageinstallationY-from=builder/venv/venvCOPY--from=builder./src/appCMD["..."]上面显示了我们首先在basePython3.8.7image中准备了应用程序和它的依赖,这是巨大的,332.88MB。这里我们安装应用程序所需的虚拟环境和库。然后我们切换到较小的基于Alpine的图像,它只有16.98MB。我们将之前创建的整个虚拟环境连同源代码复制到此映像中。这样,我们最终得到一个更小的图像,图像层更少,同时也有更少的不必要的工具和二进制文件。要记住的另一件事是我们在每次构建期间生成的层数。FROM、COPY、RUN和CMD将生成新层。至少在RUN的情况下,我们可以通过将所有RUN命令合并为一个命令来轻松减少它创建的层数,如下所示:#Bad,Creates4layersRUNyum--disablerepo=*--enablerepo="epel"RUNyumupdateRUNyuminstall-yhttpdRUNyumcleanall-y#Good,createsonly1layerRUNyum--disablerepo=*--enablerepo="epel"&&\yumupdate&&\yuminstall-yhttpd&&\yumcleanall-y我们可以更进一步,完全摆脱可能很重的基础镜像。为此,我们将使用特殊的FROMscratch信号通知Docker应该使用最小的基础镜像,下一个命令将是最终镜像的第一层。这对于以二进制文件形式运行且不需要大量工具的应用程序特别有用,例如Go、C++或Rust应用程序。但是,这种方法需要静态编译二进制文件,因此不适用于Java或Python等语言。FROMscratchDockerfiles的示例可能如下所示:FROMgolangasbuilderWORKDIR/go/src/appCOPY..#Staticbuildisrequiredsothatwecansafelyuse'scratch'baseimageRUNCGO_ENABLED=0goinstall-ldflags'-extldflags"-static"'FROMscratchCOPY--from=builder/go/bin/app/appENTR"/app"]很简单吧?使用这个Dockerfile,我们可以生成只有3MB左右的图像!锁定版本速度和大小是大多数人关心的两件事,图像安全成为事后考虑。有几种简单的方法可以锁定图像并限制攻击者可以利用的攻击面。最基本的建议是锁定所有库、包、工具和基础镜像的版本,这不仅对安全很重要,对镜像的稳定性也很重要。如果你为你的图像使用最新的标签,或者如果你没有在Python的requirements.txt或JavaScript的package.json中指定版本,你在构建期间下载的图像/库可能与应用程序代码不兼容,或者暴露容器到漏洞中间。当您想要将所有内容锁定到特定版本时,您还应该定期更新所有这些依赖项,以确保您拥有所有可用的最新安全补丁和修补程序。即使您非常努力地避免所有依赖项中的任何漏洞,仍然会有一些您错过或尚未修复/发现的漏洞。因此,为了减轻任何可能的攻击的影响,最好避免以root身份运行容器。因此,用户1001应该包含在Dockerfiles中,以指示从Dockerfiles创建的容器应该并且可以作为非根用户(理想情况下是任何用户)运行。当然,这可能需要您修改应用程序并选择正确的基础映像,因为一些常见的基础映像(如nginx)需要root权限(例如,由于特权端口)。通常很难发现和避免Docker映像中的漏洞,但如果映像仅包含运行应用程序所需的最低限度,则可能会更容易。Google发布的Distroless(https://github.com/GoogleContainerTools/distroless)就是这样一个镜像。将Distroless镜像修剪到甚至没有shell或包管理器的程度,使它们在安全方面比基于Debian或Alpine的镜像好得多。如果您使用的是多步骤Docker构建,在大多数情况下切换到Distroless运行器镜像非常容易:FROM...ASbuilder#Buildtheapplication...#PythonFROMgcr.io/distroless/python3ASrunner#GolangFROMgcr.io/distroless/baseASrunner#NodeJSFROMgcr.io/distroless/nodejs:10ASrunner#RustFROMgcr.io/distroless/ccASrunner#JavaFROMgcr.io/distroless/java:11ASrunner#CopyapplicationintorunnerandsetCMD...#Moreexamplesathttps://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或可能使用完全不同的容器运行时和引擎来限制资源消耗。
