自学内容网 自学内容网

如何为Kubernetes构建最优的容器镜像(一):高效容器镜像的特征、基础镜像层的重用与镜像层的管理

镜像是 Kubernetes 中定义应用的主要打包格式。镜像被用作 Pod 和其他对象的基础,并在有效利用 Kubernetes 功能方面发挥着重要作用。设计精良的镜像具有安全性高、性能卓越、针对性强的特点。它们能够响应 Kubernetes 提供的配置数据或指令,并能够实现 deployment 用来了解内部应用状态的探针。

在本文中,我们将介绍一些创建高质量镜像的策略,并讨论几个通用目标,以指导您在将应用容器化时做出决策。我们将重点关注构建旨在在 Kubernetes 上运行的镜像,但这些建议中的许多也同样适用于在其他编排平台。

高效容器镜像的特征

在介绍构建容器镜像时需要采取的具体行动之前,我们先来谈谈什么是好的容器镜像。设计新镜像时,你的目标应该是什么?哪些特性和行为最为重要?

  1. 单一、明确的目的

容器镜像应该有一个单一且具体的焦点。不可将容器镜像视为虚拟机而在其中将相关功能打包在一起。相反,应该将你的容器镜像视为工具,严格关注如何做好一件小事。应用可以在单个容器范围之外进行协调,以提供更复杂的功能。

  1. 通用设计,具备运行时注入配置的能力

在设计容器镜像时,应尽可能考虑到可重用性。例如,为了满足诸如在部署到生产环境前对镜像进行测试等基本需求,往往需要具备运行时调整配置的能力。小型的、通用的镜像可以以不同的配置组合起来,以便在不创建新镜像的情况下修改其行为。

  1. 镜像体积小

在像 Kubernetes 这样的集群环境中,较小的镜像具有许多优势。它们可以快速下载到新的节点上,并且通常包含较少的已安装软件包,这可以提高安全性。精简的容器镜像通过减少涉及的软件量,使问题调试变得更为简单。

  1. 不在容器内保存应用状态

在集群环境中,容器会经历非常不稳定的生命周期,包括因资源稀缺、扩展或节点故障导致的计划内和计划外关闭。为了保持一致性、帮助服务恢复和确保可用性,以及避免数据丢失,将应用状态存储在容器外部的稳定位置至关重要。

  1. 易于理解

尽量保持容器镜像简单易懂非常重要。在故障排除时,能够直接查看配置和日志,或测试容器行为,有助于更快地找到解决方案。将容器镜像视为应用的打包格式而不是机器的,有助于你找到正确的平衡点。

  1. 遵循容器化软件的最佳实践

镜像应致力于符合容器模型,而不是与之相反。应避免遵循传统的系统管理实践,比如包含完整的初始化系统、守护进程化应用。应将日志输出至标准输出stdout,这样Kubernetes可以将数据暴露给管理员,而不是使用内部日志守护进程。这些建议在很大程度上与标准操作系统最佳实践有所不同。

  1. 充分利用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为例来说明这一点,但这一原则同样适用于其他发行版的基础镜像:

FROM ubuntu:20.04
RUN apt -y update
RUN apt -y install nginx
. . .

在这里,我们使用RUN指令(apt-get update)更新本地包索引,而在另一个指令中安装Nginx。当首次使用时,这可以正常工作。然而,如果我们在之后的某天更新了Dockerfile以安装额外的包:

FROM ubuntu:20.04
RUN apt -y update
RUN apt -y install nginx php-fpm
. . .

由于自上次构建镜像以来已经过去了相当长的时间,新的构建可能会失败。这是因为包索引更新指令(apt-get update)没有改变,所以Docker会重用与该指令关联的镜像层。由于我们使用的是旧的包索引(即第一次构建时更新好的索引),此时我们本地索引中的php-fpm包版本可能已经从远端存储库中删除了,导致第二个RUN指令运行时出现错误。

为了避免这种情况,建议将所有相互依赖的步骤合并到一个RUN指令中,以便在发生更改时Docker会重新执行所有必要的命令。在shell脚本中,使用逻辑AND操作符&&是一个好方法,它可以在同一行上执行多个命令:

FROM ubuntu:20.04
RUN apt -y update && apt -y install nginx php-fpm
. . .

这样,每当包列表发生变化时,该指令都会更新本地包缓存。另一种方法是运行一个包含多行指令的shell脚本,当然前提是通过诸如copy等方式将其提供给容器。

通过调整RUN指令来减小镜像层大小

关于RUN指令如何与Docker的分层系统交互,还有一些其他需要注意的事项。如前所述,在每个RUN指令结束时,Docker都会将更改提交为一个额外的镜像层。为了控制所生成的镜像层的大小,需要注意你所运行的命令是否引入了一些不必要的文件。

例如,许多Dockerfile将rm -rf /var/lib/apt/lists/*接在apt命令的末尾,以删除下载的包索引,从而减小最终层的大小:

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来移除这种行为(当然这里需要谨慎一点,确认一下你的应用是否依赖于推荐包提供的某些功能)。

使用多阶段构建

多阶段构建是在Docker 17.05中引入的,它允许开发人员更精准地控制生成的最终运行时镜像。多阶段构建允许你将Dockerfile分成多个代表不同阶段的部分,每个部分都有一个FROM语句来指定单独的父镜像。

# syntax=docker/dockerfile:1
FROM golang:1.21
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]

在Dockerfile的第一阶段,会使用一个包含了Go SDK或者其他必要构建工具的基础镜像。在这个阶段,通过一系列的指令执行源代码编译、静态链接等操作,生成应用程序的二进制文件。这个阶段会产生很多中间文件和构建工具,它们对于最终运行应用来说不是必需的。

从第二个FROM指令开始,是一个新的构建阶段,这个阶段通常基于体积很小的scratch镜像(没有文件系统的空镜像)。COPY --from=0命令从先前的构建阶段(在这里是索引为0的第一阶段)中仅复制已编译好的二进制文件到当前阶段中。这里并不会复制任何构建工具、源代码或者其他中间产物,因此最终的结果是一个仅包含所需的应用程序二进制文件的极小的生产镜像。

这种方式可以让你在构建阶段减少对RUN指令优化的担忧,因为这些容器层不会出现在最终的运行时镜像中。通过分离容器构建的不同阶段,我们可以更容易地获得精简的镜像,而无需增加Dockerfile的复杂性。


原文地址:https://blog.csdn.net/sinat_32582203/article/details/137511230

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!