与脚本化 Dockerfile 相比,声明式云原生 buildpack 支持一些新场景。
容器无处不在
容器在大多数软件交付管道中无处不在,有时不是直接可见的,但它们存在于幕后。无论我们是在 Kubernetes、普通 Docker 主机、serverless functions 还是许多其他编排平台上运行软件,容器都代表了不可变的可运行软件工件。
将应用程序源代码转换为正在运行的应用程序需要一个中间容器构建阶段,而将软件转换为容器的一种非常流行的方法是通过 Dockerfile。
Dockerfiles
从 Dockerfile 构建容器是一种脚本化方法,Dockerfile 中的大部分 内容基本上是构建软件、安装依赖项等所需的命令。这也意味着学习如何使用 Dockerfile 的学习曲线很浅,并且现有构建脚本可以毫不费力地将其移植到 Dockerfile 中。
然而,事实证明,制作高质量的容器镜像并非易事。互联网上充满了制作小型、安全、最佳实践镜像的指南。通常围绕:
- 确保正确处理信号,例如SIGTERM是容器合约的一部分。
- 不要使用 root 用户运行应用程序。
- 不要在容器中包含不必要的工具、机密或构建工件。
- 按照优化缓存的顺序添加层,例如首先更改最少的层。
- 正确标记和签署镜像。
下面说明了一个 Dockerfile,它为 NodeJS 应用程序实现了其中的一些建议;使用两阶段构建生成一个小镜像,以非 root 身份运行,并仔细排序操作以改进缓存:
FROM node:16.13.1-alpine3.14 AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json .
COPY src src
RUN npm run build
RUN npm prune — production
FROM node:16.13.1-alpine3.14
WORKDIR /usr/src/app
USER node
ENV NODE_ENV production
COPY — from=builder /usr/src/app/node_modules/ ./node_modules
COPY — from=builder /usr/src/app/dist/ ./dist
EXPOSE 8000
CMD [ "node", "/usr/src/app/dist/main.js" ]
编写高质量的 Dockerfile 需要相当多的努力,有时 Dockerfile 通常只是其他项目的 Dockerfile 的副本。这会导致 Dockerfile 碎片化,对于拥有多个容器的组织来说,这很快就会变得难以管理。
Buildpacks
Buildpacks 源于这样一种想法,即对于给定类型的大多数应用程序,将应用程序源代码转换为容器或多或少是相同的。这意味着我们可以为这个过程设计可重用的程序。这个概念自 Heroku 发起并被 Cloud Foundry、Google App engine、Gitlab、CircleCI 等采用以来,已经发展超过 10 年。
社区努力确保它们提供高质量的容器镜像构建,而不是为每个应用程序使用碎片化 Dockerfiles,并具有不同级别的安全和最佳实践。
构建容器镜像的声明式方法
使用 buildpacks 时,我们需要了解 buildpacks 如何构建容器。与其编写脚本如何使用 Dockerfiles 构建容器,不如声明我们期望在容器中打包的内容,并让 buildpacks 找出细节。
Buildpacks 实现了许多阶段,其中两个是:
- Detect:每个 buildpack 检测它是否可以参与容器构建。例如,对于 NodeJS 应用程序,_npm_ buildpack 可能会查找package.json文件。如果找到,buildpack 将通知构建器它可以参与构建并贡献依赖项。Python buildpack 会查找requirements.txt文件,但是,不会在 NodeJS 应用程序中找到它,因此不会参与构建。
- Build:在这个阶段,所有表明它们可以参与构建的 Buildpacks 都将被执行以实现构建。如上所述,重要的是我们知道 Buildpacks 如何检测应该构建的内容。即 NodeJS 应用程序的开发人员应确保项目包含一个 package.json 文件。此外,如果我们期望某个版本的 Node 运行时或其他依赖项,则应在 package.json 文件中明确说明,例如:
{
"engines": {
"node": "16.13.1",
"npm": "8.1.2"
},
"dependencies": {
"express": "4.17.2"
}
}
容器镜像携带多种形式的元数据,Buildpacks 通常使用环境变量来声明元数据设置。以下是使用环境变量设置标准org.opencontainers.description标签的示例,该标签使用Paketo image-labels buildpack在容器镜像上。环境变量的配置是通过 project.toml 文件完成的,这是声明构建配置的常用格式:
[[build.env]]
name = "BP_OCI_DESCRIPTION"
value = "Sample NodeJS from https://github.com/MichaelVL/buildpacks"
了解 Buildpacks 的这种声明性 API 对开发人员来说是必不可少的。开发人员不应该关心 Dockerfile,他们应该知道 buildpacks 的声明式 API。
可复制的构建、交付链安全
Buildpacks 努力实现可重现的构建。构建过程是完全确定的,并且在使用相同的输入执行时会产生相同的输出。这使我们能够准确地验证将哪个应用程序二进制文件或源打包到容器中,并且可以保护软件交付链免受恶意应用程序被打包到容器中。
当使用 Dockerfiles 构建镜像时,容器镜像(及其哈希值 sha256 摘要)在每次重建镜像时都会发生变化,即使提供完全相同的输入也是如此。
可重现的构建是软件工件供应链级别第 4 级的要求,而 Buildpacks 是提高容器镜像供应链安全性的重要工具。
可重现的构建也是一种避免不必要的容器层重建的有效机制。
改进的重建速度和缓存
Dockerfile 中的每一行基本上都会为最终的容器镜像贡献一层。除非先前的图层发生更改,否则图层会被缓存并重用。当前面的层更改时,所有后续层都将重新构建和更改,因为 Docker 不应用可重现的构建。
使用 buildpacks,每个 buildpack 都会为容器镜像贡献一个层。如果 buildpack 的输入没有改变,则 buildpack 层不会改变,无论前面的层是否改变。此外,使用 buildpack 可以替换每个层,而不会影响其他 buildpack 生成的层。
如果需要更新容器基础镜像(例如由于安全问题),我们可以在容器镜像中重新设置该层,而无需重建任何其他应用程序层。这将很难使用基于 Dockerfile 的工作流来实现。
单层 rebase 不仅是容器镜像构建时间的显着改进,而且对于容器的部署也是如此。想象一下有 10 个服务使用某个基础镜像。如果安全问题导致我们更新此基础镜像,则基于 Dockerfile 的构建将导致重建所有镜像的所有层。这会消耗构建时间并下载完整的 10 个新镜像。使用 buildpack rebase 方法,我们只需要检索一个新的共享基础镜像。
容器已经 42 岁了?
如果使用 buildpacks 构建容器,您将遇到意想不到的时间戳:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
sample-app latest 4713a8f7d9bb 42 years ago 202MB
这是可重现构建的副作用。为了实现可重现的构建,时间被冻结在一个固定点,以避免任何与时间相关的数据导致容器镜像差异。Buildpacks 构建具有1980 年 00:00:01 的时间戳来源的镜像,这是为了与旧文件格式的兼容而选择的。
使用 Buildpack 时的角色
当使用 buildpacks 构建容器时,有两个角色:
- 应用程序开发人员:开发人员的重点应该是以应用程序为中心,他们与容器构建相关的主要职责是提供应用程序源、依赖版本(例如 NodeJS 应用程序的package.json)和应用程序名称和版本等元数据。对于构建容器,他们将完全控制权委托给组织策划的构建器/Buildpacks。要将大多数问题委托给平台运营商,开发人员应该使用最新构建器/Buildpacks,而不是修复构建器版本。
- 构建平台运营商:构建平台运营商的职责是为开发人员提供一个精选的构建器和一组 Buildpacks,以申请构建容器。这主要包括利用像Paketo buildpacks这样的社区构建器,但运营商可以选择添加额外的,可能是自行设计的 buildpacks 用于公司特定目的。这可以是例如添加与公司相关的元数据。构建平台运营商的主要关注点还在于:容器运行镜像(容器基础镜像)的更新和安全性。
这种关注点分离对于维护多个容器镜像的组织非常有价值,因为这允许平台构建操作员控制容器镜像。例如,如果在运行镜像中发现安全问题,则可以通过更改构建器运行镜像,使用新的运行镜像重建所有容器。如上所述,这将是一个非常有效的层变基。使用 Dockerfile,这将需要更新所有应用程序 Dockerfile 中的基础镜像,并会触发所有容器镜像层的重建。
容器里有什么软件材料清单
buildpacks 增强 Dockerfile 构建过程的一个示例是如何将构建相关的元数据附加到容器镜像。软件项目包含许多依赖项,并且有关这些依赖项的信息在镜像构建期间嵌入到容器镜像中。
软件物料清单 (SBOM) 是进入容器镜像的组件的结构化列表。这可用于通过将 SBOM 与已知安全问题进行比较,来确保仅使用安全软件。SBOM 还可用于验证哪个软件许可证管理软件等。CycloneDX和SPDX是结构化 SBOM 数据的两个通用标准。
下面是来自 NodeJS 项目的 SBOM 的摘录,我们可以从 SBOM 中看到所使用的 Node 引擎的确切版本,以及提供它的 buildpack:
{
{
"name": "Node Engine",
"metadata": {
"source": {
"checksum": {
"hash": "34b23965457fb0587cda6fa898e5d030211f5f374cb6"
},
"uri": "https://nodejs.org/.../node-v16.13.1.tar.gz"
},
"version": "16.13.1"
},
"buildpacks": {
"id": "paketo-buildpacks/node-engine",
"version": "0.11.2"
}
}
}
使用 SBOM 来保护软件是对使用容器镜像扫描仪的有力补充。由于 SBOM 是由 buildpacks 创建的,因此它将精确且清楚地识别交付链。容器镜像扫描仪将不得不从容器的实际内容中扣除这个版本,这将不太精确。
如何使用 Buildpacks?
使用云原生 Buildpacks,构建过程和 Buildpacks 包含在两个容器镜像中:一个构建器镜像和一个运行镜像。在某种程度上,它可以被看作是一个改进的 Dockerfile 两阶段构建。下图说明了两阶段 Dockerfile 构建如何映射到 Buildpacks。请注意 NodeJS 应用程序的构建逻辑如何映射到buildpack(通常这种应用程序构建将分布在多个 buildpack 中)
构建器镜像包含一组有序的 Buildpacks 构建事物的逻辑、一个生命周期组件 Buildpacks 的编排器和对运行镜像的引用。Dockerfile 与生命周期组件没有并行性,因为 Dockerfile 是线性处理的。在检测阶段,Buildpacks 将选择加入或退出构建,生命周期组件对此进行管理。
最后,需要一个工具来触发生命周期组件:非常类似于 docker build 命令。为此,存在许多工具,其中最广为人知的是 pack 和 Tekton,然而,像 CircleCI 和 Gitlab 这样的商业持续集成供应商也支持使用 buildpacks 进行构建。
可以在此处找到使用pack 的 Github actions 工作流示例。
结论
虽然经验丰富的 Dockerfile 作者在从 Dockerfiles 迁移到 buildpacks 时可能会感到失去了对细节的控制,但上面概述的优势有望意味着使用 buildpacks 时会以开放的心态来对待。我相信大多数开发人员都会喜欢 buildpacks。构建容器和维护 Dockerfile 从来不是他们的主要关注点,而是部署应用程序所需的必要步骤。构建平台操作员、SRE 和安全团队应该喜欢 buildpacks,因为它恢复了对容器镜像和容器镜像中内置工件的控制。
用云原生 buildpack 替换 Dockerfile 会改变我们构建容器镜像的方式:
对于开发人员和组织而言,重点关注声明性部分很重要。学习如何声明容器应该如何构建以及正在构建什么将是最重要的。
buildpacks 完美吗?
不完全是。在写这篇文章时,我尝试复制一开始提出的两阶段 Dockerfile 的精益容器构建,但是,这并不完全可行(使用 Paketo 构建器)生成的容器的大小要大一些。但是,我预计 buildpacks 会在未来改进。
还有一些应用程序很难用 buildpacks 打包到容器中。例如,如果没有自定义 Buildpacks,打包在容器(VM 风格的容器)中的遗留单体应用程序将难以实现。此类应用程序不能很好地与 buildpacks 配合使用,并且可能需要对 Dockerfile 进行低级控制,这些应用程序可能是容器打包应该注意的问题。