Kubernetes 是一个通用的编排引擎,不仅适用于运行应用程序,也适用于构建容器镜像。镜像生成器(Image Builder)模式解释了为什么在集群内构建容器镜像是有意义的,以及目前有哪些技术可以在 Kubernetes 内创建镜像。

问题描述

到目前为止,本书中所有的模式都是关于在 Kubernetes 上操作应用程序的。我们学习了如何开发和准备我们的应用程序成为优秀的云原生公民。但是构建应用程序本身呢?经典的方法是在集群外部构建容器镜像,将它们推送到注册表(Registry),并在 Kubernetes 部署描述符中引用它们。然而,在集群内构建有几个优势。

如果公司政策允许,只用一个集群来处理所有事情是有利的。在一个地方构建和运行应用程序可以大大降低维护成本。它还可以简化容量规划,减少平台资源开销。通常,持续集成(CI)系统(如 Jenkins)被用来构建镜像。使用 CI 系统进行构建是一个调度问题,用于有效地寻找构建作业的空闲计算资源。Kubernetes 的核心是一个高度复杂的调度器,非常适合这种调度挑战。

一旦我们转移到持续交付(CD),我们从构建镜像过渡到运行容器,如果构建发生在同一个集群内,两个阶段共享相同的基础设施,并简化过渡。例如,让我们假设在用于所有应用的基础镜像中发现了一个新的安全漏洞。一旦你的团队修复了这个问题,你就必须重建所有依赖于这个基础镜像的应用镜像,并将你的运行中的应用更新为新的镜像。在实现这种镜像生成器模式时,集群会同时知道 — 镜像的构建和它的部署 — 并且可以在基础镜像发生变化时自动进行重新部署。

无守护进程构建

在 Kubernetes 内进行构建时,集群对构建过程有完全的控制权,但由于构建不再以异构方式运行,因此需要更高的安全标准。要在集群中进行构建,必须在没有 root 权限的情况下运行构建。幸运的是,如今有很多方法可以实现所谓的无守护进程构建,在没有提升权限的情况下工作。

Docker 在将容器技术带给大众方面取得了巨大的成功,这得益于它无与伦比的用户体验。Docker 基于客户端-服务器架构,一个 Docker 守护进程在后台运行,通过 REST API 从客户端接受指令。这个守护进程需要 root 权限,主要是出于网络和卷管理的原因。不幸的是,这带来了安全风险,因为运行不受信任的进程可以逃离他们的容器,入侵者可以控制整个主机。这种担忧不仅适用于运行容器时,也适用于构建容器时,因为当 Docker 守护进程执行任意命令时,构建也发生在容器内。

许多项目已经被创建,以允许 Docker 构建,而不需要 root 执行权限,以减少该攻击面。其中一些项目不允许在构建过程中运行命令(如 Jib),其他工具则使用不同的技术。在写这篇文章的时候,最主要的无守护程序镜像构建工具是 imgbuildahKaniko。另外,在第 225 页 “Source-to-Image” 中解释的 S2I 系统在没有 root 权限的情况下执行镜像构建。

看过了在平台上构建镜像的好处,我们来看看在 Kubernetes 集群中创建镜像有哪些技术。

解决方案

在 Kubernetes 集群中构建镜像的最古老、最成熟的方式之一是 OpenShift 构建子系统。它允许几种构建镜像的方式。其中一种超移植的技术是Source-to-Image(S2I),这是一种用所谓的构建器镜像进行构建的意见方式。另一种进行集群内构建的机制是通过 Knative Build。它工作在 Kubernetes 以及服务网格 Istio 之上,是 Knative 的主要部分之一,Knative 是一个用于构建、部署和管理无服务器工作负载的平台。在写这篇文章的时候,Knative 还是一个非常年轻的项目,而且进展很快。

我们先来看看 OpenShift 的构建。

OpenShift Build

