构建容器的最佳实践


本文介绍构建容器的一系列最佳实践。这些做法涵盖了广泛的目标(从缩短构建时间到创建更小、弹性更佳的镜像),旨在使容器更加容易构建,并且更加容易在 Kubernetes 中运行。

这些最佳实践的重要性并不相同。例如,成功运行某一生产工作负载可能无需其中某些做法,但必须使用其他做法。 特别是,与安全相关的最佳实践的重要性较为主观。是否实现它们取决于你的环境和所受限制。

若要充分理解本文中的内容,你需要了解一些关于 DockerKubernetes 的知识。 此处介绍的一些最佳实践也适用于 Windows 容器,但大多数做法假定你使用的是 Linux 容器。

有关运行和运营容器的建议,请参阅 容器运维的最佳实践

重要性:高

注意:在此最佳实践的上下文中,应用 被视为具有唯一父进程且可能具有多个子进程的单个软件。

开始使用容器时,一种常见误解是将它们视为可以同时运行许多不同软件的虚拟机 (VM)。容器可以这样工作,但这样做会消减容器模型的大部分优点。例如,拿经典的 Apache/MySQL/PHP 堆栈来说,你可能非常想在单个容器中运行所有组件。但是,最佳实践是使用两个或三个不同容器:一个用于 Apache,一个用于 MySQL,如果运行 PHP-FPM,则可能还有一个用于 PHP。

由于容器与其托管的应用具有相同的生命周期,因此每个容器应仅包含一个应用。当容器启动时,应用也应该启动,当应用停止时,容器也应该停止。下图展示了此最佳实践。

自定义镜像的启动过程的示意图

图 1. 左边的容器符合最佳实践,右边的容器则不符合最佳实践。

如果一个容器中具有多个应用,则这些应用可能具有不同的生命周期或处于不同状态。例如,到最后可能出现容器在运行但其某个核心组件崩溃或无响应的情况。如果不进行额外的健康检查,则整个容器管理系统(Docker 或 Kubernetes)将无法判断该容器是否运行正常。对于 Kubernetes,这意味着,如果核心组件无响应,Kubernetes 不会自动重启容器。

你可能会在公开镜像中看到以下操作,但 不要 按照它们的示例进行操作:

  • 使用 Supervisor 等进程管理系统来管理容器中的一个或多个应用。
  • 使用 bash 脚本作为容器中的入口点,并使其生成多个应用作为后台作业。如需了解如何在容器中正确使用 bash 脚本,请参阅 正确处理 PID 1、信号处理和僵尸进程
注意:你可能会看到有些知名供应商的官方镜像并未实施这种最佳实践。供应商这样做的原因是他们需要多个组件正常工作,并且他们希望用户能够通过单个docker run命令来运行他们的软件。 虽然此方法可用于测试和实验,但我们不建议在生产环境中运行这些镜像。

重要性:高

Linux 信号是控制容器内进程生命周期的主要方式。根据以往的最佳实践,为了将应用的生命周期与其所处的容器紧密关联,请确保你的应用正确处理 Linux 信号。 最重要的 Linux 信号是 SIGTERM ,因为它可以终止进程。你的应用可能还会接收 SIGKILL 信号(用于非正常终止进程)或 SIGINT 信号(系统会在你输入Ctrl+C时发送此信号,应用通常以类似 SIGTERM 的方式处理此信号)。

进程标识符 (PID) 是 Linux 内核为每个进程提供的唯一标识符。PID 属于命名空间,这意味着容器具有一组自己的 PID,这些 PID 映射到主机系统上的 PID。启动 Linux 内核时启动的第一个进程具有PID 1。对于常规操作系统,此进程是 init 系统,例如 systemd 或 SysV。同样,在容器中启动的第一个进程将获得PID 1。Docker 和 Kubernetes 使用信号与容器内的进程通信,特别是终止它们。 Docker 和 Kubernetes 都只能向容器内具有PID 1的进程发送信号。

