为Kubernetes构建优化的容器
介绍
容器镜像是在 Kubernetes 中定义应用程序的主要打包格式。 图像用作 pod 和其他对象的基础,在有效利用 Kubernetes 的特性方面发挥着重要作用。 精心设计的图像是安全的、高性能的和专注的。 他们能够对 Kubernetes 提供的配置数据或指令做出反应。 它们实现了部署用来了解其内部应用程序状态的端点。
在本文中,我们将介绍一些创建高质量图像的策略,并讨论一些一般目标,以帮助指导您在容器化应用程序时做出决策。 我们将专注于构建旨在在 Kubernetes 上运行的镜像,但其中许多建议同样适用于在其他编排平台或其他上下文中运行容器。
高效容器镜像的特点
在我们讨论构建容器镜像时要采取的具体操作之前,我们将讨论什么是好的容器镜像。 在设计新图像时,您的目标应该是什么? 哪些特征和行为最重要?
一些要追求的品质是:
单一、明确的目的
容器图像应该有一个独立的焦点。 避免将容器映像视为虚拟机,将相关功能打包在一起是有意义的。 取而代之的是,像对待 Unix 实用程序一样对待你的容器镜像,保持严格专注于做好一件小事。 应用程序可以在单个容器范围之外进行协调,以提供更复杂的功能。
能够在运行时注入配置的通用设计
容器图像的设计应尽可能考虑重用。 例如,通常需要在运行时调整配置的能力来满足基本要求,例如在部署到生产之前测试图像。 小型通用图像可以以不同的配置组合以修改行为,而无需创建新图像。
图像尺寸小
较小的镜像在 Kubernetes 等集群环境中有许多好处。 它们可以快速下载到新节点,并且通常安装的软件包数量较少,这可以提高安全性。 通过减少所涉及的软件数量,精简的容器映像使调试问题变得更简单。
外部管理状态
集群环境中的容器经历了一个非常不稳定的生命周期,包括由于资源稀缺、扩展或节点故障而导致的计划内和计划外关闭。 为了保持一致性、帮助恢复和服务可用性以及避免丢失数据,将应用程序状态存储在容器外部的稳定位置至关重要。
容易明白
尽量使容器图像尽可能简单易懂,这一点很重要。 在进行故障排除时,能够直接查看配置和日志,或测试容器行为,可以帮助您更快地找到解决方案。 将容器映像视为应用程序的打包格式而不是机器配置可以帮助您取得适当的平衡。
遵循容器化软件最佳实践
图像应该旨在在容器模型中工作,而不是与之对抗。 避免实施传统的系统管理实践,例如包括完整的初始化系统和守护程序应用程序。 登录到 stdout
以便 Kubernetes 可以向管理员公开数据,而不是使用内部日志记录守护程序。 这些建议与完整操作系统的最佳实践大相径庭。
充分利用 Kubernetes 功能
除了符合容器模型之外,理解和协调 Kubernetes 提供的工具也很重要。 例如,为活动性和就绪性检查提供端点或根据配置或环境的变化调整操作可以帮助您的应用程序充分利用 Kubernetes 的动态部署环境。
既然我们已经建立了一些定义高功能容器映像的品质,我们可以更深入地研究帮助您实现这些目标的策略。
重用最少的共享基础层
我们可以从检查构建容器镜像的资源开始:基础镜像。 每个容器镜像都是从 父镜像 (用作起点的镜像)构建的,或者从抽象的 scratch
层(没有文件系统的空镜像层)构建。 基础镜像 是一个容器镜像,它通过定义基本操作系统和提供核心功能作为未来镜像的基础。 图像由一个或多个相互叠加的图像层组成,以形成最终图像。
直接从 scratch
工作时没有可用的标准实用程序或文件系统,这意味着您只能访问极其有限的功能。 虽然直接从 scratch
创建的图像可以非常精简和最小化,但它们的主要目的是定义基本图像。 通常,您希望在父镜像之上构建容器镜像,该父镜像设置应用程序运行的基本环境,这样您就不必为每个镜像构建完整的系统。
虽然有各种 Linux 发行版的基本映像,但最好慎重选择您选择的系统。 每台新机器都必须下载父映像和您添加的任何附加层。 对于大图像,这会消耗大量带宽,并显着延长容器在首次运行时的启动时间。 没有办法削减在容器构建过程中用作下游父级的映像,因此从最小的父级开始是个好主意。
Ubuntu 等功能丰富的环境允许您的应用程序在您熟悉的环境中运行,但需要考虑一些权衡。 Ubuntu 镜像(以及类似的传统分发镜像)往往相对较大(超过 100MB),这意味着从它们构建的任何容器镜像都将继承该权重。
Alpine Linux 是基本映像的一种流行替代方案,因为它成功地将许多功能打包到一个非常小的基本映像(约 5MB)中。 它包括一个带有大量存储库的包管理器,并具有您期望从最小 Linux 环境中获得的大多数标准实用程序。
在设计您的应用程序时,尝试为每个图像重用相同的父级是个好主意。 当您的图像共享父层时,运行您的容器的机器将只下载一次父层。 之后,他们只需要下载图像之间不同的图层。 这意味着,如果您希望在每个图像中嵌入共同的特性或功能,那么创建一个共同的父图像来继承可能是一个好主意。 共享沿袭的图像有助于最大限度地减少您需要在新服务器上下载的额外数据量。
管理容器层
选择父映像后,您可以通过添加其他软件、复制文件、公开端口和选择要运行的进程来定义容器映像。 镜像配置文件中的某些指令(例如,如果您使用 Docker,则为 Dockerfile
)将为您的镜像添加额外的层。
由于上一节中提到的许多相同原因,由于结果大小、继承和运行时复杂性,请务必注意如何向图像添加图层。 为了避免构建大而笨重的镜像,重要的是要很好地理解容器层如何交互、构建引擎如何缓存层,以及相似指令的细微差异如何对您创建的镜像产生重大影响。
了解图像层和构建缓存
Docker 每次执行 RUN
、COPY
或 ADD
指令时都会创建一个新的镜像层。 如果再次构建镜像,构建引擎将检查每条指令,看它是否有为操作缓存的镜像层。 如果在缓存中找到匹配项,则使用现有的图像层,而不是再次执行指令并重建层。
此过程可以显着缩短构建时间,但了解用于避免潜在问题的机制很重要。 对于 COPY
和 ADD
等文件复制指令,Docker 会比较文件的校验和,看是否需要再次执行操作。 对于 RUN
指令,Docker 检查它是否为该特定命令字符串缓存了现有的图像层。
虽然可能不会立即明显,但如果您不小心,此行为可能会导致意外结果。 一个常见的例子是更新本地包索引并在两个单独的步骤中安装包。 我们将在此示例中使用 Ubuntu,但基本前提同样适用于其他发行版的基础映像:
包安装示例 Dockerfile
FROM ubuntu:20.04 RUN apt -y update RUN apt -y install nginx . . .
在这里,本地包索引在一个 RUN
指令(apt -y update
)中更新,Nginx 在另一个操作中安装。 这在第一次使用时没有问题。 但是,如果稍后更新 Dockerfile 以安装额外的包,则可能会出现问题:
包安装示例 Dockerfile
FROM ubuntu:20.04 RUN apt -y update RUN apt -y install nginx php-fpm . . .
我们在第二条指令运行的安装命令中添加了第二个包。 如果自上一个映像构建以来已经过了很长时间,则新构建可能会失败。 那是因为包索引更新指令 (RUN apt -y update
) 的 not 改变了,所以 Docker 重用了与该指令关联的镜像层。 由于我们使用的是旧的包索引,因此我们在本地记录中的 php-fpm
包的版本可能不再在存储库中,导致运行第二条指令时出错。
为避免这种情况,请确保将任何相互依赖的步骤合并到单个 RUN
指令中,以便 Docker 在发生更改时重新执行所有必要的命令。 在 shell 脚本中,使用 逻辑 AND 运算符 &&
,只要它们都成功,它将在同一行上执行多个命令,这是实现此目的的好方法:
包安装示例 Dockerfile
FROM ubuntu:20.04 RUN apt -y update && apt -y install nginx php-fpm . . .
现在,只要包列表更改,该指令就会更新本地包缓存。 另一种方法是 RUN
一个包含多行指令的整个 shell.sh
脚本,但必须首先对容器可用。
通过调整 RUN 指令减小图像层大小
前面的例子演示了 Docker 的缓存行为是如何颠覆预期的,但是关于 RUN
指令如何与 Docker 的分层系统交互,还有一些其他的事情需要记住。 如前所述,在每条 RUN
指令的末尾,Docker 将更改提交为附加的镜像层。 为了控制生成的图像层的范围,您可以通过注意您运行的命令引入的工件来清理不必要的文件。
通常,将命令链接到单个 RUN
指令中可以对将要写入的层进行大量控制。 对于每个命令,您可以设置层的状态 (apt -y update
),执行核心命令 (apt install -y nginx php-fpm
),并在提交之前删除任何不必要的工件以清理环境。 例如,许多 Dockerfile 将 rm -rf /var/lib/apt/lists/*
链到 apt
命令的末尾,删除下载的包索引,以减少最终层大小:
包安装示例 Dockerfile
FROM ubuntu:20.04 RUN apt -y update && apt -y install nginx php-fpm && rm -rf /var/lib/apt/lists/* . . .
为了进一步减小您正在创建的图像层的大小,尝试限制您正在运行的命令的其他意外副作用可能会有所帮助。 例如,除了显式声明的包外,apt
还默认安装“推荐”包。 您可以在 apt
命令中包含 --no-install-recommends
以消除此行为。 您可能需要进行试验以确定您是否依赖推荐的软件包提供的任何功能。
我们在本节中使用了包管理命令作为示例,但这些相同的原则也适用于其他场景。 总体思路是构建先决条件,执行最小可行命令,然后在单个 RUN
命令中清理任何不必要的工件,以减少您将生成的层的开销。
使用多阶段构建
多阶段构建 在 Docker 17.05 中引入,允许开发人员更严格地控制他们生成的最终运行时映像。 多阶段构建允许您将 Dockerfile 划分为代表不同阶段的多个部分,每个部分都有一个 FROM
语句来指定单独的父映像。
前面的部分定义了可用于构建应用程序和准备资产的图像。 这些通常包含生成应用程序所需的构建工具和开发文件,但不是运行它所必需的。 文件中定义的每个后续阶段都可以访问前一阶段生成的工件。
最后一个 FROM
语句定义将用于运行应用程序的图像。 通常,这是一个精简的镜像,它只安装必要的运行时需求,然后复制前一阶段生成的应用程序工件。
该系统让您不必担心在构建阶段优化 RUN
指令,因为这些容器层不会出现在最终的运行时映像中。 您仍应注意在构建阶段指令如何与层缓存交互,但您的努力可以直接用于最小化构建时间而不是最终图像大小。 注意最后阶段的指令对于减小图像大小仍然很重要,但是通过分离容器构建的不同阶段,更容易获得流线型的图像,而没有 Dockerfile
那样的复杂性。
容器和 Pod 级别的范围功能
虽然您在容器构建说明方面做出的选择很重要,但有关如何将服务容器化的更广泛决策通常会对您的成功产生更直接的影响。 在本节中,我们将更多地讨论如何最好地将您的应用程序从更传统的环境过渡到在容器平台上运行。
按功能容器化
通常,将每个独立功能打包到单独的容器映像中是一种很好的做法。
这与虚拟机环境中采用的常见策略不同,在虚拟机环境中,应用程序经常在同一个映像中组合在一起,以减小大小并最大限度地减少运行 VM 所需的资源。 由于容器是轻量级抽象,不会虚拟化整个操作系统堆栈,因此这种权衡在 Kubernetes 上不太引人注目。 因此,虽然 Web 堆栈虚拟机可能会将 Nginx Web 服务器与 Gunicorn 应用程序服务器捆绑在一台机器上以服务于 Django 应用程序,但在 Kubernetes 中,它们可能会被拆分为单独的容器。
设计为您的服务实现一个离散功能的容器提供了许多优势。 如果建立了服务之间的标准接口,则每个容器都可以独立开发。 例如,Nginx 容器可以潜在地用于代理到许多不同的后端,或者如果给定不同的配置,可以用作负载均衡器。
部署后,每个容器映像都可以独立扩展,以解决不同的资源和负载限制。 通过将应用程序拆分为多个容器映像,您可以在开发、组织和部署方面获得灵活性。
在 Pod 中组合容器镜像
在 Kubernetes 中,pods 是控制平面可以直接管理的最小单元。 Pod 由一个或多个容器以及额外的配置数据组成,以告诉平台应该如何运行这些组件。 Pod 中的容器总是调度在集群中的同一个工作节点上,系统会自动重启失败的容器。 pod 抽象非常有用,但它引入了另一层决策,即如何将应用程序的组件捆绑在一起。
与容器镜像一样,当太多功能被捆绑到一个实体中时,Pod 也会变得不那么灵活。 Pod 本身可以使用其他抽象进行扩展,但其中的容器不能独立管理或扩展。 因此,为了继续使用我们之前的示例,单独的 Nginx 和 Gunicorn 容器可能不应该捆绑到一个 pod 中。 这样,它们可以单独控制和部署。
但是,在某些情况下,将功能不同的容器组合为一个单元确实有意义。 通常,这些可以归类为附加容器支持或增强主容器的核心功能或帮助其适应其部署环境的情况。 一些常见的模式是:
- Sidecar:辅助容器通过充当支持实用程序角色来扩展主容器的核心功能。 例如,当远程存储库发生更改时,sidecar 容器可能会转发日志或更新文件系统。 主容器仍然专注于其核心职责,但通过 sidecar 提供的功能得到增强。
- Ambassador:大使容器负责发现和连接(通常是复杂的)外部资源。 主容器可以使用内部 pod 环境在众所周知的接口上连接到大使容器。 大使抽象后端资源并代理主容器和资源池之间的流量。
- Adaptor:适配器容器负责规范主容器的接口、数据和协议,以与其他组件预期的属性保持一致。 主容器可以使用本机格式运行,适配器容器转换和规范化数据以与外界通信。
您可能已经注意到,这些模式中的每一个都支持构建标准的通用主容器镜像的策略,然后可以将其部署在各种上下文和配置中。 辅助容器有助于弥合主容器和正在使用的特定部署环境之间的差距。 一些 Sidecar 容器也可以重复使用,以使多个主容器适应相同的环境条件。 这些模式受益于 pod 抽象提供的共享文件系统和网络命名空间,同时仍然允许标准化容器的独立开发和灵活部署。
设计运行时配置
构建标准化、可重用组件的愿望与使应用程序适应其运行时环境所涉及的要求之间存在一些紧张关系。 运行时配置是弥合这些问题之间差距的最佳方法之一。 这样,组件被构建为通用的,并且通过提供额外的配置细节在运行时概述了它们所需的行为。 这种标准方法适用于容器,也适用于应用程序。
考虑到运行时配置的构建需要您在应用程序开发和容器化步骤中提前考虑。 应用程序应设计为在启动或重新启动时从命令行参数、配置文件或环境变量中读取值。 这种配置解析和注入逻辑必须在容器化之前在代码中实现。
在编写 Dockerfile 时,还必须在设计容器时考虑运行时配置。 容器有许多在运行时提供数据的机制。 用户可以从主机挂载文件或目录作为容器内的卷,以启用基于文件的配置。 同样,环境变量可以在容器启动时传递到内部容器运行时。 CMD
和 ENTRYPOINT
Dockerfile 指令也可以定义为允许将运行时配置信息作为命令行参数传入。
由于 Kubernetes 操作 pod 等更高级别的对象,而不是直接管理容器,因此有一些机制可用于定义配置并在运行时将其注入容器环境。 Kubernetes ConfigMaps 和 Secrets 允许您单独定义配置数据,然后在运行时将这些值投影到容器环境中。 ConfigMap 是通用对象,旨在存储可能因环境、测试阶段等而异的配置数据。 Secrets 提供了类似的接口,但专为敏感数据设计,例如帐户密码或 API 凭据。
通过理解和正确使用每个抽象层中可用的运行时配置选项,您可以构建灵活的组件,这些组件从环境提供的值中获取线索。 这使得在非常不同的场景中重用相同的容器镜像成为可能,通过提高应用程序的灵活性来减少开发开销。
使用容器实现流程管理
在过渡到基于容器的环境时,用户通常首先将现有工作负载转移到新系统上,而很少或没有变化。 他们通过将他们已经在新抽象中使用的工具包装在容器中来打包应用程序。 虽然使用通常的模式来启动和运行迁移的应用程序很有帮助,但在容器中放弃以前的实现有时会导致设计无效。
将容器视为应用程序,而不是服务
当开发人员在容器中实现重要的服务管理功能时,经常会出现问题。 例如,在容器内运行 systemd 服务或守护 Web 服务器可能被认为是正常计算环境中的最佳实践,但它们通常与容器模型中固有的假设相冲突。
主机通过向容器内以 PID(进程 ID)1 运行的进程发送信号来管理容器生命周期事件。 PID 1 是第一个启动的进程,它将是传统计算环境中的 init 系统。 但是,由于主机只能管理 PID 1,使用传统的 init 系统来管理容器内的进程有时意味着无法控制主应用程序。 主机可以启动、停止或终止内部初始化系统,但不能直接管理主应用程序。 这些信号有时可以将预期的行为传播到正在运行的应用程序,但这仍然会增加复杂性并且并不总是必要的。
大多数情况下,最好简化容器内的运行环境,以便 PID 1 在前台运行主应用程序。 在必须运行多个进程的情况下,PID 1 负责管理后续进程的生命周期。 某些应用程序,如 Apache,通过生成和管理处理连接的工作程序来本地处理此问题。 对于其他应用程序,可以使用包装脚本或非常精简的初始化系统,如 dumb-init 或包含的 tini 初始化系统。 无论您选择哪种实现方式,在容器中作为 PID 1 运行的进程都应该适当地响应 Kubernetes 发送的 TERM
信号以按预期运行。
在 Kubernetes 中管理容器运行状况
Kubernetes 部署和服务为长期运行的进程和对应用程序的可靠、持久的访问提供生命周期管理,即使在底层容器需要重新启动或实现本身发生变化时也是如此。 通过从容器中提取监控和维护服务健康的责任,您可以利用平台的工具来管理健康的工作负载。
为了让 Kubernetes 正确管理容器,它必须了解在容器中运行的应用程序是否健康并能够执行工作。 为了实现这一点,容器可以实现活动探测:即,可用于报告应用程序运行状况的网络端点或命令。 Kubernetes 将定期检查定义的活动探测以确定容器是否按预期运行。 如果容器没有正确响应,Kubernetes 会重新启动容器以尝试重新建立功能。
Kubernetes 还提供了就绪探测,一个类似的结构。 就绪探测不是指示容器内的应用程序是否健康,而是确定应用程序是否准备好接收流量。 当容器化应用程序具有必须在准备好接收连接之前完成的初始化例程时,这可能很有用。 Kubernetes 使用就绪探针来确定是向服务添加 pod 还是从服务中删除 pod。
为这两种探测类型定义端点可以帮助 Kubernetes 有效地管理您的容器,并且可以防止容器生命周期问题影响服务可用性。 响应这些类型的健康请求的机制必须内置到应用程序本身中,并且必须在 Docker 映像配置中公开。
结论
在本指南中,我们介绍了在 Kubernetes 中运行容器化应用程序时要牢记的一些重要注意事项。 重申一下,我们讨论的一些建议是:
- 使用最少的、可共享的父映像来构建具有最小膨胀的映像并减少启动时间
- 使用多阶段构建来分离容器构建和运行时环境
- 结合 Dockerfile 指令创建干净的镜像层,避免镜像缓存错误
- 通过隔离离散功能实现容器化以实现灵活的扩展和管理
- 将 pod 设计为具有单一、集中的职责
- 捆绑辅助容器以增强主容器的功能或使其适应部署环境
- 构建应用程序和容器以响应运行时配置,从而在部署时提供更大的灵活性
- 将应用程序作为容器中的主要进程运行,以便 Kubernetes 可以管理生命周期事件
- 在应用程序或容器中开发健康和活跃度端点,以便 Kubernetes 可以监控容器的健康状况
在整个开发和实施过程中,您需要做出可能影响服务稳健性和有效性的决策。 了解容器化应用程序与传统应用程序的不同之处,并了解它们如何在托管集群环境中运行,可以帮助您避免一些常见的陷阱,并允许您利用 Kubernetes 提供的所有功能。