:::info Red Hat OpenShift 是 Kubernetes 的一个企业级发行版,它除了支持 Kubernetes 的所有功能外,还增加了一些与企业相关的功能,如集成的容器镜像注册、单点登录支持和新的用户界面,还增加了原生镜像构建功能。除了支持 Kubernetes 支持的所有功能外,它还增加了一些企业相关的功能,比如集成的容器镜像注册表、单点登录支持和新的用户界面,还为 Kubernetes 增加了原生镜像构建功能。OKD(原名OpenShift Origin)是上游开源社区版发行版,包含了 OpenShift 的所有特性。 :::

OpenShift Build 是第一个直接由 Kubernetes 人工构建镜像的集群集成方式。它支持多种策略来构建镜像:

  • 源到图像(Source-to-Image,S2I):获取应用程序的源代码,并借助特定语言的 S2I 构建器镜像创建可运行的工件,然后将镜像推送到集成的注册表。
  • Docker 构建:使用一个 Dockerfile 加上一个上下文目录,然后像 Docker 守护进程那样创建一个镜像。
  • 管道构建:通过允许用户配置 Jenkins 管道,将构建工作映射到内部管理的 Jenkins 服务器上。
  • 自定义构建:让你完全控制如何创建你的镜像。在自定义构建中,你必须在构建容器中自己创建镜像并将其推送到注册表。

做构建的投入可以来自不同的渠道:

  • Git:通过远程 URL 指定的 Git 仓库,从那里获取源文件。
  • Dockerfile:直接作为构建配置资源的一部分存储的 Dockerfile。
  • 镜像:另一个容器映像,从中提取当前构建的文件。这种源码类型允许进行链式构建,如例 25-2 所示。
  • Secret:为构建提供保密信息的资源。
  • 二进制源:以提供所有来自外部的输入。在开始构建时必须提供这些输入。

我们可以选择以何种方式使用哪些输入源,取决于构建策略。二进制和 Git 是相互排斥的源类型。所有其他的源都可以合并或单独使用。我们将在后面的例 25-1 中看到如何工作。

所有的构建信息都定义在一个名为 BuildConfig 的中心资源对象中。我们可以通过直接将其应用于集群或使用 CLI 工具 oc 来创建这个资源,oc 是相当于 kubectl 的 OpenShift。

在我们看 BuildConfig 之前,我们需要了解两个对 OpenShift 来说很重要的额外概念。

:::info ImageStream 是一个 OpenShift 资源,它引用一个或多个容器镜像。它有点类似于 Docker 仓库,它也包含多个具有不同标签的镜像。OpenShift 将一个实际的标签镜像映射到 ImageStreamTag 资源,这样一个 ImageStream(仓库)就有一个引用 ImageStreamTag(标签镜像)的列表。为什么需要这个额外的抽象?因为它允许 OpenShift 在 ImageStreamTag 的注册表中更新镜像时发出事件。在构建过程中或当镜像被推送到 OpenShift 内部注册表时,会创建镜像。这样,构建或部署就可以监听这些事件,并触发新的构建或启动部署。 :::

:::warning 要将 ImageStream 连接到 Deployment,OpenShift 使用 DeploymentConfig 资源而不是 Kubernetes Depolyment 资源,后者只能直接使用容器镜像引用。然而,如果你不打算使用 ImageStream,你仍然可以在 OpenShift 中使用原生 Deployment 资源。 :::

另一个概念是触发器,我们可以把它看作是事件的一种监听器。一个可能的触发器是 imageChange,它对因为 ImageStreamTag 变化而发布的事件做出反应。作为一种反应,这样的触发器可以,例如,导致重建另一个图像或使用该图像重新部署 Pod。你可以在 OpenShift 文档中阅读更多关于触发器的信息,以及除了 imageChange 触发器之外,还有哪些种类的触发器。

Source-to-Image(S2I)