在容器的上下文中,PID 和 Linux 信号会产生两个需要考虑的问题。

对于具有PID 1的进程,Linux 内核处理其信号的方式与处理其他进程的信号的方式有所不同。系统不会自动为此进程注册信号处理程序,这意味着 SIGTERM SIGINT 等信号在默认情况下不起作用。默认情况下,你必须使用 SIGKILL 来终止进程,防止出现任何正常关闭。使用 SIGKILL 可能会导致面向用户的错误、(数据存储区的)写入中断或监控系统中出现不必要的提醒,具体取决于你的应用。

经典 init 系统(如 systemd)也可用于移除(回收)孤立的僵尸进程。孤立进程(其父级已结束的进程)被重新附加到具有PID 1的进程,该进程应在这些进程结束时回收它们。 普通 init 系统即可做到这一点。但在容器中,这一职责由具有PID 1的进程承担。如果该进程无法正确处理回收,则可能会出现耗尽内存或一些其他资源的风险。

针对这些问题,有几个常见解决方案,将在以下各部分中进行概述。

该解决方案只解决了第一个问题。如果你的应用以可控方式生成子进程(通常是这种情况),则该解决方案有效,且避免了第二个问题。

实现此解决方案的最简单方法是使用 Dockerfile 中的CMDENTRYPOINT 组合指令来启动进程。例如,在以下 Dockerfile 中,nginx 是第一个也是唯一一个要启动的进程。

FROM debian:11

RUN apt-get update && \
    apt-get install -y nginx

EXPOSE 80

CMD [ "nginx", "-g", "daemon off;" ]
警告nginx 进程会注册自己的信号处理程序。如果使用此解决方案,在许多情况下,你必须在应用代码中执行相同操作。

有时,你可能需要在容器中准备环境,以便进程能够正常运行。在此情况下,最佳实践是让容器在启动时启动一个 shell 脚本。此 shell 脚本的任务是准备环境和启动主进程。 但是,如果采用此方法,shell 脚本将具有PID 1而不是你的进程,因此你必须使用内置的 exec 命令从 shell 脚本启动进程。 exec 命令会将脚本替换为你所需的程序。然后,你的进程将继承PID 1

如果你为 Pod 启用 进程命名空间共享,则 Kubernetes 会为该 Pod 中的所有容器使用单个进程命名空间。Kubernetes Pod 基础架构容器将成为PID 1,并自动回收孤立的进程。

正如你在较经典的 Linux 环境中所做的那样,你还可以使用 init 系统来处理这些问题。但是,如果仅出于此目的,普通 init 系统(例如 systemd 或 SysV)太过复杂而庞大,因此我们建议你使用专为容器创建的 init 系统(例如 tini )。

重要性:高

Docker 构建缓存 可以大幅度加速容器镜像的构建。 镜像是逐层构建的,在 Dockerfile 中,每条指令都会在生成的镜像中创建一层。在构建期间,如果可能,Docker 会重复使用先前构建中的层并跳过可能很耗时的步骤。 仅当所有先前的构建步骤都使用 Docker 的构建缓存时,Docker 才能使用该缓存。虽然此行为通常有助于加速构建,但你需要考虑一些情况。

例如,要充分利用 Docker 构建缓存,必须将经常更改的构建步骤置于 Dockerfile 底部。如果将它们放在顶部,则 Docker 无法将其构建缓存用于其他不经常更改的构建步骤。 由于通常会为每个新版本的源代码构建一个新 Docker 镜像,因此,请尽可能晚地向 Dockerfile 中的镜像添加源代码。 在下图中,你可以看到,如果更改 STEP 1,则 Docker 只能重复使用FROM debian:11步骤中的层。但是,如果更改 STEP 3,则 Docker 可以重复使用 STEP 1STEP 2 的层。

如何使用 Docker 构建缓存的示例