让我们来快速了解一下 S2I 构建镜像是什么样子的。我们在这里不做过多的赘述,但 S2I 生成器镜像是一个标准的容器镜像,它包含了一组 S2I 脚本,有两个我们必须提供的命令。

  • assemble:构建开始时被调用的脚本,它的任务是获取配置输入的源码,必要时编译它,并将最终的工件复制到适当的位置。它的任务是获取配置输入的源代码,必要时进行编译,并将最终的工件复制到适当的位置。
  • run:用来作为这个镜像的入口点。OpenShift 在部署镜像时调用此脚本。该运行脚本使用生成的工件来交付应用服务。

你也可以选择脚本提供一个使用信息,将生成的产物保存在所谓的增量构建中,在后续的构建运行中可以被 assemble 脚本访问,或者添加一些健康检查。

:::info 让我们仔细看看图 25-1 中的 S2I 构建。一个 S2I 构建有两个要素:构建器镜像和源输入。当一个构建开始时,S2I 构建系统会将这两样东西汇集在一起 — 要么是因为收到了一个触发事件,要么是因为我们手动启动了它。当构建镜像通过编译源代码等方式完成后,容器会被提交到一个镜像中,并被推送到配置的 ImageStreamTag 中。这个镜像包含编译和准备好的工件,镜像的运行脚本被设置为入口点。 ::: image.png
图 25-1 以 Git 源作为输入的 S2I 构建

例 25-1 显示了一个简单的 Java S2I 构建和 Java S2I 镜像。这个构建需要一个源,即构建者镜像,并产生一个输出镜像,推送到一个 ImageStreamTag。它可以通过 oc start-build 手动启动,也可以在构建者镜像变化时自动启动。

  1. # 例 25-1 S2I 使用 Java 构建器镜像构建
  2. ---
  3. apiVersion: v1
  4. kind: BuildConfig
  5. metadata:
  6. name: random-generator-build
  7. spec:
  8. # 要获取的源代码的引用;在本例中,从 GitHub 上获取
  9. source:
  10. git:
  11. uri: https://github.com/k8spatterns/random-generator
  12. # sourceStrategy 切换到 S2I 模式,构建器镜像直接从 Docker Hub 中拾取
  13. strategy:
  14. sourceStrategy:
  15. from:
  16. kind: DockerImage
  17. name: fabric8/s2i-java
  18. # 用生成的镜像更新的 ImageStreamTag,这是 assemble 脚本运行后提交的构建器容器
  19. output:
  20. to:
  21. kind: ImageStreamTag
  22. name: random-generator-build:latest
  23. # 当构建器镜像更新时,自动重建
  24. triggers:
  25. - type: ImageChange

S2I 是一种强大的创建应用镜像的机制,它比普通的 Docker 构建更加安全,因为构建过程完全在受信任的构建者镜像的控制之下。然而,这种方法仍然有一些缺点。

:::warning 对于复杂的应用,S2I 可能会很慢,特别是当构建需要加载许多依赖关系时。在没有任何优化的情况下,S2I 每次构建都会重新加载所有的依赖项。在用 Maven 构建 Java 应用的情况下,没有像做本地构建时那样的缓存。为了避免一次又一次地从网上下载,建议在集群内部建立一个 Maven 仓库作为缓存。然后,构建者镜像必须被配置为访问这个公共仓库,而不是从远程仓库下载工件。 :::

另一种减少构建时间的方法是使用 S2I 的增量构建,它允许重用之前 S2I 构建中创建或下载的工件。然而,大量的数据会从之前生成的镜像复制到当前的构建容器中,性能上的优势通常不会比使用集群本地代理持有依赖关系好多少。

:::info S2I 的另一个缺点是,生成的镜像还包含整个构建环境。这一事实不仅增加了应用程序映像的大小,还增加了潜在攻击的表面,因为构建工具也会变得脆弱。为了摆脱像 Maven 这样的不需要的构建工具,OpenShift 提供了链式构建,它采用 S2I 构建的结果,并创建一个纤细的运行时镜像。 :::

Docker 构建