图 2. 如何使用 Docker 构建缓存的示例。 绿色表示可以重复使用的层。红色表示必须重新创建的层。

重复使用层还有另外一种结果:如果构建步骤依赖于存储在本地文件系统上的任何类型的缓存,则必须在同一构建步骤中生成此缓存。如果不生成此缓存,则构建步骤可能会通过来自先前构建的过期缓存执行。软件包管理器(如aptyum)中最常出现此行为:你必须在安装软件包的同一个RUN命令中更新代码库。

如果你在下面的 Dockerfile 中更改了第二个RUN步骤,则系统不会重新运行apt-get update命令,而是使用已过期的 apt 缓存。

FROM debian:11

RUN apt-get update
RUN apt-get install -y nginx

因此建议改为在单个RUN步骤中合并这两个命令:

FROM debian:11

RUN apt-get update && \
    apt-get install -y nginx

重要性:中

要保护你的应用免受攻击者攻击,请尝试移除任何不必要的工具,以减少应用的攻击面。 例如,移除可用于在系统内创建逆向 shell 的 netcat 等实用工具。如果 netcat 未处于容器中,攻击者就必须另寻攻击方法。

此最佳实践适用于任何工作负载,即使是非容器化的工作负载也是如此。 不同之处在于,此最佳实践针对容器(而不是经典虚拟机或裸机服务器)进行了优化。

移除不必要的工具还有助于改进调试流程。例如,如果你充分采用此最佳实践,则详尽的日志、跟踪和性能剖析系统可能几乎是必需的。 实际上,你无法再依赖本地调试工具,因为它们通常需要很高的特权。

此最佳实践的第一部分涉及容器镜像的内容。请在镜像中保留尽可能少的内容。如果你可以将应用编译为单个静态链接的二进制文件,则通过将此二进制文件添加到 暂存镜像,你可以获得仅包含应用而不包含任何其他内容的最终镜像。 通过减少镜像中打包的工具数,可以减少潜在攻击者在容器中可执行的操作。如需了解详情,请参阅 构建尽可能小的镜像

不在镜像中保留任何工具还不够:你必须防止潜在的攻击者安装其自己的工具。你可以结合使用以下两种方法:

  • 避免在容器内以 root 身份运行:此方法提供了第一层安全,并且可以防止攻击者使用镜像中嵌入的软件包管理器(例如apt-getapk)修改 root 拥有的文件。 为了使此方法起作用,你必须停用或卸载sudo命令。 避免以根身份运行 中更加广泛地介绍了此主题。
  • 以只读模式启动容器,你可以使用docker run命令中的--read-only标志或使用 Kubernetes 中的readOnlyRootFilesystem选项来执行此操作。 你可以使用PodSecurityPolicy在 Kubernetes 中强制执行此操作。
警告:如果你的应用需要将临时数据写入磁盘,你仍然可以使用readOnlyRootFilesystem选项并为临时文件添加 emptyDir 。 Kubernetes 不支持 针对 emptyDir 卷使用装载选项,因此你无法在启用noexec标志的情况下装载此卷,这意味着攻击者可以在此卷中放置二进制文件并执行该文件。

重要性:中

构建较小的镜像可以带来上传和下载速度更快等优点,这对于 Kubernetes 中 pod 的冷启动时间来说尤为重要:镜像越小,节点下载镜像的速度越快。 但是,构建小镜像可能很困难,因为你可能会在最终镜像中无意间添加构建依赖项或未优化的层。

注意:如需了解详情并查看特定于语言的示例,请参阅博文 Kubernetes 最佳实践:为何及如何构建小型容器镜像

基础镜像是 Dockerfile 的FROM指令中引用的镜像。Dockerfile 中的每个其他指令都在此镜像基础上构建。基础镜像越小,生成的镜像越小,下载速度也就越快。例如,alpine:3.17 镜像比 ubuntu:22.04 镜像小 23 MB。