OpenShift 还支持直接在这个集群中进行 Docker 构建。Docker 构建的工作方式是直接将 Docker 守护进程的 Socket 挂载在构建容器中,然后将其用于 Docker 构建。Docker 构建的源是一个 Dockerfile 和一个存放上下文的目录。你也可以使用镜像源,它指的是一个任意镜像,从它那里可以将文件复制到 Docker 构建上下文目录中。正如在下一节中提到的,这种技术和触发器一起,可以用于链式构建。

另外,你也可以使用一个标准的多阶段 Dockerfile 来分离构建和运行时的部分。我们的示例仓库中包含了一个完全有效的多阶段 Docker 构建示例,其结果与下一节中描述的链式构建相同。

链式构建

链式构建的机制如图 25-2 所示。一个链式构建包括一个初始 S2I 构建,它像二进制可执行文件一样创建运行时工件。然后,这个工件被第二个构建从生成的映像中拾取,通常是 Docker 那种构建。
image.png
图 25-2 使用 S2I 编译和 Docker 构建应用程序镜像的链式构建

例 25-2 显示了第二种构建配置的设置,它使用了例 25-1 中生成的 JAR 文件,最终被推送到 ImageStream random-generator-runtime 的镜像可以在 DeploymentConfig 中用于运行应用。

:::info 请注意,例 25-2 中使用的触发器,它监控 S2I 构建的结果。每当我们运行 S2I 构建时,这个触发器都会导致该运行时镜像的重建,这样两个 ImageStream 总是同步的。 :::

# 例 25-2 用于创建应用程序镜像的 Docker 构建

---
apiVersion: v1 
kind: BuildConfig 
metadata:
    name: runtime 
spec:
    source: 
      images:
    # 镜像源引用包含 S2I 构建运行结果的 ImageStream,并选择镜像中包含编译后的 JAR 存档的目录
        - from:
                kind: ImageStreamTag
                name: random-generator-build:latest
            paths:
            - sourcePath: /deployments/.
                destinationDir: "."
    # 用于 Docker 构建的 Docker 文件源,它从 S2I 构建生成的 ImageStream 中复制 JAR 存档
        dockerfile: |-
            FROM openjdk:8-alpine 
      COPY *.jar /
            CMD java -jar /*.jar
  # 该策略选择 Docker 构建
    strategy: 
      type: Docker
  # 当 S2I 结果 ImageStream 发生变化时,自动重建 — 在成功后 S2I 运行编译 JAR 存档
    output: 
      to:
            kind: ImageStreamTag
            name: random-generator:latest 
  # 为镜像更新注册监听器,并在新镜像被添加到 ImageStream 时进行重新部署
  triggers:
    - imageChange: 
          automatic: true
            from:
                kind: ImageStreamTag
                name: random-generator-build:latest
        type: ImageChange

你可以在我们的示例仓库中找到完整的示例与安装说明。

如前所述,OpenShift 构建,以及其最突出的 S2I 模式,是在具有 OpenShift 风味的 Kubernetes 集群内安全构建容器镜像的最古老和最成熟的方法之一。让我们看看另一种在原生 Kubernetes 集群内构建容器镜像的方法。

Knative Build

谷歌在 2018 年启动了 Knative 项目,旨在为 Kubernetes 带来先进的应用相关功能。

:::info Knative 的基础是像 Istio 这样的服务网格,它开箱即提供流量管理、可观察性和安全性的基础设施服务。服务网格使用 Sidecar 来装备应用程序与基础设施相关的功能。在服务网格之上,Knative 提供了额外的服务,主要针对应用开发者。 :::

  • Knative Serving:对于应用服务的收缩化到零的支持,可以被 Function-as-a-Service 平台等所利用。加上第 24 章 “弹性伸缩” 中描述的模式和底层服务网状结构的支持,Knative Serving 可以实现从零到任意多副本的扩展。
  • Knative Eventing:通过通道将事件从源传送到汇的机制。事件可以触发作为汇的服务,从零开始扩展。
  • Knative Build:用于在 Kubernetes 集群内将应用程序的源代码编译成容器镜像。后续项目是 Tekton Pipelines,它将最终取代 Knative Build。

Istio 和 Knative 都是用 Operator 模式实现的,并使用 CRD 来声明它们的管理域资源。在本节剩下的部分,我们将重点介绍 Knative 构建,因为这是 Knative 对镜像生成器模式的实现。

:::info Knative Build 主要针对工具开发者,为终端用户提供一个用户界面和无缝的构建体验。在这里,我们将对 Knative Build 的构建模块进行一个高级别的概述。这个项目正在快速发展,甚至可能被 Tekton Pipelines 这样的后续项目所取代,但其原理机制很可能会保持不变。尽管如此,一些细节可能会在未来发生变化,所以请参考示例代码,我们会根据最新的 Knative 版本和项目进行更新。 :::

Knative 的设计是为了提供与您现有的 CI/CD 解决方案相集成的构件,它不是一个单独构建容器镜像的 CI/CD 解决方案。它本身并不是一个构建容器镜像的 CI/CD 解决方案。我们希望随着时间的推移,会有越来越多的这样的解决方案出现,但现在让我们来看看它的构建模块。

简单构建

Build CRD 是 Knative Build 的核心元素。Build 定义了 Knative Build 操作者在构建过程中需要执行的具体步骤。例子 25-3 演示了主要成分。

  • 一个指向应用程序源码位置的源码规范。源码可以是 Git 仓库,也可以是其他远程仓库,比如 Google 云存储,甚至可以是一个任意的容器,构建操作员可以从那里提取源码。
  • 将源代码转化为可运行的容器镜像所需的步骤。每一个步骤都指的是用于执行该步骤的构建者镜像。每个步骤都可以访问一个安装在 /workspace 上的卷,该卷包含源代码,但也用于在步骤之间共享数据。

在这个例子中,源码又是我们托管在 GitHub 上的 Java 示例项目,用 Maven 构建。构建者镜像是一个包含 Java 和 Maven 的容器镜像。Jib 用于在没有 Docker 守护进程的情况下构建镜像,并将其推送到注册表。

# 例 25-3 在 Knative 上使用 Maven 和 Jib 构建 Java 应用

---
apiVersion: build.knative.dev/v1alpha1 
kind: Build
metadata:
    # 构建对象的名称
    name: random-generator-build-jib 
spec:
    # 带有 GitHub URL 的源代码规范
    source: 
      git:
            url: https://github.com/k8spatterns/random-generator.git
            revision: master 
  # 一个或过多构建步骤
  steps:
    - name: build-and-push
      # 包含 Java 和 Maven 的镜像,用于此构建步骤
        image: gcr.io/cloud-builders/mvn
    # 给予镜像生成器容器的参数,以触发 Maven 通过 jib-maven-plugin 编译、创建和推送容器镜像
    args:
    - compile
    - com.google.cloud.tools:jib-maven-plugin:build
    - -Djib.to.image=registry/k8spatterns/random-generator 
    # 目录/workspace 是共享的,每一步构建都要挂载
    workingDir: /workspace

简单了解一下 Knative 的构造,也是很有意思的。Operator 进行构建。

image.png
图 25-3 使用 Init 容器进行 Knative Build

图 25-3 显示了自定义 Build 资源如何转化为普通 Kubernetes 资源。一个 Build 变成了一个 Pod,其构建步骤被转化为一个接一个调用的 Init 容器链。第一个 Init 容器是隐式创建的。在我们的例子中,有一个用于与外部仓库交互的初始化凭证,第二个初始容器用于检查 GitHub 的源代码。其余的 Init 容器只是给定的步骤和声明的构建者镜像。当所有 Init 容器都完成后,主容器只是一个什么都不做的空操作,这样 Pod 在初始化步骤后就停止了。

构建模板

例 25-3 只包含一个构建步骤,但您的典型构建包括多个步骤。BuildTemplate 自定义资源可以用于在类似的构建中重复使用相同的步骤。

例 25-4 演示了这样一个模板的构建,有三个步骤:

  1. mvn 包创建一个Java JAR 文件。
  2. 创建一个 Dockerfile,将这个 JAR 文件复制到一个容器镜像中,并用 java -jar 启动它。
  3. 使用 Kaniko 创建并推送一个容器镜像与构建器镜像。Kaniko 是 Google 创建的一个工具,用于在用户空间运行本地 Docker 守护进程的情况下,从容器内的 Dockerfile 构建容器镜像。

模板与 Build 差不多,不同的是它支持占位符等参数,在使用模板的时候要填写这些参数。在本例中,IMAGE 是指定要创建的目标镜像所需的单一参数。

# 例 25-4 使用 Maven 和 Kaniko 的 Knative 构建模板

---
apiVersion: build.knative.dev/v1alpha1 
kind: BuildTemplate
metadata:
    name: maven-kaniko 
spec:
    parameters:
  # 使用模板时要提供的参数列表
    - name: IMAGE
        description: The name of the image to create and push 
  steps:
  # 使用 Maven 编译和打包一个 Java 应用程序的步骤
    - name: maven-build
        image: gcr.io/cloud-builders/mvn 
    args:
        - package
        workingDir: /workspace
  # 创建 Dockerfile 的步骤,用于复制和启动生成的 JAR 文件
    - name: prepare-docker-context 
      image: alpine
        command: [ .... ]
  # 调用 Kaniko 来构建和推送 Docker 镜像的步骤
    - name: image-build-and-push
        image: gcr.io/kaniko-project/executor 
    args:
        - --context=/workspace
    # 目的地使用提供的模板参数 ${IMAGE}
        - --destination=${IMAGE}

然后,这个模板可以被 Build 使用,Build 指定模板的名称,而不是如例 25-5 中看到的步骤列表。正如你所看到的,你只需要指定要创建的应用程序容器映像的名称,你可以很容易地重复使用这个多步骤构建来创建不同的应用程序。

# 例 25-5 使用构建模板进行 Knative 构建

---
apiVersion: build.knative.dev/v1alpha1 
kind: Build
metadata:
    name: random-generator-build-chained 
spec:
    # 从哪里获取源代码
    source: 
      git:
            url: https://github.com/k8spatterns/random-generator.git
            revision: master 
  # 引用我们在例 25-4 中定义的模板
  template:
        name: maven-kaniko 
       arguments:
    # 作为参数填充到模板中
        - name: IMAGE
            value: registry:80/k8spatterns/random-generator

你可以在 Knative build-templates 资源库中找到许多预定义的模板。

这个例子结束了我们对 Knative Build 的快速浏览。如前所述,这个项目还很年轻,细节可能会有变化,但这里描述的主要机制应该保持不变。

一些讨论

你已经看到了在集群内构建容器镜像的两种方式。OpenShift 构建系统很好地展示了在同一集群中构建和运行应用程序的主要好处之一。通过 OpenShift 的 ImageStream 触发器,你不仅可以连接多个构建,还可以在构建更新你的应用程序的容器镜像时重新部署你的应用程序。这对于较低的环境尤其有用,因为在这些环境中,构建步骤之后通常会有一个部署步骤。构建和部署之间更好的集成是向 CD 的圣杯迈进了一步。OpenShift 构建与 S2I 是一种成熟的、成熟的技术,但 S2I 目前只有在使用 Kubernetes 的 OpenShift 发行版时才能使用。

Knative Build 是这种镜像生成器模式的另一种实现。Knative Build 的主要目的是将源代码转化为可运行的容器镜像,并将其推送到注册表,以便 Deployment 可以接收到它。这些步骤是由构建器镜像形成的,它可以为不同的技术提供。Knative Build 对构建的具体步骤是不可知的,但关心的是如何管理构建的生命周期以及如何安排构建。

Knative Build 还是一个年轻的项目(截至2019年),它为集群构建提供了构建模块。它的目的并不是为了最终用户,而是更多的是为了工具构建者。你可以期待新的和现有的工具很快就会支持 Knative Build 或其后续项目,所以我们会看到更多的镜像生成器模式的实现出现。

参考资料