你甚至可以使用 暂存基础镜像,它是一个空镜像,你可以在该镜像上构建自己的运行时环境。如果你的应用是静态链接的二进制文件,你可以使用暂存基础镜像,如下所示:

FROM scratch
COPY mybinary /mybinary
CMD [ "/mybinary" ]

以下 Kubernetes 最佳实践视频介绍了在构建小型容器的同时降低安全漏洞风险的其他策略。

要减小镜像大小,请仅在其中安装严格需要的内容。人们可能会倾向于安装额外的软件包,然后在稍后的步骤中移除它们。但仅仅依靠这种方法还不够。 Dockerfile 的每条指令都会创建一层,所以在创建镜像之后的步骤中从镜像中移除数据无法减小整个镜像的大小(数据仍然存在,只是隐藏在更深的层中)。请参考下面的示例:

差 Dockerfile

FROM debian:11

RUN apt-get update && \
    apt-get install -y \
    [buildpackage]
RUN [build my app]
RUN apt-get autoremove --purge \
    -y [buildpackage] && \
    apt-get -y clean && \
    rm -rf /var/lib/apt/lists/*

好 Dockerfile

FROM debian:11

RUN apt-get update && \
    apt-get install -y \
    [buildpackage] && \
    [build my app] && \
    apt-get autoremove --purge \
    -y [buildpackage] && \
    apt-get -y clean && \
    rm -rf /var/lib/apt/lists/*

在差版本的 Dockerfile 中,[buildpackage]/var/lib/apt/lists/*中的文件仍然存在于与第一条RUN对应的层中。 此层包含在镜像之中,必须与其余层一并上传和下载,即使其中所包含的数据在最终得到的镜像中无法访问也是如此。

在好版本的 Dockerfile 中,所有内容都在一个仅包含构建应用的层中完成。[buildpackage]/var/lib/apt/lists/*中的文件不存在于生成的镜像中的任何位置,也未隐藏在更深的层中。

如需详细了解镜像层,请参阅 优化 Docker 构建缓存

减小镜像中的杂乱程度的另一种好办法是使用多阶段构建(在 Docker 17.05 中引入)。多阶段构建允许你在第一个“构建”容器中构建应用,并将结果用于其他容器,同时使用同一 Dockerfile。

Docker 多阶段构建过程

图 3.  Docker 多阶段构建过程。

在以下 Dockerfile 中,hello 二进制文件在第一个容器中构建,并在第二个容器中注入。由于第二个容器基于 暂存镜像,因此生成的镜像仅包含 hello 二进制文件,而不包含构建期间所需的源文件和目标文件。二进制文件必须静态链接,才能在不需要暂存镜像中的任何外部库的情况下正常工作。

FROM golang:1.20 as builder

WORKDIR /tmp/go
COPY hello.go ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -ldflags '-s' -o hello

FROM scratch
CMD [ "/hello" ]
COPY --from=builder /tmp/go/hello /hello

如果必须下载 Docker 镜像,Docker 首先会检查你是否已具有镜像中的某些层。如果你具有这些层,则不会下载它们。如果你之前下载的另一镜像与你当前正在下载的镜像具有相同的基础,则可能会出现此情况。 结果是第二个镜像的已下载数据量要少得多。

在组织级别,你可以为开发者提供一组通用的标准基础镜像,从而利用这一规律减少下载量。你的系统下载各基础镜像的次数必须仅为一次。初始下载后,仅需要使每个镜像具有唯一性的层。实际上,镜像的共同点越多,下载速度就越快。

创建具有通用层的镜像

图 4.  创建具有通用层的镜像。

重要性:中

在裸机服务器和虚拟机领域,软件漏洞是一个众所周知的问题。解决这些漏洞的一种常用方法是,使用列出每个服务器上安装的软件包的集中式库存系统。 订阅上游操作系统的漏洞 Feed,以便在漏洞影响服务器时收到通知,并对其进行相应修补。

但是,由于容器应该是不可变的(详情请参阅 容器的无状态性和不变性,因此,如果存在漏洞,请勿对其进行就地修补。最佳实践是重新构建镜像(包含补丁程序),并重新部署该镜像。 与服务器相比,容器的生命周期短得多,标识不太清晰明确。因此,使用类似的集中式库存系统来检测容器中的漏洞不是一种好方法。

Artifact Analysis 可以扫描镜像是否存在公开监控的软件包中的安全漏洞,从而帮助你解决此问题。你可以使用以下选项:

自动漏洞扫描

启用后,此功能可识别容器镜像中的软件包漏洞。在将镜像上传到 Artifact Registry 或 Container Registry 后,系统会扫描镜像,并在推送镜像后的最长 30 天内持续监控数据,以查找新漏洞。你可以通过以下几种方式处理此功能报告的信息:

  • 创建一个类似 cron 的作业,该作业会列出漏洞并触发漏洞修复过程(如果存在修补程序)。
  • 检测到漏洞后,使用 Cloud Pub/Sub 集成来触发组织使用的修补过程。

On-Demand Scanning API

启用后,你可以手动扫描本地镜像或存储在 Artifact Registry 或 Container Registry 中的镜像。此功能可帮助你在构建流水线的早期阶段检测并解决漏洞。例如,你可以使用 Cloud Build 在构建镜像后对镜像进行扫描,若扫描检测到指定严重级别的漏洞,则禁止上传到 Artifact Registry。如果你还启用了自动漏洞扫描,Artifact Registry 还会扫描你上传到注册表的镜像。

我们建议自动执行修补过程,并依赖最初用于构建镜像的现有持续集成流水线。如果你对持续部署流水线有信心,则可能还想要在准备就绪时自动部署已修复的镜像。 但是,大多数人更喜欢在部署之前执行手动验证步骤。可通过以下过程实现此目标:

  1. 将镜像存储在 Artifact Registry 中并启用漏洞扫描。
  2. 配置一个作业,该作业定期从 Artifact Registry 中获取新漏洞,并在需要时触发镜像的重新构建。
  3. 构建新镜像后,让持续部署系统将它们部署到暂存环境。
  4. 手动检查暂存环境是否存在问题。
  5. 如果未发现任何问题,手动触发到生产的部署。

重要性:中

Docker 镜像通常由两个部分标识:它们的名称和标记。例如,对于google/cloud-sdk:419.0.0镜像,google/cloud-sdk是名称,而419.0.0是标记。如果你未在 Docker 命令中提供标记,则系统默认使用latest标记。在任意给定时间,名称/标记对都是唯一的。但是,你可以根据需要将标记重新分配给其他镜像。

构建镜像时,是否正确标记镜像取决于你。请遵循一致的标记策略。记录你的标记政策,以便镜像用户能够轻松理解它。

容器镜像是一种封装和发布软件的方式。 通过标记镜像,用户即可识别特定版本的软件,从而方便下载。因此,请将容器镜像上的标记系统与软件的发布政策紧密关联。

发布软件的常用方法是使用版本号“标记”(如git tag命令中所示)特定版本的源代码。语义版本控制规范提供了一种用于处理版本号的简便方法。在此系统中,你的软件版本号由三部分组成:X.Y.Z,其中:

  • X是主要版本号,仅在发布不兼容的 API 更改时递增。
  • Y是次要版本号,在发布新功能时递增。
  • Z是补丁程序版本,在发布 bug 修复时递增。

次要版本号或补丁程序版本号的每次递增都必须针对向后兼容的更改。

如果你使用此系统或类似系统,请根据以下政策标记你的镜像:

  • latest标记始终表示最新(可能处于稳定状态)的镜像。创建新镜像后,此标记会立即移动。
  • X.Y.Z标记表示软件的某个特定版本。请勿将其移动至其他镜像。
  • X.Y标记表示软件的 X.Y 次要分支的最新补丁程序版本。在有新的补丁程序版本发布后,此标记会移动。
  • X标记表示 X 主要分支的最新次要版本的最新补丁程序版本。在有新的补丁程序版本或新的次要版本发布时,系统会移动此标记。

通过使用此政策,用户可以灵活选择要使用的软件版本。他们可以选择特定的 X.Y.Z 版本,并可确保该镜像永远不会更改,也可以选择一个不太具体的标记,自动获取更新。

如果你拥有高级持续交付系统并且经常发布软件,则可能不会使用 “语义版本控制规范” 中所述的版本号。在此情况下,处理版本号的常见方法是使用 Git 提交SHA-1哈希值(或其较短版本)作为版本号。按照设计,Git 提交哈希值是不可变的,其引用特定版本的软件。

你可以将此提交哈希值用作软件的版本号,也可以将其用作从此特定版本的软件构建的 Docker 镜像的标签。 这样做可使 Docker 镜像可跟踪:由于在此情况下镜像标记是不可变的,因此你可以立即知道哪个特定版本的软件正在给定容器内运行。在持续交付流水线中,系统会为你的部署使用版本号自动更新功能。

重要性:无

注意:严格来说,这项声明不是最佳实践,而是你在使用容器的过程中必须处理的一个主题。你的组织及其限制会影响你对解决方案的选择。

Docker 的一大优势是适用于各种软件的大量公开发布的镜像。这些镜像可帮助你快速上手。但是,为组织设计容器策略时,你可能会遇到公开提供的镜像无法满足的限制。 以下是一些可能导致无法使用公开镜像的限制示例:

  • 需要准确控制镜像内的内容。
  • 不希望依赖外部代码库。
  • 需要严格控制生产环境中的漏洞。
  • 需要在每个镜像中使用相同的基本操作系统。

满足所有这些限制的方法是相同的,可惜代价很高:你必须构建自己的镜像。对于数量有限的镜像,构建你自己的镜像是可行的,但此数量具有快速增长的趋势。 要规模化管理此类系统,请考虑使用以下方法:

  • 一种以可靠方式构建镜像的自动化方法,即使对于很少构建的镜像也是如此。 Cloud Build 中的构建触发器是实现这一点的好方法。
  • 标准化的基础镜像。Google 提供了一些可供你使用的基础镜像。
  • 一种将基础镜像的更新传播到“子”镜像的自动化方法。
  • 一种处理镜像漏洞的方法。如需了解详情,请参阅扫描镜像是否存在漏洞。
  • 一种对组织中不同团队创建的镜像强制执行内部标准的方法。

以下几种工具可帮助你对你构建和部署的镜像强制执行政策:

  • container-diff 可以分析镜像内容,甚至可以对两个镜像进行比较。
  • container-structure-test 可以测试镜像内容是否符合你定义的一组规则。
  • Grafeas 是一种软件工件元数据 API,你可以在其中存储有关镜像的元数据,以便稍后检查这些镜像是否符合你的政策。
  • Kubernetes 具有 准入控制器,该控制器可用于在 Kubernetes 中部署工作负载之前检查许多先决条件。
  • Kubernetes 还具有 pod 安全政策,可用于在集群中强制使用安全选项。
废弃:请注意,PodSecurityPolicy 在 Kubernetes 1.21 版中 已弃用,并且已从 1.25 版 Kubernetes 中移除。

你可能还想采用混合系统:使用 DebianAlpine 等公开镜像作为基础镜像,并以该镜像为基础进行构建。 或者,你可能想要将公开镜像用于某些非关键镜像,并针对其他情况构建自己的镜像。这些问题并没有正确或错误的答案,但你必须对其进行处理。

在 Docker 镜像中添加第三方库和软件包之前,请确保相应许可允许你执行此操作。第三方许可还可能对重新分配施加限制,这些限制会在你将 Docker 镜像发布到公共注册表时应用。