打造 ML/AI 系统的内部开发者平台(IDP)——在 Kubernetes 上构建应用程序

0 阅读21分钟

本章将涵盖:

  • 搭建 ML 平台的基础设施骨干
  • 使用 Docker 对应用进行容器化封装
  • 使用 Kubernetes 编排部署
  • 自动化构建与部署
  • 为生产应用实现监控

作为一名 ML 工程师,你的主要职责之一,是构建并维护支撑 ML 系统运行的基础设施。无论你是在部署模型、搭建流水线,还是管理一整套 ML 平台,你都需要对现代基础设施工具与实践打下坚实基础(见图 3.1)。

image.png

图 3.1 心智地图现在把焦点转向 ML 平台的基础——主要是 Kubernetes——以及持续集成/持续部署与监控等关键实践,它们对部署与维护 ML 系统至关重要。

我们将攻克构建可靠 ML 系统所需的关键 DevOps 工具与实践。我们会从基础开始,通过动手示例循序渐进地搭建你的知识体系。到最后,你将理解如何做到以下几点:

  • 使用 Docker 以一致的方式打包应用
  • 在 Kubernetes 上部署并管理应用
  • 通过持续集成/持续部署(CI/CD)自动化工作流
  • 监控应用健康状况与性能

虽然这些工具并非 ML 专属,但它们构成了我们在规模化场景下构建稳健 ML 系统的基础。我们先从 Docker 开始——它能帮助我们以一致的方式封装应用。

如果你此前完全没接触过这些技术,那正好!我们会从最基础讲起,并逐步加深。到本章结束时,你将能够理解并使用 Docker、Kubernetes 以及其他 DevOps 工具来部署并管理应用。

3.1 容器与工具链(Containers and tooling)

无论是传统 ML 模型还是大语言模型(LLM),其部署与对外服务都是 ML 平台最重要的方面之一。它提供了把训练好的模型部署到真实环境的方法,使模型能够基于不断到来的新数据进行实时预测。为了实现这一点,通常会使用容器化技术,把模型封装在独立环境中,以获得一致、可扩展的部署方式。

承载我们应用的这些容器,接着需要被部署到各种环境中——可能是本地集群,也可能是云上。我们必须确保为 ML 容器分配足够资源,使其保持最佳性能;并且在负载增加时能够扩展。如果某些容器因底层基础设施原因失败,就需要由其他运行中的容器替换。上述所有过程都应该被自动化并纳入监控,应用遇到的任何问题都应在升级前被主动识别并缓解。

要完成这些工作,我们需要熟悉一些业界常见工具。容器化由 Docker 平台实现:它允许我们把应用及其依赖(包括库与运行时)打包到一个容器中。随后,我们把这个容器部署到 Kubernetes 上,由它负责资源管理、扩缩容,以及在需要时拉起新的容器。部署过程还需要用持续集成(CI)与持续部署(CD)工具实现自动化——本书中我们使用 GitLab CI 与 Argo CD。应用部署完成后,还需要由监控系统进行监控:它既要跟踪应用特定的指标,也要提供可视化这些指标的用户界面(UI)。监控系统我们使用 Prometheus,并用 Grafana 作为仪表盘来展示指标。

这些工具都有多种替代方案,其中一些可能更适合你的组织。工具本身只是达成目标的手段——只要最终实现“自动化的 ML 平台”这个目标,你完全可以使用任意工具组合。比如,我们选择 Kubernetes,是因为它提供了可扩展、可移植且灵活的编排层,让我们更容易在不同环境中部署、管理并监控 ML 工作负载。Kubernetes 被设计为云厂商无关(cloud agnostic),使工作负载能够在本地数据中心与不同云提供商之间以一致方式运行(见图 3.2)。在本章中,我们会进一步探索支持自动化与监控的 DevOps 工具;在第 4、5 章,我们会扩展到特征库、ML 流水线、实验跟踪与漂移监控等 MLOps 工具。

image.png

图 3.2 在 Kubernetes 中部署应用,并配合 CI/CD 与监控体系

本章我们会围绕一个应用贯穿始终,它的代码在本书的代码仓库中提供。这个 FastAPI 应用只暴露一个端点,用于返回一个随机笑话。我们会先把应用容器化,然后把它部署到一个容器编排平台上;接着聚焦自动化与监控。

让我们从容器化开始。本章所有代码都在 GitHub 上:https://github.com/practical-mlops/chapter-3

3.2 Docker

假设我们在本地工作站上构建一个目标检测应用,而该应用依赖若干 Python 库(例如 TensorFlow、NumPy 和 OpenCV),还依赖诸如 CMake 之类的其他库。当我们把这个应用部署到云上时,需要确保这些依赖在云环境中也被正确安装——如果生产环境的 Linux 发行版与本地发行版不同,这个问题会被进一步放大。Docker 通过保证“我们在本地构建的容器”同样能在生产环境中良好运行,从而帮助解决这一问题。

Docker 是一个通过容器化来交付应用的工具。容器化是把应用与其依赖(例如 Python 包)一起打包的过程,从而确保它几乎可以在任何计算环境中运行。你可以把容器理解成一个真实的物理容器:里面装着你的源代码与所有依赖;而 Docker 服务则提供了运行这个容器的方式。

我们用真实的海运集装箱来类比容器化。想象你是一个负责把各种货物运输到全球各国的物流经理。每件物品都必须安全送达、便于运输,并且能够快速地装卸于不同运输工具之间(卡车、轮船、火车)。

我们对比一下传统运输与 Docker 运输。先看没有 Docker 的传统运输是怎样的:

  • 每种产品都有独特的包装,尺寸各异,并且有各自的搬运说明。
  • 同时运输多种货物会变得复杂,需要为每一种货物分别做计划。
  • 因为不同产品形状与尺寸不同,把它们装卸到不同运输工具上会耗费更多时间与精力。
  • 当产品与现有运输方式不匹配时,就会出现兼容性问题。

再看 Docker 运输:

  • 不再为每种产品使用不同包装,而是把每种产品都放进标准化的集装箱。
  • 每个集装箱是一个自包含单元,装着产品以及所有必要的搬运说明。
  • 集装箱尺寸与形状一致,使其无需改造就能轻松装到卡车、轮船与火车上。
  • 你可以把不同产品装进不同集装箱来运输,确保它们都能无缝适配。
  • 装卸流程被标准化并更高效,因为所有集装箱都遵循同一套标准。

在这个类比中:产品对应不同应用或服务;包装与搬运说明对应每个应用所需的依赖、库与配置;而集装箱对应 Docker 容器——它把应用及其依赖封装在一起。

就像标准化集装箱简化物流、确保货物被良好封装、并让跨运输方式与跨地点运输变得顺畅一样,Docker 容器也以一致、隔离的方式打包应用及其依赖,使其能够在不同环境中更易部署与管理。

3.2.1 编写应用代码

我们来为一个 FastAPI 应用构建 Docker 容器。FastAPI 是一个用 Python 构建 API 的 Web 框架。在 ML 语境里,它特别适合用来构建提供模型服务的 API。现在我们先用它来部署一个返回随机笑话的应用。所有源代码都在一个简单的 main.py 文件中,依赖列表在 requirements.txt 里。应用目录结构如下,main.py 的代码见清单 3.1:

├── requirements.txt
├── main.py

清单 3.1 打印随机笑话的 FastAPI 应用

from fastapi import FastAPI
import pyjokes
app = FastAPI()
@app.get("/")
async def root():
    random_joke = get_joke("en","neutral")
    return {"random_joke": random_joke}

我们可以先在本地运行它:在终端执行下面命令安装依赖:

pip install -r requirements.txt

它使用 Uvicorn(一个快速的异步服务器网关接口 [ASGI] 服务器)在 8083 端口运行我们的 FastAPI 应用(main.py 里的 app):

uvicorn main:app --host 0.0.0.0 --port 8083

如果我们 curl 这个端点,会得到:

curl localhost:8083
{
  "random_joke": "If you play a Windows CD backwards, you'll hear satanic\n\
chanting ... worse still, if you play it forwards, it installs Windows."
}

现在应用按预期工作了,我们希望把它容器化。要构建 Docker 容器,首先需要安装 Docker Desktop(mng.bz/4ngB)。在大多数平台上,只要跟随 GUI 安装器即可完成安装。

3.2.2 编写 Dockerfile

Docker 化的下一步是编写 Dockerfile。你可以把 Dockerfile 理解为:构建并部署应用的一系列指令清单。对应前面的运输类比,“集装箱的装箱清单(manifest)”就是 Dockerfile。我们的 FastAPI 应用(以及大多数基础 Python 应用)的 Dockerfile 示例大致如清单 3.2 所示。

每个 Dockerfile 都以 FROM 开头,它引用一个基础镜像。你可以把它看作我们叠加应用专属层(layers)的地基;每一条 Dockerfile 指令都是一个增量层。在我们的例子里,我们用 WORKDIR 创建一个名为 app 的目录作为工作目录。然后用 RUN 更新索引文件并安装必要包。RUN 用于执行基本的命令行指令。接着用 COPY 复制并安装 Python 依赖:先复制 requirements.txt,再执行 pip 安装。之后,我们把应用文件复制进工作目录。接下来用 ARG 声明一个构建参数;这些参数在构建时传入,在这里我们用它通过 ENV 设置一个名为 ENVIRONMENT 的环境变量。最后,我们把 entrypoint.sh 脚本设为可执行,并定义容器的入口命令。ENTRYPOINT 是运行 Docker 镜像时默认执行的命令。

清单 3.2 Python 应用的 Dockerfile 示例

FROM python:3.10-slim-buster    #1
WORKDIR /app    #2
RUN apt-get update && \
    apt-get install --no-install-recommends -y \
                       build-essential \
                       && apt-get clean && rm -rf /tmp/* /var/tmp/*
COPY requirements.txt /app/requirements.txt    #3
RUN pip3 install --upgrade pip     #3
RUN pip3 install --no-cache-dir -r requirements.txt     #3
COPY . /app    #4
EXPOSE 8083
ENV PYTHONPATH="/app"    #5
ARG environment     #5
ENV ENVIRONMENT $environment     #5
RUN chmod +x /app/entrypoint.sh    #6
ENTRYPOINT ["/app/entrypoint.sh"]     #6
#1 Dockerfile 的基础镜像
#2 设置工作目录
#3 复制并安装应用依赖
#4 把本地内容复制到工作目录
#5 设置 PYTHONPATH 环境变量
#6 设置容器启动时执行的默认命令

entrypoint.sh 文件如下面清单所示,它包含将作为 Docker 容器运行命令的脚本。

清单 3.3 entrypoint.sh 示例

#!/bin/sh
uvicorn main:app --host 0.0.0.0 --port 8083

加入 Dockerfile 与 entrypoint.sh 后,我们的应用目录结构如下:

├── Dockerfile
├── entrypoint.sh
├── main.py
└── requirements.txt

3.2.3 构建并推送 Docker 镜像

现在我们已经在 Dockerfile 中写好了“打包指令”,接下来需要把集装箱“装满”(也就是开始打包)。在 Docker 世界里,把容器装满的过程就是构建镜像(build an image)。你可以把镜像理解为一个已经准备好被运送到部署环境的包。我们通过下面命令构建 Docker 镜像:

docker build . -t hello-joker:v1

这个 Docker 镜像名是 hello-joker,tag 是 v1。镜像 tag 可以对应镜像版本或某个 Git commit ID。镜像构建完成后,我们可以运行下面命令查看本地镜像列表:

docker images

它会列出镜像并显示仓库名(repository,即镜像名)与 tag(代表该镜像的具体版本或变体)。Docker 还会生成一个唯一的镜像 ID,你也能看到创建时间与镜像大小。

tag 用于区分同一镜像的不同版本——例如 v1v2latest——从而让你更清晰地管理更新与部署:

REPOSITORY     TAG    IMAGE ID       CREATED          SIZE
hello-joker    v1     5004e994efa5   20 minutes ago   361MB

构建镜像会生成一个轻量、独立、可执行的软件包,里面包含运行某个应用所需的全部依赖、库与配置。运行镜像则意味着创建该镜像的一个实例。容器是 Docker 镜像的可运行实例,它在隔离环境中封装了应用及其依赖。要运行镜像,我们使用 docker run 命令来启动应用。-p 参数把容器的 8083 端口发布到宿主机的 8081 端口,也就是服务可在 localhost:8081 访问。-it 参数让 Docker 分配一个连接到容器 stdin 的伪 TTY,这对运行交互式进程很有用,比如容器内的 shell 会话或需要用户输入的应用。在这个例子中,如果要终止应用,直接按 Ctrl-C 即可:

docker run -it -p 8081:8083 hello-joker:v1
INFO:     Started server process [7]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8083 (Press CTRL+C to quit)

如果我们再次在 8081 端口 curl 端点,会得到:

curl 0.0.0.0:8081
{"random_joke":"Why did the QA cross the road? To ruin everyone's day."}

随着应用代码演进,我们也可以像用 Git 跟踪与存储代码那样,跟踪并保存这些镜像版本。这依赖于容器镜像仓库(container registry):它是一个集中式仓库,用于存储与管理容器镜像(见图 3.3)。像 Kubernetes 这样的编排器会从镜像仓库拉取镜像,并以容器形式运行你的应用。Docker Hub 就是一个镜像仓库例子;云厂商也提供镜像仓库,例如 Google Container Registry(GCR)或 Amazon Web Services(AWS)的 Elastic Container Registry(ECR)。

image.png

图 3.3 从本地桌面构建 Docker 镜像并推送到镜像仓库的过程:服务器从仓库拉取镜像,并以容器形式运行

现在我们已经构建了镜像并在本地测试通过,接下来就可以把它推送到镜像仓库。这里我们用 Docker Hub 存储镜像。首先需要在 Docker Hub 注册账号(hub.docker.com/signup)。然后点击 Create a Registry,命名为镜像名 hello-joker,并把 Visibility 设为 Public。接着在本地系统登录 Docker Hub:

docker login

输入凭证后,我们需要给镜像重新打 tag,以便推送到 Docker Hub(重新打 tag 本质上就是给镜像改名):

docker tag hello-joker:v1 <dockerhub-user-name>/hello-joker:v1

完成重新打 tag 后,就可以用 docker push 推送到 Docker Hub:

docker push <docker_username>/hello-joker:v1

Docker 命令行界面(CLI)提供了许多命令,可用来查看运行中的容器、查看日志等。更多 Docker 命令与 Dockerfile 参考可见:docs.docker.com/reference/

接下来,我们将从镜像仓库拉取镜像,并把它部署到容器编排平台上。我们的选择是 Kubernetes。

3.3 Kubernetes

Kubernetes 是一个容器编排(container orchestration)工具。那这到底意味着什么?我们刚刚学习了 Docker 容器,但如何把这些容器部署到开发环境之外的环境中呢?当然,我们可以用一个简单的 docker run 命令来运行容器。但如果某个容器因为内存需求问题挂掉了(例如臭名昭著的 Out of Memory,OOM 错误),谁来帮我们把它拉起来?谁来把容器调度到某台服务器上?答案就是 Kubernetes——后面我们会亲切地称它为 K8s:K +(去掉中间 8 个字母)+ s = K8s。

3.3.1 Kubernetes 架构概览

想象我们有一台服务器/节点(server/node),在上面安装了 K8s。我们在这个节点上部署容器。如果这台节点宕机了,我们的应用也就跟着宕了。为了解决这个问题,我们需要不止一个节点,这样系统才具备某种形式的弹性与容错能力。于是我们得到一个集群(cluster),它可以由两个或更多节点组成,并且这些节点上都安装了 K8s。拥有多个节点也有助于在高负载时扩展应用规模。但问题来了:谁来管理这个集群?谁来监控容器,并在节点故障时把容器调度到其他节点上?这时我们需要再设置一个称为 master 的节点,它的主要工作是监控 worker 节点及其工作负载。

一个 K8s 的部署由 master 节点与 worker 节点共同构成。每种节点上都会运行一些服务,用于实现容器编排。master 节点上的服务包括:

  • API server —— 这个组件接收来自用户与应用的请求。它作为 K8s 的前端,负责与 CLI、用户以及其他服务交互。它提供一个接口来接收 K8s 命令,例如调度、扩容,甚至删除某个部署。
  • etcd 键值存储(Etcd key-value store) —— 一个分布式数据存储,用于保存集群相关信息,包括集群中所有节点、它们的状态与配置。简而言之,etcd 为 K8s 提供系统状态的单一事实来源(single source of truth)。
  • Scheduler(调度器) —— 负责把容器调度到各节点上。调度器会根据应用所需资源(CPU、内存)与节点可用资源,在合适的节点上安排一个新的容器。它把容器与合适的节点匹配起来。
  • Controller(控制器) —— 负责编排的组件。当应用容器挂掉时,控制器会决定启动一个新的容器。

worker 节点上的服务包括:

  • Container runtime(容器运行时) —— 用来运行应用容器的软件。在我们的例子中是 Docker,因为我们用 Docker 服务来构建容器。
  • Kubelet —— 运行在节点上的软件/代理(agent)。它的主要职责是确保容器按预期在节点上运行。

容器运行时与 kubelet 安装在 worker 节点上。kubelet 服务会与 master 通信:向 master 汇报节点与容器的健康状态;并在 master 发出请求时,在 worker 节点上执行相应操作。API server、etcd 键值存储、scheduler 与 controller 安装在 master 节点上(见图 3.4)。

image.png

图 3.4 用户通过 master 节点上的 API server 交互并下发命令;master 节点再与 worker 节点通信以执行命令。所有这些由各节点上安装的组件共同实现。

3.3.2 Kubectl

命令行工具 kubectl(常读作 “kube control”)用于与 K8s 集群交互。它可以用来列出集群中的节点、查看节点状态、部署应用,以及执行许多其他操作。例如:

kubectl get nodes

会列出集群中的不同节点,并显示节点名称、状态(是否 ready)、角色或节点标签(额外元数据)、节点存在时长(age),以及 K8s 版本:

NAME                    STATUS   ROLES    AGE     VERSION
gke-data-54f8762d-r7bz  Ready    <none>   8d      v1.24.14-gke.1200
gke-data-5glf8762d-r7bz Ready    <none>   8d      v1.24.14-gke.1200
gke-data-7d9b880a-jljzk Ready    <none>   4d17h   v1.24.14-gke.1200

我们还可以通过下面命令获取集群信息:

kubectl cluster-info

或者用下面命令在 K8s 中运行应用:

kubectl run <application_name> --image <docker_image_name>

以上只是 kubectl 能做的众多事情中的几个例子。它是管理与交互 K8s 集群的关键工具——无论你是在部署、扩缩容、监控,还是调试应用。在接下来的小节中,我们会给出更多 kubectl 命令示例。请在你的本地系统安装 K8s 与 kubectl,以便亲自测试这些命令。安装说明在附录 A 中提供。

3.3.3 Kubernetes 对象(Kubernetes objects)

在与 K8s 交互时,我们交互的其实是一组离散组件——它们相互配合来提供功能。我们从最基本的执行单元开始:Pod

Pods

在 1.1 节中,我们已经成功为一个 FastAPI 应用构建了 Docker 镜像。现在,是时候把这个应用容器部署到 K8s 集群里了。不过,这个容器不会以“Docker 容器”的形式直接部署;相反,它会被封装为一个称为 pod 的 K8s 对象。Pod 是 K8s 世界里最小的单位——也就是我们的应用在集群上部署的一个实例。当应用负载增加时,我们会通过增加 pod 的数量来扩展。

一个 pod 可以承载多个容器,这些容器共享同一套网络与存储空间。多容器 pod 通常用于:其中一个容器是工具型容器(utility container),并与主容器同生共死。比如,工具容器为主应用做日志处理,或执行某些文件处理任务。这些容器就像一套公寓里的不同房间,各司其职;它们共享同一套资源,因此更容易相互通信并协同工作。

我们接下来为 hello-joker 应用创建一个 pod。K8s 对象的定义写在 YAML 文件里。(想了解更多 YAML 格式,请阅读附录 B。)

我们开始在 YAML 文件里编写 pod 定义。所有 K8s 对象都有四个主要的顶层键:

  • apiVersion —— 帮助 K8s 解析我们为某个资源提供的设置与配置。随着 K8s 演进并新增能力,它保证一致性与兼容性。每种 K8s 资源类型(例如 pod)都有自己的 API 版本。
  • kind —— 指定对象类型,例如 pod、deployment、ReplicaSet、service 等。在我们这里是 pod。
  • metadata —— 指定 pod 本身的信息。在这里我们设置 pod 名称,并在需要时为 pod 打标签(label)。label 广泛用于在定义其他 K8s 对象(如 service、deployment)时进行资源选择与组织。
  • spec —— 用于列出 pod 的内容。至少要包含镜像名与容器名。对我们的 FastAPI 应用来说,还需要指定容器监听的端口以接收流量。

我们 FastAPI 应用的完整 pod.yaml 大致如下所示。

清单 3.4 pod.yaml:创建一个 pod

apiVersion: v1    #1
kind: Pod    #2
metadata:
  name: joker    #3
spec:
  containers:
  - name: hello-joker    #4
    image: <docker_hub_repo>:hello-joker:v1    #5
    ports:
    - containerPort: 8083    #6
#1 Pod 的 API 版本是 v1
#2 kind 为 Pod
#3 在 metadata 下指定 pod 名称
#4 容器名位于 spec.containers 下
#5 指定容器的 Docker 镜像
#6 应用需要暴露的端口

我们可以通过 kubectl create 创建这个 pod。kubectl create 创建 K8s 对象的方式如下:

kubectl create -f pod.yaml

它会输出:

pod/joker created

如果我们想查看当前运行了多少 pod,可以执行:

kubectl get pod

应该会看到类似输出:

NAME     READY   STATUS    RESTARTS      AGE
joker    1/1     Running   0             3m34s

这里,NAME 是 pod 名称;READY 是 pod 内运行中的容器数 / pod 内总容器数;STATUS 是 pod 状态——此处为 RunningRESTARTS 表示 pod 重启次数;AGE 则是 pod 启动以来的时间。

现在,我们故意在 pod.yaml 中把镜像名改成一个不存在的 hello-joker:v1。我们可以用 kubectl apply 应用这个修改,它会创建并替换该 pod:

kubectl apply -f pod.yaml

输出如下:

pod/joker configured

当我们运行 kubectl get 时,会看到 pod 的 STATUS 变为 ErrImagePull。顾名思义,这是拉取镜像时发生错误——这符合预期,因为镜像不存在。同时注意 READY 变成了 0/1,表示 pod 中没有任何容器在运行:

NAME     READY   STATUS         RESTARTS      AGE
joker    0/1     ErrImagePull   0             44s

修复方法也很简单:把镜像名改回正确值,然后再用 kubectl apply 应用修改即可。要获取 pod 的更多信息,可以使用 kubectl describe

kubectl describe pod joker

它会给出 pod 的详细信息,例如名称、容器镜像名、pod 状态、pod 运行所在节点、pod IP,以及 pod 遇到的事件(events)。你可以看到事件列表,其中包含拉取镜像的过程,以及镜像拉取完成后启动容器的过程。这个命令在 pod 出现错误时非常适合用于调试。

要查看 pod 日志,我们用 kubectl logs

kubectl logs joker

它会输出容器日志,例如:

INFO:     Started server process [7]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:80 (Press CTRL+C to quit)

我们来测试这个 pod:访问端点,让它返回一个笑话。为了从本地系统访问,我们可以使用 kubectl port-forwardkubectl port-forward 特别适合用于调试、测试,以及访问那些没有通过 K8s service 对外暴露的服务。你需要指定 pod 名称与端口映射:宿主机端口(本地可访问的端口)与容器端口(容器内应用接收流量的端口):

kubectl port-forward <pod_name> <host_port>:<container_port>

对我们来说是:

kubectl port-forward joker 8080:80

注意宿主机端口可以是你本地任何一个空闲端口。之后在浏览器输入 http://localhost:8080,你会看到一个随机笑话:

{
  "random_joke": "How do you know whether a person is a Vim user? \
Don't worry, they'll tell you."
}

最后,如果我们想删除 pod,可以执行 kubectl delete

kubectl delete pod joker

输出如下:

pod "joker" deleted

在这一节里,我们创建并更新了 pod,查看了 pod 生命周期中的事件,读取了 pod 日志,测试了 pod,并最终删除了它。K8s 的 pod 是 K8s 生态里容器化应用的基础构件。接下来我们会学习如何扩展这些 pod,以及当节点失败或发生错误时,K8s 如何调度另一个 pod 来接替运行。

ReplicaSets

如果我们需要扩展 pod 数量,或者确保我们的应用至少有一个 pod 在运行,该怎么办?我们可能是为了高可用,也可能是因为单个 pod 扛不住请求量。我们用 ReplicaSet 来解决这个问题。

ReplicaSet 是一个 K8s 对象,用于监控 pod,并在需要时复制(replicate)它们。创建 ReplicaSet 时,我们同样先定义前面提到的四个共同属性:apiVersionkindmetadataspec。其中 apiVersionapps/v1kindReplicaSetmetadata 包含 ReplicaSet 的名称与标签;而 spec 有三个子属性:templatereplicasselectortemplate 是我们希望复制的 pod 模板;replicas 是期望的 pod 副本数;selector 告诉 ReplicaSet 需要复制并监控哪些 pod——这是通过匹配我们定义的 pod label 来实现的。

但既然已经提供了 pod 模板,为什么还要写 selector?原因是:ReplicaSet 能监控在它创建之前就已经存在的 pod。我们提供 pod 模板是为了当某个 pod 挂掉、或者 pod 数量与期望副本数不一致时,ReplicaSet 能基于模板创建新的 pod。

我们用 joker pod 作为模板创建一个 ReplicaSet(见清单 3.5)。副本数设为 3,selector 要求 ReplicaSet 复制 label 为 app: fast-api 的 pod。

清单 3.5 replicaset.yaml:创建一个 ReplicaSet

apiVersion: apps/v1    #1
kind: ReplicaSet    #2
metadata:
  name: joker-replicaset    #3
  labels:
      app: fast-api    #4
spec:
  template:    #5
    metadata:
      name: joker
      labels:
          app: fast-api    #6
    spec:
      containers:
      - name: hello-joker
        image: varunmallya/hello-joker:v1
        ports:
        - containerPort: 8083
  replicas: 3    #7
  selector:
    matchLabels:    #8
      app: fast-api
#1 ReplicaSet 的 API 版本是 apps/v1
#2 kind 为 ReplicaSet
#3 在 metadata 下指定 ReplicaSet 名称
#4 用 key-value 的 labels 提供额外元数据
#5 指定 pod 模板
#6 为了 ReplicaSet 监控,pod 需要有 labels
#7 期望副本数
#8 监控并扩缩容 label 匹配 app: fast-api 的 pod

我们用下面命令创建 ReplicaSet:

kubectl create -f replicaset.yaml

它会启动 3 个 hello-joker 应用 pod。注意 pod 名称会是 ReplicaSet 名称加上一串 hash 后缀。这个 hash 名称不是固定的,每次创建 ReplicaSet 都可能不同。三个 pod 可能如下所示:

NAME                              READY   STATUS    RESTARTS      AGE
joker-replicaset-nznl4            1/1     Running   0             16s
joker-replicaset-vqdcx            1/1     Running   0             16s
joker-replicaset-zxmf9            1/1     Running   0             16s

我们试着删除其中一个 pod,看看 ReplicaSet 是否会拉起一个新 pod,确保运行副本数仍为 3:

kubectl delete pod joker-replicaset-nznl4

ReplicaSet 几乎会立刻启动一个新 pod:

NAME                              READY   STATUS    RESTARTS      AGE
joker-replicaset-m7hvk            1/1     Running   0             59s
joker-replicaset-vqdcx            1/1     Running   0             6m26s
joker-replicaset-zxmf9            1/1     Running   0             6m26s

如果我们想把 ReplicaSet 扩展到 4 个副本,可以运行 kubectl scale

kubectl scale ReplicaSet joker-replicaset --replicas=4

此时会有 4 个副本:

NAME                              READY   STATUS    RESTARTS      AGE
joker-replicaset-lkwjb            1/1     Running   0             5m29s
joker-replicaset-m7hvk            1/1     Running   0             39m
joker-replicaset-sr4vr            1/1     Running   0             17s
joker-replicaset-vqdcx            1/1     Running   0             44m

我们也可以验证:ReplicaSet 是否能监控在它创建之前就存在的 pod。做法是先删除 ReplicaSet:

kubectl delete ReplicaSet joker-replicaset

然后先启动一个单独的 hello-joker pod:

kubectl create -f pod.yaml

接着重新创建 ReplicaSet,并观察它会创建多少新 pod。ReplicaSet 期望总共有 3 个 pod。你会看到它额外拉起 2 个新 pod,加上原来的那个 pod,正好满足 3 个副本:

NAME                              READY   STATUS    RESTARTS      AGE
joker                             1/1     Running   0             3m46s
joker-replicaset-49c62            1/1     Running   0             3m19s
joker-replicaset-976ph            1/1     Running   0             3m19s

ReplicaSet 会通过持续监控并调整副本数来响应环境变化,即便出现节点故障或流量负载波动,也能维持应用的期望状态。因此,我们已经能够创建、修改与删除 ReplicaSet,并且也看到 ReplicaSet 可以把“早于它存在的 pod”纳入管理范围。

Deployments

K8s 的 Deployment 负责处理诸如扩容/缩容、升级容器镜像等操作,以确保应用的实际状态与期望状态一致。这与 ReplicaSet 很相似,但两者也有几个差异。Deployment 支持滚动更新(rolling updates) :当我们要把应用容器镜像升级到新版本时,可以渐进式地进行——一次下线一个旧 pod,再用新 pod 替换它。在这个过程中,如果新 pod 出现错误,更新会被暂停。相比“先把旧版本的所有 pod 全部下线,再一次性换成新版本”,这种方式更好,因为它可以确保用户几乎不受影响。Deployment 还支持回滚(rollback) :如果我们发现新版本应用有问题,可以选择回滚到此前正常工作的版本。

Deployment 是更高层的抽象,用于以声明式方式更新应用。Deployment 管理 ReplicaSet,而 ReplicaSet 再管理承载应用容器的 pod。Deployment 也提供滚动更新与回滚等额外能力(见图 3.5)。

image.png

图 3.5 创建一个 deployment 时,它会创建一个 ReplicaSet;ReplicaSet 基于指定的 pod 模板来管理 pod 的创建与扩缩容;pod 内部承载应用容器。

我们为 hello-joker 应用创建一个 deployment,如清单 3.6 所示。从 YAML 语法上看,它与 ReplicaSet 基本一样,只有一处改动:把 kindReplicaSet 改为 Deployment

清单 3.6 deployment.yaml:创建一个 deployment

apiVersion: apps/v1    #1
kind: Deployment    #2
metadata:
  name: joker-deployment    #3
  labels:
      app: fast-api
spec:
  template:    #4
    metadata:
      name: joker
      labels:
          app: fast-api
    spec:
      containers:
      - name: hello-joker
        image: varunmallya/hello-joker:v1
        ports:
        - containerPort: 8083
  replicas: 3
  selector:
    matchLabels:
      app: fast-api
#1 Deployment 的 API 版本是 apps/v1
#2 kind 为 Deployment
#3 指定 deployment 名称
#4 template 与 ReplicaSet 使用的相同

kubectl create 创建 deployment:

kubectl create -f deployment.yaml

输出如下:

deployment.apps/joker-deployment created

我们可以用下面命令列出 deployment:

kubectl get deployments

在输出中,NAME 是 deployment 名称;READY 表示已启动的 pod 数 / 期望的 pod 数;UP-TO-DATE 表示运行着目标应用版本的 pod 数;AVAILABLE 表示当前可用 pod 数;AGE 是 deployment 启动以来的时间:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
joker-deployment   3/3     3            3           12m

现在我们来看看 deployment 如何更新 pod。我们把镜像 tag 改为 v2

containers:
- name: hello-joker
  image: varunmallya/hello-joker:v2

然后用下面命令更新 deployment:

kubectl apply -f deployment.yaml

随后我们可以用 kubectl describe 查看 deployment 事件。事件会显示 pod 是一次只扩一个的方式逐步更新的:这里 joker-deployment-5d7fd8d75f 是旧版本的 ReplicaSet,joker-deployment-5c6f544ccd 是新版本的。旧版本一开始有 3 个副本;新版本先拉起 1 个 pod,然后旧版本缩到 2 个,新版本扩到 2 个,如此往复:

kubectl describe deployment joker-deployment

事件示例(节选):

Events:
Type    Reason                   Age   From
----    ------                   ----  ----
Normal  ScalingReplicaSet        40m   deployment-controller
        Scaled up replica set joker-deployment-5d7fd8d75f to 3
Normal  ScalingReplicaSet        104s  deployment-controller
        Scaled up replica set joker-deployment-5c6f544ccd to 1
Normal  ScalingReplicaSet        96s   deployment-controller
        Scaled down replica set joker-deployment-5d7fd8d75f to 2
...
Normal  ScalingReplicaSet        78s   deployment-controller
        Scaled down replica set joker-deployment-5d7fd8d75f to 0

接下来,我们把 deployment 更新为一个不存在的镜像。此时你会在事件表里看到一条新记录:新版本只被扩到了 1 个 pod。原因是 deployment 在创建该 pod 时发现镜像拉取失败,于是不会继续扩到 3 个;并且也不会缩掉旧版本的应用,因此 deployment 仍确保有 3 个旧版本 pod 在运行。

此时再 kubectl get,你会看到只有 1 个 pod 处于 UP-TO-DATE(对应失败的新版本),但旧版本仍有 3 个 pod 在运行:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
joker-deployment   3/3     1            3           69m

我们可以通过 kubectl rollout undo 撤销这次部署:

kubectl rollout undo deployment/joker-deployment

这会把 deployment 回滚一个版本,并清掉那个有错误版本的 pod。此时 deployment 又回到 3 个 up-to-date pod:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
joker-deployment   3/3     3            3           3h34m

最后,我们可以删除 deployment:

kubectl delete deployment joker-deployment

到这里,你已经学会了创建、更新、回滚与删除一个 deployment。像 hello-joker 这样的无状态应用,以及后续章节会部署的 ML 推理服务,大多都会用 K8s deployment 来完成部署。

3.3.4 网络与服务(Networking and services)

在上一节中,你学会了如何在 K8s 上部署应用。但我们如何让应用对用户/其他应用可用?我们如何访问 FastAPI 的 joker 端点来获取随机笑话?前面我们通过 kubectl port-forward 连接到 pod,但这只适用于测试场景——我们不能指望普通用户每次访问网站都先做一次端口转发。K8s 的 Service 让我们能够把应用暴露给用户与其他应用,并在 K8s 集群内部提供高效的网络与连通性。

K8s Service 也允许集群内部的应用彼此通信。想象我们有一个需要与后端应用通信的前端应用;两者都部署在 K8s 中。前端应用会与外部用户交互,并通过 Service 与后端应用通信(见图 3.6)。

image.png

图 3.6 用户通过 service 1 访问前端应用;前端应用通过 service 2 与后端应用通信。这里的 service 起到抽象层作用。

注意:pod 自己有 IP 地址,pod 之间也可以互相通信。但我们也知道,pod 会被重启、删除,并且每次新部署都可能重新拉起。pod 这种动态且常常短暂(transient)的特性,需要通过 K8s Service 提供的抽象层,与其他组件与外部用户所需要的稳定性解耦。K8s Service 有三种类型:

  • NodePort
  • ClusterIP
  • LoadBalancer

NodePort

NodePort Service 用于把运行在 pod 中的应用,通过它所在节点(node)的某个端口对外提供访问。当我们创建一个 NodePort Service 时,K8s 会在每个节点上为该 Service 分配一个特定端口。这个端口会把流量转发到属于该 Service 的 pods。分配到的 NodePort 允许外部客户端(用户或其他服务)通过集群节点访问我们的应用。例如,如果分配到的 NodePort 是 33000,那么外部客户端可以通过 http://<node_IP>:33000 访问服务。即使集群扩缩容,NodePort Service 也会在所有节点上保持可用,而不管 pods 实际跑在哪些节点上。这样即便因为扩缩容或节点故障导致 pod 漂移,也能保证访问方式一致。

我们来为 hello-joker 应用创建一个 NodePort Service。与所有 K8s 对象一样,我们创建一个 YAML 文件,写入四个通用属性:apiVersionv1kindServicemetadata 包含 service 名称;spec 中指定 service 类型、端口信息以及 selector。在 ports 下,我们列出三类端口:

  • targetPort —— pod 上应用实际监听的端口
  • port —— service 端口,用于与 targetPort 通信
  • nodePort —— 用户可以访问 service 的节点端口(取值范围 30000~32767)

与 deployment 和 ReplicaSet 类似,我们需要指定一个 selector,用来匹配我们希望 service 关联的 pod 的 labels。图 3.7 展示了 NodePort service 的关系。

image.png

图 3.7 用户访问 NodePort;NodePort 转到 service port;service port 再转到 pod 的 targetPort。

我们先重启 deployment,把 pods 拉起来:

kubectl create -f deployment.yaml

在有了这些信息之后,我们定义 node-port-service.yaml,如清单 3.7 所示。

清单 3.7 node-port-service.yaml:创建 NodePort service

apiVersion: v1    #1
kind: Service    #2
metadata:    #3
  name: joker-nodeport-service
spec:
  type: NodePort    #4
  ports:
    - targetPort: 8083    #5
      port: 80    #6
      nodePort: 30420    #7
  selector:
      app: fast-api    #8
#1 Service 的 API 版本是 v1
#2 kind 为 Service
#3 在 metadata 下指定 service 名称
#4 在 spec.type 下指定 service 类型
#5 targetPort 是 pod 的端口
#6 port 是与 targetPort 通信的 service 端口
#7 nodePort 是物理节点上与 service 端口通信的端口
#8 selector 用于匹配 label 为 app: fast-api 的 pods

创建它:

kubectl create -f node-port-service.yaml

然后用下面命令查看 service:

kubectl get service

其中,NAMETYPE 分别表示 service 名称与类型;CLUSTER-IP 是 service 的内部 IP;EXTERNAL-IP 是从集群外部访问集群内 service 时可使用的 IP 地址。在我们这里它为空,因为我们将使用节点 IP 作为 host 地址。PORT(S) 表示 <service_port>:<node_port>/protocolAGE 表示 service 创建以来的时间:

NAME                    TYPE      CLUSTER-IP      EXTERNAL-IP  PORT(S)        AGE
joker-nodeport-service  NodePort  10.65.67.22     <none>       80:30420/TCP   19m

要测试该 service,我们需要先获取节点 IP。可以运行 kubectl get nodes,加上 -o wide 会提供更多信息,例如节点的 external IP:

kubectl get nodes -o wide | grep -v EXTERNAL-IP | awk '{print $1, $7}'

输出会列出节点名与其 external IP 地址:

gke-dev-04b53695-tkxx 35.189.23.46 
gke-dev-06b596589-bkfx 35.190.23.67
gke-dev-089596589-dkjn 35.67.23.46
gke-dev-55969b6509-mkig 35.69.230.43

我们可以用任意节点的 external IP 加上 NodePort 来访问服务。比如用第一个节点的 external IP 测试:

curl http:/35.189.23.46:30420

返回:

{"random_joke":"Pyjokes is like Adobe Flash: always updated, never better."}

这说明应用在 NodePort 30420 上可用,我们能通过调用端点拿到随机笑话。要记住:你可以使用任意节点 IP,因为 K8s 会负责把请求路由到正确的 service 与 pod。这展示了 NodePort service 如何作为应用的抽象层。要删除该 service,执行:

kubectl delete service joker-nodeport-service

ClusterIP

当我们需要访问同一 K8s 集群内部的 pods 时,ClusterIP service 会提供一个集群级别的内部 IP 地址。为了让应用内部的多个组件能互相通信,但又不把应用暴露到外部网络,我们会使用这种 service。典型例子是:前端应用与后端应用都运行在 K8s 集群里,且通信完全在集群内部完成。

创建 ClusterIP service 时,K8s 会从集群内部 IP 段分配一个 IP 地址给 service。这个 IP 无法从集群外部访问,只能在集群内使用。该 service 会对符合 selector 的 pods 进行流量负载均衡:service IP 会把流量路由到这些 pods,并在它们之间均匀分配。在集群内部,ClusterIP service 的 IP 地址也可以通过 DNS 名称来访问相关 pods。K8s 通过 DNS 记录把 service 名称与 ClusterIP 映射起来。

我们为 hello-joker 应用创建一个 ClusterIP service,如清单 3.8 所示。cluster-ip-service.yamlnode-port-service.yaml 很相似,只有两点差异:类型是 ClusterIP,并且 ports 里不需要指定 nodePort

清单 3.8 cluster-ip-service.yaml:创建 ClusterIP service

apiVersion: v1    #1
kind: Service    #2
metadata:
  name: joker-nodeport-service
spec:
  type: ClusterIP   #3
  ports:    #4
    - targetPort: 8083
      port: 80
  selector:
      app: fast-api   #5
#1 Service 的 API 版本是 v1
#2 kind 为 Service
#3 service 类型为 ClusterIP
#4 指定 targetPort 与 port,但不指定 nodePort
#5 selector 用于匹配 label 为 app: fast-api 的 pods

创建 ClusterIP service:

kubectl create -f cluster-ip-service.yaml

kubectl get service 查看:

NAME                       TYPE       CLUSTER-IP       EXTERNAL-IP  PORT(S)  AGE
joker-cluster-ip-service   ClusterIP  10.65.75.100     <none>       80/TCP   9s

我们可以从另一个 pod 内部调用该 service 来测试。比如我们运行一个临时 pod,名为 curl-test,镜像 curlimages/curl(内置 curl)。下面命令会临时创建该 pod,并进入 shell 以运行 curl:

kubectl run curl-test --rm -it --image=curlimages/curl:latest -- sh

在这个 shell 中 curl service IP 与端口:

~ $ curl http://10.65.75.100:80

返回:

{
  "random_joke": "There are two ways to write error-free programs; \
only the third one works."
}

我们也可以 curl service 名称,它会作为 hostname:

~ $ curl http://joker-cluster-ip-service:80

返回:

{
  "random_joke": "How many programmers does it take to change a lightbulb? \
None, that's a hardware problem."
}

ClusterIP service 通过一个集群范围的 IP 地址,充当 K8s 环境中多个组件之间的连接纽带。

LoadBalancer

LoadBalancer service 类型使用外部负载均衡器,把一组 pods 暴露给外部世界(或集群内部的其他服务)。当需要把进入的流量分散到多个 pods 上,以提升性能、冗余与可用性时,这种 service 类型很常见。

LoadBalancer service 会创建一个位于 K8s 集群之外的外部负载均衡器。该负载均衡器接收进入的流量,并将其分发到与该 service 关联的 pods。当我们创建 LoadBalancer service 时,K8s 会与云提供商通信,为负载均衡器分配一个外部 IP 地址。位于集群外部的客户端可以使用这个 IP 访问应用。外部负载均衡器把进入流量路由到 service 的节点与 pods,从而确保流量均匀分配与资源高效利用。LoadBalancer service 的外部 IP 为外部客户端(用户或其他服务)提供了访问应用的入口点:它为 service 中包含的 pods 提供一个单一访问点(见图 3.8)。

image.png

图 3.8 外部负载均衡器通过 LoadBalancer service 把流量路由到 pods。

我们为 hello-joker 应用创建一个 LoadBalancer service。load-balancer-service.yaml 与 ClusterIP service 类似,唯一差别是 type 设为 LoadBalancer,如清单 3.9 所示。

清单 3.9 load-balancer-service.yaml:LoadBalancer service

apiVersion: v1    #1
kind: Service    #2
metadata:
  name: joker-load-balancer-service
spec:
  type: LoadBalancer    #3
  ports:    #4
    - port: 80
      targetPort: 8083
  selector:
    app: fast-api    #5
#1 Service 的 API 版本是 v1
#2 kind 为 Service
#3 service 类型为 LoadBalancer
#4 指定 targetPort 与 port
#5 selector 用于匹配 label 为 app: fast-api 的 pods

kubectl create 创建 LoadBalancer service:

kubectl create -f loadbalancer-service.yaml

kubectl get 查看 service 详情,会得到类似输出:

NAME                          TYPE           CLUSTER-IP     EXTERNAL-IP     
joker-load-balancer-service   LoadBalancer   10.65.77.13    34.101.34.188   
PORT(S)          AGE
80:30729/TCP     3m7s

可以看到 LoadBalancer service 有一个外部 IP。我们也可以在本地用 curl 访问这个 IP 来拿到笑话:

curl http://34.101.34.188:80

返回:

{"random_joke":"Java: Write once, run away."}

LoadBalancer service 通常用于把服务暴露给外部客户端或网络,适用于面向公众的 Web 应用、API,以及需要直接对外访问的服务。

下面表格总结三种 service 类型的差异(原文为表,此处保持原意):

  • ClusterIP:只能在集群内部访问;用于集群内组件间的内部通信;配置最简单。
  • NodePort:可通过节点 IP + 指定端口从外部访问;适用于需要外部访问但不想引入复杂配置的开发或测试场景;需要指定对外可访问的静态端口。
  • LoadBalancer:通过云厂商的外部负载均衡器对外提供访问;适用于生产场景,要求外部访问与自动负载均衡;涉及外部负载均衡器的创建以及云厂商相关的额外配置。

Deployment 与 Service 是最常用的两类 K8s 对象。有了这些知识,你现在可以在 K8s 上部署应用,并让它们对其他服务或用户可访问。

3.3.5 其他对象(Other objects)

除了 K8s 的 deployment 与 service 之外,还有一些 K8s 对象能帮助我们实现生产级部署。K8s 对象的完整清单超出了本书范围,但我们这里再介绍三个对象:

  • Namespaces
  • ConfigMaps
  • Secrets

Namespaces

Namespace 是在一个物理集群中创建“虚拟集群”的一种方式。它为名称提供作用域,让我们能够以更隔离、更可管理的方式组织与管理资源。Namespace 在大型、复杂的 K8s 环境中特别有用:多个团队或应用共享同一个物理集群,但又需要隔离与资源管理。Namespace 在单个 K8s 集群内提供一种资源隔离形式。每个 namespace 都有自己的一组资源,例如 pods、services、ReplicaSets 等。这样的隔离能避免命名冲突,并让不同团队或应用可以相对独立地工作。

默认情况下,K8s 会有一个 default namespace。如果你不指定 namespace,资源就会创建在 default 中。前面章节里我们创建的所有资源都是在 default namespace 里。不过,当需要时我们可以创建额外的 namespace。

我们来创建一个名为 funny 的新 namespace,并在其中部署 hello-joker 应用。创建 namespace 使用 kubectl create namespace

kubectl create namespace funny

用下面命令列出 namespaces(包括名称与创建时长):

kubectl get namespaces

输出类似:

NAME      STATUS   AGE
default   Active   86d
funny     Active   6m

要查看该 namespace 中部署的 pods,我们在 kubectl get pod 后加 -n(n 指 namespace):

kubectl get pod -n funny

因为我们还没在该 namespace 部署任何东西,会看到:

No resources found in funny namespace.

现在把 hello-joker 应用部署到 funny namespace。做法是:在 deployment.yamlmetadata 下增加 namespace 属性:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: joker-deployment
  namespace: funny
  labels:
      app: fast-api
..

运行以下命令创建该 deployment:

kubectl create -f deployment-with-namespace.yaml

然后再在 funny namespace 里执行 kubectl get,会看到创建了三个 pods:

NAME                                READY   STATUS    RESTARTS   AGE
joker-deployment-5d7fd8d75f-cjxh7   1/1     Running   0          6m52s
joker-deployment-5d7fd8d75f-rhx4q   1/1     Running   0          6m53s
joker-deployment-5d7fd8d75f-xrqqh   1/1     Running   0          6m55s

创建 service 也类似:在 cluster-ip-service.yaml 的 service metadata 下指定 namespace 即可。我们在 funny namespace 创建一个 ClusterIP service:

kubectl create -f service-with-namespace.yaml

-n 列出该 namespace 的 services:

kubectl get services -n funny

输出类似:

NAME                       TYPE       CLUSTER-IP       EXTERNAL-IP  PORT(S)  AGE
joker-cluster-ip-service   ClusterIP  10.65.77.208     <none>       80/TCP   5s

同一 namespace 内的 services 可以直接用名称互相引用。但如果要从另一个 namespace 调用某个 service,需要遵循下面的 DNS 约定:

<service-name>.<namespace-name>.svc.cluster.local

我们试着从 default namespace 中的 curl-test pod 调用 funny namespace 里刚创建的 ClusterIP service。重启该 pod 并在 shell 里运行 curl:

curl http://joker-cluster-ip-service.funny.svc.cluster.local:80

返回:

{
  "random_joke": "If you put a million monkeys at a million keyboards, \
one of them will eventually write a Java program. The rest of them will \
write Perl."
}

随着应用规模扩张,K8s namespace 会成为保持清晰与秩序的重要工具。它鼓励对资源进行整洁分类,把不同团队、项目或环境隔离开来,而不用担心命名冲突;因此,生态中的复杂度也能得到控制。

ConfigMaps

ConfigMap 是 K8s 对象,用于把配置数据与应用代码分离管理。它提供一种集中式方式来存储键值对、环境变量,甚至配置文件。ConfigMap 让配置与应用逻辑解耦,使你无需修改应用代码或容器,就能更容易管理与更新配置项。这类配置包括数据库 URL、API 端点等。

一个 ConfigMap 示例见清单 3.10。apiVersionv1kindConfigMapmetadata 下指定 ConfigMap 名称。这里不再用 spec,而是用 data,在其中写入应用可能会用到的键值对。

清单 3.10 config-map.yaml:创建 ConfigMap

apiVersion: v1    #1
kind: ConfigMap    #2
metadata:
  name: test-configmap     #3
data:    #4
  database-url: "cloudsql-proxy.prod.company.data"
  environment: sandbox 
#1 ConfigMap 的 API 版本是 v1
#2 kind 为 ConfigMap
#3 ConfigMap 名称为 test-configmap
#4 键值对存放在 data 下

列出 ConfigMaps:

kubectl get configmaps

会返回 ConfigMap 名称;DATA 列显示该 ConfigMap 中键值对数量:

NAME           DATA   AGE
test-configmap 2      26m

kubectl describe 查看 ConfigMap 的内容:

kubectl describe configmap test-configmap

会输出类似:

Name:         test-configmap
Namespace:    default
Labels:       <none>
Annotations:  <none>
Data
====
environment:
----
sandbox
database-url:
----
cloudsql-proxy.prod.company.data

Secrets

Secret 是 K8s 对象,用于以更安全的方式管理敏感信息,例如密码、API token 等机密数据。Secret 通过对静态数据进行编码来提供更高一层的安全性。

在生成 secret 时,敏感信息通常会进行 base64 编码,以处理特殊字符与二进制数据,从而能在 YAML 文件中以文本友好格式表示。需要强调:base64 是编码(encoding) ,不是加密(encryption) ——这是一个关键区别。必要时这些信息很容易被解码。敏感信息应当使用行业标准加密工具进行加密,并存放在 K8s 之外,或使用专门的密钥管理方案(例如 Vault)。

K8s secret 的一些类型包括:

  • Opaque —— 任意键值对
  • Docker Registry —— Docker 认证信息
  • TLS —— TLS 证书与私钥

清单 3.11 给出了一个 secret 示例:apiVersionv1kindSecretmetadata 指定名称,type 指定 secret 类型。data 下写入 database_usernamedatabase_password 的 base64 编码值。要对一个值做 base64 编码,可以运行:

echo -n "db_username" | base64

得到:

ZGJfcGFzc3dvcmQ=

当我们有了用户名与密码的 base64 编码值后,就可以创建 secret 了。

清单 3.11 secret.yaml:创建 secret

apiVersion: v1    #1
kind: Secret     #2
metadata:
  name: test-secret    #3
type: Opaque    #4
data:
  database_username: ZGJfdXNlcm5hbWU=
  database_password: ZGJfcGFzc3dvcmQ=
#1 Secret 的 API 版本是 v1
#2 kind 为 Secret
#3 Secret 名称为 test-secret
#4 该 secret 以通用 Opaque 类型存储键值对及其 base64 编码值

创建 secret:

kubectl create -f secret.yaml

列出当前 namespace 的 secrets:

kubectl get secret

输出类似 ConfigMap,但多了一个 TYPE 列表示 secret 类型:

NAME        TYPE    DATA   AGE
test-secret Opaque  2      4s

如果想查看 secret 的信息,用 kubectl describe

kubectl describe secret test-secret

它会显示用于存储 database_passworddatabase_username 的字节数,但不会显示具体值:

Name:         test-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>
Type:  Opaque
Data
====
database_password:  11 bytes
database_username:  8 bytes

要取出并解码值,可以运行下面命令:--template 用于指定变量路径,base64 -d 用于解码:

kubectl get secrets/test-secret \
  --template={{.data.database_username}} \
| base64 -d

输出:

db_username

现在我们知道如何定义 Secret 与 ConfigMap 了,但如何在应用里使用它们?做法是:在 pod 中把 ConfigMap 里的变量作为环境变量注入。环境变量在 pod 中通过 env 定义;值通过 valueFrom 获取;ConfigMap 通过 configMapKeyRef 指定 ConfigMap 名称与 key。对于 Secret,则把 configMapKeyRef 换成 secretKeyRef

我们把 test-configmap 中定义的 database-urlapi-key 用在一个 pod 里。我们定义两个环境变量 DATABASE_URLAPI_KEY,并通过 configMapKeyReftest-configmap 取值:

apiVersion: v1
kind: Pod
metadata:
  name: pod-using-configmap
spec:
  containers:
    - name: container-using-configmap
      image: ubuntu
      env:
        - name: DATABASE_URL
          valueFrom:
            configMapKeyRef:
              name: test-configmap
              key: database-url
        - name: API_KEY
          valueFrom:
            configMapKeyRef:
              name: test-configmap
              key: api-key

我们也可以使用 test-secret 中定义的变量,此时在 valueFrom 下使用 secretKeyRef

env:
  - name: DATABASE_USERNAME
    valueFrom:
      secretKeyRef:
        name: test-secret
        key: database-username
  - name: DATABASE_PASSWORD
    valueFrom:
      secretKeyRef:
        name: test-secret
        key: database-password

到这里,我们已经覆盖了 pods、ReplicaSets、deployments、services、namespaces、ConfigMaps 与 secrets。K8s 文档中还解释了许多其他对象;我们这里描述的这些对象,已经能帮助我们部署大多数基础应用。

K8s 作为容器编排平台的强大与高效,很大程度上依赖于这些对象:它们是实现容器化应用管理、部署、扩缩容与通信的关键基石。

3.3.6 Helm charts

当一个应用被部署时,更多情况下并不是只需要把一个 K8s deployment 部署到集群里。相反,往往需要一整套组合:deployment、ConfigMap、secrets、services 以及其他 K8s 对象。管理这些对象之间的关系会变得相当复杂。如果我们想修改或升级其中某个对象,就必须去改它对应的单独文件。K8s 并不会把我们的应用当作一个整体来看待;它会检查每一个独立对象,确保其按预期工作。因此,我们需要一种方式,把应用所需的所有对象打包在一起。

Helm 是一个 K8s 包管理器,用来更容易地部署与维护应用。它既能确保应用所需的所有对象都被安装到集群中各自正确的位置,也能保证它们是可定制的。Helm 通过一个称为 chart 的概念来打包应用,使定义、安装、升级与管理复杂 K8s 应用变得更简单。一个 chart 由对象模板(object templates)取值(values) 组成。对象模板是 K8s 对象的 YAML 文件,其中某些属性是可定制的(见图 3.9)。以 pod 或 deployment 为例,可定制属性可以是镜像名,或者请求的 CPU/内存。所有这些可定制值都在 values 文件中修改。values 文件(通常叫 values.yaml)可以用来定制成百上千个 K8s 对象,使应用管理更容易。

image.png

图 3.9 service_template.yaml 文件定义了 K8s service,而 service 类型与端口从 values.yaml 中获取。模板与取值共同构成 Helm chart 的核心。

类似于 Docker Hub 为多种应用托管 Docker 镜像,我们有 Artifact Hub——它作为一个 Helm chart 仓库,收录了大量常用应用的 Helm charts。下面我们来安装 Helm,并使用一些常见 Helm 命令,帮助我们在 K8s 中安装与更新应用。

在 Linux 上,我们运行:

curl -fsSL -o get_helm.sh \
https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3
chmod +x get_helm.sh
./get_helm.sh

在 macOS 上,我们可以用 Homebrew 安装 Helm:

brew install helm

安装完成后,我们可以通过 Helm 版本命令验证安装:

helm version

输出类似:

version.BuildInfo{
  Version:      "v3.7.2",
  GitCommit:    "663a896f4a815053445eec4153677ddc24a0a361",
  GitTreeState: "clean",
  GoVersion:    "go1.17.3",
}

现在我们可以用 Helm 安装一个应用。作为示例,我们用 Helm 来部署 Redis。Redis 是一个流行的开源内存键值存储。Artifact Hub 上提供了 Redis 的 Helm chart。要安装 Redis,首先需要添加 chart 仓库(Helm 会在这个仓库里查找 charts):

helm repo add bitnami https://charts.bitnami.com/bitnami

添加仓库后,下一步是搜索我们要安装的 Redis chart:

helm search repo redis

会得到名称中包含 Redis 的 charts 列表。NAME 是 chart 名称,APP VERSION 是 Redis 版本:

NAME                  CHART VERSION  APP VERSION
bitnami/redis         17.3.11        7.0.5
DESCRIPTION
Redis(R) is an open source, advanced key-value store
bitnami/redis-cluster 8.2.7          7.0.5
DESCRIPTION
Redis(R) is an open source, scalable, distributed key-value store

确定 chart 存在后,我们用 Helm 仓库更新来确保拿到最新 charts。我们创建一个新 namespace 叫 redis-setupkubectl create ns redis-setup)。安装 chart 时,我们可以用 --generate-name 让 Helm 自动生成 release 名称:

helm repo update
kubectl create namespace redis-setup
helm install bitnami/redis --generate-name --namespace redis-setup

这会安装 Redis,并给出一个 release 名称与 revision 编号。因为这是第一次发布,revision 为 1。同时它也会给出 chart 信息,例如 NAMECHART VERSIONAPP VERSION

NAME: redis-1692770110
LAST DEPLOYED: Wed Aug 23 13:55:24 2023
NAMESPACE: redis-setup
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: redis
CHART VERSION: 17.3.11
APP VERSION: 7.0.5

我们可以通过下面命令看到 Redis 在 redis-setup namespace 里运行:

kubectl get pod -n redis-setup

输出类似:

NAME                          READY   STATUS    RESTARTS   AGE
redis-1692770110-master-0     1/1     Running   0          7m40s
redis-1692770110-replicas-0   1/1     Running   0          7m40s
redis-1692770110-replicas-1   1/1     Running   0          6m57s
redis-1692770110-replicas-2   1/1     Running   0          6m18s

我们可以用 helm list 列出所有 Helm releases。它会显示 release 名称、部署状态、chart 版本与应用版本:

helm list -n redis-setup

输出类似:

NAME              NAMESPACE    REVISION  UPDATED              STATUS  
redis-1692770110  redis-setup  1         2023-08-23 13:55:24  deployed
CHART APP VERSION
7.0.5

最后,如果要卸载该 Helm release,可以运行 helm uninstall

helm uninstall redis-1692770110 -n redis-setup

输出:

release "redis-1692770110" uninstalled

如果我们想下载 chart 但不安装,可以用 helm pull。同时用 --untar 解压 chart 目录:

helm pull --untar bitnami/redis

redis 目录下,可以看到 chart 的目录与文件。Chart.lockChart.yaml 用于指定 chart 依赖;charts 目录存放依赖 charts;img 文件夹与 README.md 包含 Redis chart 文档。对大多数项目(以及对我们)最关键的是 templates 目录:它包含安装 Redis 所需的 K8s 对象模板。values.yaml 用来存放模板的可配置值,而 values.schema.json 用来校验 values.yaml 中添加的值:

├── Chart.lock
├── Chart.yaml
├── README.md
├── charts
├── img
├── templates
├── values.schema.json
└── values.yaml

现在我们为 hello-joker 应用创建一个类似的 chart:

helm create hello-joker

这会生成一个名为 hello-joker 的目录,包含 chart 文件:Chart.yaml、一个空的 charts 目录(暂无依赖),以及 templates 目录(里面有诸如 deployment.yamlservice.yaml 等 K8s 对象):

├── Chart.yaml
├── charts
├── templates
└── values.yaml

我们通过修改 values.yaml 来把应用配置为:一个 3 副本的 deployment,以及一个类型为 NodePort 的 service。values.yaml 里展示了多种配置,我们只需要修改与 deployment 和 service 相关的配置即可,包括 replicaCount、镜像信息、以及 service 类型与端口:

replicaCount: 3
image:
  repository: varunmallya/hello-joker
  pullPolicy: IfNotPresent
  tag: v1
..
service:
  type: NodePort
  port: 8080

修改 values 后,我们就可以用 helm install 把该 chart 安装到 funny namespace。这里同样用 --generate-name 自动生成 release 名称:

helm install hello-joker --generate-name -n funny

我们用 kubectl get all 查看该 namespace 下的服务与部署。get all 会返回指定 namespace 下的所有对象:我们会看到一个 service、三个 pods、一个 deployment,以及一个 ReplicaSet。也就是说,我们通过一次 helm install 就完成了整套资源安装:

kubectl get all -n funny

输出类似:

NAME                                          READY   STATUS    RESTARTS   AGE
pod/hello-joker-1692835125-59bb5b9985-c8hg7   1/1     Running   0          29m
pod/hello-joker-1692835125-59bb5b9985-twnqr   1/1     Running   0          29m
pod/hello-joker-1692835125-59bb5b9985-x9q5h   1/1     Running   0          29m

NAME                             TYPE      CLUSTER-IP     EXTERNAL-IP  PORT(S)         AGE
service/hello-joker-1692835125   NodePort  10.65.75.5     <none>       8080:31410/TCP  29m

NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello-joker-1692835125   3/3     3            3           29m

NAME                                               DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-joker-1692835125-59bb5b9985  3         3         3       29m

现在你已经熟悉 Helm 的基础,这将帮助你部署 ML 应用,以及后续用于监控与部署其他 ML 工具的应用。Helm charts 通过把应用、配置与依赖打包成一个单一且一致的单元,为复杂应用的简化管理、版本化与共享提供了一条顺畅路径。

3.3.7 结论(Conclusion)

现在你已经能够使用 Docker 将应用容器化,并把它们部署到 K8s 上。K8s 是一个非常庞大的主题,需要单独一本书来系统覆盖(例如 Kubernetes in Action [Manning, 2026] 就是其中一本),不过,本章所介绍的内容已经提供了一个很好的起点。

作为容器编排器,K8s 不仅让我们能够自动化部署,也帮助我们这些 ML 工程师更轻松地搭建复杂应用。在部署与管理 ML 工作负载时,K8s 有多方面优势。它的特性尤其适合 ML 应用,因为 ML 应用通常是动态的、资源密集型的。ML 工作负载经常需要大量计算资源;K8s 使得对资源进行有效扩缩容与管理成为可能,从而确保 ML 模型在训练与推理时能获得所需算力。

K8s 让 ML 工作负载的部署、可扩展性与管理变得更简单。借助它提升的资源效率、灵活性与可维护性,数据科学与工程团队可以把精力更多集中在模型构建与创新上。

3.4 持续集成与持续部署(Continuous integration and deployment)

在前面的章节里,我们能够构建镜像并把它部署到 K8s 上。但我们是手动完成镜像构建与部署的。一个应用在其生命周期中代码会频繁变化,因为会有多人参与协作。允许生产应用进行手动部署会带来很多问题;例如,如果有人指定了错误的镜像 tag,就可能部署到错误的应用版本。此外,某个用户在本地进行部署、并覆盖了最新部署,也可能导致程序无法正常运行。显然,更好的方式是通过持续集成(CI)/持续部署(CD)作业把这一流程自动化。CI/CD 是软件开发中被广泛采用的行业实践,是现代软件开发与发布工作流的关键组件。你可能在软件工程圈里听过这些术语;同样的原则也可以用于部署 ML 应用。

CI 作业负责测试、构建并更新 Helm chart;而 CD 作业负责把更新后的 Helm chart(包含新构建的镜像 tag)部署出去。CD 工具还会监控已部署的应用,并确保它与最近一次构建的镜像 tag 保持一致。这在有人不小心部署了旧版本时很有用:监控该部署的 CD 工具可以纠正它,并用新构建的部署替换旧部署。这样就能为已部署应用确立一个单一事实来源(single source of truth)。CI 作业我们使用 GitLab CI,CD 作业我们使用 Argo CD。

3.4.1 GitLab CI

CI 的主要目标之一,是在运行一定的单元/集成测试之后,自动化软件部署。这能帮助我们更快部署、也更快发现 bug。CI 作业通常会在每次向 Git 仓库提交(commit)后触发。GitLab 是许多组织常用的 Git 仓库之一,因为它让编写 CI 作业变得很容易。

CI 作业能做很多事。最常见的 CI 用例包括:测试代码、构建 Docker 镜像,以及触发部署。Python 提供了多种用于单元测试的包。本章 3.2 节已经展示了如何构建 Docker 镜像。在大多数情况下,部署只会在构建出新镜像后被触发。

在本节中,我们将自动化构建这个返回随机笑话的 FastAPI 应用。首先,我们需要把代码迁移到一个 GitLab 仓库。

要搭建 GitLab CI 项目,请到这里注册:https://about.gitlab.com/free-trial/。将 Group name 与 Project name 分别填写为 learn-mlopsgitlab-ci-example(见图 3.10)。然后按清单 3.12 中的命令把仓库推送到 GitLab。

image.png

图 3.10 在 GitLab 中创建项目:指定 Group name 与 Project name。

清单 3.12 将项目仓库推送到 GitLab

rm -rf .git
git init --initial-branch=main    #1
git remote add origin \
git@gitlab.com:youraccount/project_name.git    #2
git add .    #3
git commit -m "Initial commit"    #4
git push -u origin main     #5
#1 初始化 Git
#2 添加 Git 项目 URL
#3 将所有文件加入暂存区
#4 提交代码
#5 推送代码

我们来更仔细看看 FastAPI Python 应用的 CI 作业(见清单 3.13)。CI 作业定义在 .gitlab-ci.yaml 中。它会测试 Python 应用、构建 Docker 镜像并推送到 Docker Hub,并用新构建的镜像 tag 更新 Helm chart。在尝试之前,请确保你已经准备好 Docker runtime 与 Docker Hub(见 3.2 节)。

CI 有三个 stage:testbuildupdate。你可以把 stage 理解成流水线中的一个步骤。这些 stages 在 .gitlab-ci.yamlstages 节点下列出。每个 stage 都有一个 job,并且同一个 stage 内的 job 会并发运行。test stage 下的 test_code job 运行单元测试;build stage 下的 build_image job 构建 Docker 镜像;update stage 下的 update_helm_chart job 更新 Helm chart。每个 job 里,我们会指定要运行的脚本,以及运行这些脚本时所用的 Docker 镜像。所有 GitLab CI 作业在运行时都可以使用一些预定义变量,例如 CI_PROJECT_DIRCI_COMMIT_SHA(完整列表见 https://mng.bz/QwXm)。CI_PROJECT_DIR 指 Git 仓库根目录,CI_COMMIT_SHA 指触发 CI 作业的 Git commit ID。

我们也可以在 CI/CD 变量中定义环境变量,例如 DOCKER_PASSWORDDOCKER_USERNAME:路径为 Settings > CI/CD > Variables。这样做适用于存储登录凭证或其他构建环境参数(见图 3.11)。

image.png

图 3.11 通过 Settings > CI/CD,点击 Variables 来创建 GitLab CI 变量。

我们还需要创建一个 access token,才能把更新后的 Helm chart 推回仓库。access token 让我们能在 CI 作业里以编程方式执行 git push。我们会在 GitLab CI 的 update stage 使用该 token。创建方式是:点击用户头像进入用户设置,然后选择 Access Tokens > Add New Token,添加一个新 token,并把 Scopes 设为 API。请在刷新页面之前复制 token 值,因为之后将无法再看到(见图 3.12)。

image.png

图 3.12 添加一个新的 access token。

test stage 的 test_code job 脚本使用 pytest 运行应用单元测试;build stage 的 build_image job 构建并推送 Docker 镜像到个人 Docker Hub 镜像仓库,镜像 tag 使用预定义变量 CI_COMMIT_SHA 截取后的短 commit ID。随后 update stage 的 update_helm_chart job 使用命令行工具 yqhttps://github.com/mikefarah/yq)更新 Helm chart 的镜像 tag(它会修改 values.yaml)。我们会 clone 仓库、修改 YAML,然后把更新后的 YAML 推回仓库。这里会用到我们在 CI/CD variables 中定义的环境变量,例如 GITLAB_USER_EMAILGITLAB_USER_NAMEGITLAB_ACCESS_TOKEN

如果 test_code job 失败,CI 作业就不会进入后续 stages,而是通知 CI 负责人本次 CI 失败。

每当我们把代码变更推送到仓库,CI 作业都会运行。我们可以做一个小改动并推送到 GitLab 仓库来验证,如清单 3.13 所示。

清单 3.13 GitLab CI 示例

image: docker:20.10.16
variables:
  DOCKER_TLS_CERTDIR: "/certs"
stages:    #1
  - test
  - build
  - update
services:    #2
  - docker:20.10.16-dind    #3

test_code:    #4
    stage: test
    image:
      name: python:3.10
    script:
      - echo "testing code"
      - pip install -r $CI_PROJECT_DIR/requirements.txt
      - pytest $CI_PROJECT_DIR/

build_image:    #5
    stage: build
    script:
      - echo "building docker image"
      - cd $CI_PROJECT_DIR
      - echo ${CI_COMMIT_SHA:0:8}
      - docker build . -t varunmallya/hello-joker:${CI_COMMIT_SHA:0:8}
      - docker login -u ${DOCKER_USERNAME} -p ${DOCKER_PASSWORD}
      - docker push varunmallya/hello-joker:${CI_COMMIT_SHA:0:8}

update_helm_chart:    #6
    stage: update
    image:
      name: python:3.10
    script:
      - apt-get update && apt-get install git
      - git clone https://${GITLAB_USER_NAME}:${GITLAB_ACCESS_TOKEN}@\
gitlab.com/learn-mlops/gitlab-ci-example.git  #7
      - cd $CI_PROJECT_DIR
      - wget https://github.com/mikefarah/yq/releases/download/v4.2.0/\
yq_linux_amd64 -O /usr/bin/yq && chmod +x /usr/bin/yq  #8
      - git config --global user.email "${GITLAB_USER_EMAIL}"
      - git config --global user.name "${GITLAB_USER_NAME}"
      - yq e -i ".image.tag |= "${CI_COMMIT_SHA:0:8}"" \
        hello-joker/values.yaml  #9
      - git add hello-joker/values.yaml
      - git commit -m "[skip ci]Update helm chart"    #10
      - git push https://${GITLAB_USER_NAME}:\
        ${GITLAB_ACCESS_TOKEN}@gitlab.com/learn-mlops/\
        gitlab-ci-example.git HEAD:main  #11
#1 声明 job stages
#2 使用 Docker-in-Docker 服务来构建镜像
#3 声明 Docker DIND 的变量
#4 安装依赖并测试代码的 GitLab job
#5 构建 Docker 镜像的 GitLab job
#6 用新镜像 tag 更新 Helm chart
#7 在 CI job 内用预定义的 GitLab access token clone 仓库
#8 安装命令行工具 yq
#9 编辑 values.yaml,用新构建镜像的 tag(短 commit ID)替换 image tag
#10 提交更新后的 Helm chart values;[skip ci] 确保这次提交不会触发 CI
#11 把更新后的 Helm chart values 推回仓库

我们的项目目录结构会包含 .gitlab-ci.yaml,如下所示:

├── Dockerfile
├── entrypoint.sh
├── main.py
├── test_main.py
└── hello-joker
├── .gitlab-ci.yaml
└── requirements.txt

test_main.py 文件包含应用的单元测试。我们还可以用 GitLab Runner 在本地测试 GitLab CI 作业。首先安装 GitLab Runner,安装命令如下:

Ubuntu/Debian(将 arch 替换为你的 Linux 内核架构):

curl -LJO \
"https://gitlab-runner-downloads.s3.amazonaws.com/latest/\
deb/gitlab-runner_${arch}.deb"
dpkg -i gitlab-runner_<arch>.deb

macOS:

brew install gitlab-runner

然后,我们可以通过运行下面命令来测试单个 job:

gitlab-runner exec shell test_code

它会在本地 shell 中运行 pytest。我们也可以在 GitLab UI 中触发 pipeline:进入项目主页的 CI/CD > Pipeline,在 Run pipeline 标签页点击 Run pipeline 按钮(见图 3.13)。

image.png

图 3.13 通过点击 Run pipeline 按钮运行 GitLab CI pipeline,并在 UI 中监控 pipeline 状态。

通过 GitLab CI,我们实现了自动化测试、构建并推送 Docker 镜像,以及更新 Helm chart。更新后的 Helm chart 将由我们的 CD 工具监控,并由它完成实际部署。

3.4.2 Argo CD

除了作为一个 CD 工具之外,Argo CD 还帮助确保:我们 Git 仓库中的代码与生产环境中运行的代码保持同步。这是通过持续监控 Git 仓库与已部署应用来实现的。如果我们向 Git 仓库推送了新的 commit,Argo CD 会确保这些变更被推送到生产环境;如果有人在生产环境里直接改动了应用,Argo CD 会去对照 Git 仓库,检查这些改动是否与仓库中的变更一致。如果不一致,改动会回滚到 Git 中的状态。对 Argo CD 来说,Git 里的代码是唯一的事实来源(single source of truth)(见图 3.14)。

image.png

图 3.14 Argo CD 确保 Git 中的 manifests 与 K8s 中已部署的 manifests 一致。

要搭建 Argo CD,请按附录 A 的 A.1.7 节提供的步骤操作。搭好之后,登录 Argo CD,点击 Settings > Repositories,添加我们的仓库,并确保连接方式使用 https。填入必要信息,包括用户名、密码等(见图 3.15)。

image.png

图 3.15 在 Argo 中通过 https 认证配置 Git 仓库。

我们的 CD 作业应该部署上一节 CI 作业更新过的 Helm chart。Argo CD 会拿到我们 Helm chart 在仓库中的位置,而这个 Helm chart 就作为它的事实来源。操作方法是:在首页点击,选择 Applications > New App,然后按以下步骤进行:

  • 输入 Application Name,并把 Project 设为 Default。
  • 输入 Repository URL。
  • 将 target Revision 设置为某个分支或 HEAD(默认)。
  • 注意 Path 指定的是仓库中 Helm chart 所在的位置。
  • 选择 Cluster URL 与 Namespace。如果 namespace 不存在就创建它(我们创建了一个叫 online-ml-svc 的 namespace)。
  • 点击 Create,等待一会儿,然后点击 Sync。应用同步完成后,你应该会看到类似图 3.16 的界面。

image.png

图 3.16 Argo CD 中的 hello-joker 应用。Status 显示为 Healthy 且 Synced。

我们用端口转发到 FastAPI service 来测试:

kubectl port-forward svc/fast-api-app -n online-ml-svc 8081:8080

在浏览器访问 localhost:8081,你应该会看到一个随机笑话:

{
  "random_joke": "Unix is user friendly. It's just very particular \
about who its friends are."
}

应用已经部署好后,我们试着把 service 类型从 ClusterIP 改为 LoadBalancer:

kubectl edit svc fast-api-app -n online-ml-svc

把 type 改为:

type: LoadBalancer

你现在会看到应用变成 out of sync。如果你点击 svc fast-api-app,再点击 diff,就能看到 live manifest 与 expected manifest 的差异在于 service 的 type。

因此我们可以看到:Argo CD 会持续跟踪已部署资源,并确保 Git 仓库中的 manifests 是唯一事实来源。如果我们启用 auto-sync,Argo CD 会自动与 Git 仓库的 manifests 保持同步。

通过 CI/CD 工具,我们实现了应用部署步骤的自动化,并确保生产环境中的应用只有一个事实来源:Git 仓库。GitLab CI 与 Argo CD 的文档分别提供了更多关于编写 CI 与 CD 作业的信息。

对于 ML 系统而言,监控尤其关键,因为我们不仅要跟踪应用健康状况,还要跟踪模型性能、数据质量与预测模式。虽然我们会在后续章节深入 ML 专属监控,但先从如何实现基础应用监控开始——这将作为更复杂 ML 监控系统的基础。

3.5 Prometheus 与 Grafana

任何部署到生产环境的应用都需要被监控。说到监控应用运行,日志(logging)是我们大多数人最熟悉的方式。日志在调试应用逻辑时非常有用。但如果我们想回答这样的问题怎么办:过去一小时我们的端点收到了多少请求?我们的请求中有多少比例的响应时间小于 2 秒?要回答这类问题,我们需要收集指标(metrics) 。指标用于跟踪应用性能,通常会在仪表盘中进行可视化展示。

如有需要,我们还可以设置告警(alerts)。Web 应用最常见的一些告警包括:应用是否存活(up/down)、95 分位响应时间是否超过阈值、某个服务的请求量是否跌破某个阈值等。你可以把指标看作日志的延伸:随着我们开发应用,我们会添加日志来监控某些条件;同样,我们也会添加指标来回答上一段提出的问题。

为帮助实现监控,我们可以使用 Prometheus——一个能“照亮”应用、提供更高可见性的监控工具。它有四个主要组成部分:

  • 一个时间序列数据库(time series database),指标按时间索引,便于查询
  • 一个负责抓取(scraping)指标并存入时间序列数据库的 worker
  • 一个用于可视化指标的 UI 组件
  • 一个 Alertmanager,用于把告警路由到不同渠道(邮件、推送通知等)

Prometheus UI 可以用来快速检查:Prometheus 是否正在从服务抓取指标;但如果我们要构建真正的仪表盘,它就不太够用。为此,我们需要另一个开源工具:Grafana。

Grafana 是一个主要用于跟踪应用指标的可视化看板工具。它能很好地与 Prometheus 以及许多其他数据源集成。Grafana 提供查询输入框;如果 Prometheus 是我们的数据源,我们就可以写查询,从 Prometheus 的时间序列数据库取数,并把结果画成美观的图表。

要让 Prometheus 能抓取指标,我们必须在应用中提供一个端点,供 Prometheus worker 抓取。Prometheus 是一个基于拉取(pull-based)的指标系统;我们会使用一些语言相关的包来定义所需指标。

我们要监控的应用必须提供类似 /metrics 这样的端点。Prometheus worker 抓取这些指标并存入时间序列数据库。通常我们会在 Grafana 中用一种基于 Prometheus 的查询语言 PromQL 来写查询,从而可视化这些数据。若要设置告警,我们需要修改 Alertmanager 的配置,通过它把告警路由到需要的渠道(例如邮件)(见图 3.17)。

image.png

图 3.17 应用 1 与应用 2 暴露指标端点;Prometheus 抓取这些指标并由 Grafana 可视化。Alertmanager 使用这些指标判断是否需要触发告警并路由到某个渠道(此处为邮件)。

我们可以使用 Helm charts 安装 Prometheus 与 Grafana。要安装二者,需要运行一些 Helm 命令,如清单所示。

清单 3.14 安装 Prometheus 与 Grafana

helm repo add prometheus-community \
https://prometheus-community.github.io/helm-charts  #1
helm upgrade -i prometheus prometheus-community/prometheus \
--namespace prometheus --create-namespace  #2
helm repo add grafana \
https://grafana.github.io/helm-charts  #3
helm repo update
helm install grafana grafana/grafana \
--namespace grafana \
--create-namespace \
--set persistence.enabled=true \
--set adminPassword='adminpassword' \
--set datasources."datasources.yaml".apiVersion=1 \
--set datasources."datasources.yaml".datasources[0].name=\
Prometheus \
--set datasources."datasources.yaml".datasources[0].type=\
prometheus \
--set datasources."datasources.yaml".datasources[0].url=\
"http://prometheus-server.prometheus.svc.cluster.local" \
--set datasources."datasources.yaml".datasources[0].access=\
proxy \
--set datasources."datasources.yaml".datasources[0].isDefault=\    #4
true     #4
#1 添加 Prometheus chart
#2 在 K8s 中部署 Prometheus
#3 添加 Grafana chart
#4 在 K8s 中部署 Grafana

安装 Grafana 时,我们同时配置了 Prometheus 数据源以及管理员密码(用于登录 Grafana)。现在 Grafana 已搭好,我们来扩展 FastAPI 的 hello-joker 应用:增加一个指标端点,让 Prometheus 能从中抓取指标;并在 Grafana 中创建一个简单图表来可视化某个指标。

我们使用一种叫 exporter 的工具,把基本指标(如响应时间与请求数量)暴露在 /metrics 端点上。对于 FastAPI,有一些预构建的 exporter,例如 starlette_exporter,可以配置后暴露基础指标。我们在应用中加入 Prometheus Middleware 提供的 Starlette Exporter,并准备 /metrics 端点来处理指标,如下所示。

清单 3.15 在 /metrics 暴露 Prometheus 指标

from fastapi import FastAPI
from starlette_exporter import PrometheusMiddleware, handle_metrics
import pyjokes

app = FastAPI()
app.add_middleware(PrometheusMiddleware)    #1
app.add_route("/metrics", handle_metrics)    #2

@app.get("/")
async def root():
    random_joke = pyjokes.get_joke("en","neutral")
    return {"random_joke": random_joke}
#1 定义 Prometheus Middleware
#2 定义指标端点

只加了两行代码,我们就为应用搭好了基础监控。你在本地运行这段代码时,可以访问 /metrics 端点,看到 Prometheus 指标名称以及对应数值。

starlette_requests_total 这个指标类型是 counter(计数器),本质上用于计数——在这里它统计请求数量。counter 的值只能增加。接着还有一种 gauge(仪表盘型)指标,它可以上升也可以下降,适合用于测量内存占用等。要测量响应时间的 95 分位,我们可以使用 histogram(直方图)类型的指标。histogram 本质上也是计数,但它会在预定义的 buckets(桶/区间)里计数,类似直方图。利用这些计数,我们就能报告关键的响应时间指标(见图 3.18)。

image.png

图 3.18 /metrics 端点上可用的 Prometheus 指标

我们甚至可以自定义指标,只需要使用 Prometheus 客户端库即可。例如,我们可以先定义一个 counter:

TEST_COUNTER = Counter("test","a simple test_counter")

然后在应用中调用 inc 方法增加计数:

TEST_COUNTER.inc()

定义好指标后,我们可以通过本地 /metrics 端点验证它们是否按预期工作。不过要在 Grafana 中可视化,就必须让 Prometheus 能抓到这些指标。为此,我们需要把应用 Docker 化并部署到 K8s 集群中——而我们已经在该集群里部署了 Prometheus 与 Grafana。

Prometheus 使用一种叫服务发现(service discovery)的机制来检查是否有新的应用可以抓取指标。默认情况下,我们需要在 pod 中指定三个 annotations:

annotations:
  prometheus.io/scrape: 'true'
  prometheus.io/path: '/metrics'
  prometheus.io/port: '80'

部署应用后,我们可以通过 Prometheus UI 检查 Prometheus 是否能抓取到这些应用指标:选择 Status > Targets(见图 3.19)。

image.png

图 3.19 Prometheus targets

在这里我们能看到 Prometheus 正在抓取指标。我们还可以在 UI 中尝试一个简单查询:把某个指标复制到 graph expression 输入框里(见图 3.20)。

image.png

图 3.20 在 UI 中编写 Prometheus 查询会返回按查询得到的指标值。这是检查是否正在抓取指标、以及指标值是否符合预期的简单方式。

我们应该能看到该指标以及它的值。接下来,我们在 Grafana 中把该指标画成时间序列图。为此,我们登录 Grafana,创建一个新 dashboard,然后创建一个空 panel。这里选择 time chart;默认应为 time series。要画出过去 10 分钟的请求数时间序列,我们可以写一条 PromQL 查询。我们用 sum by 按 HTTP 响应码分组,用 increase 计算指定时间窗口内时间序列的增量:

sum by (response_code) (
  increase(
    starlette_requests_total{
      app_name="starlette",
      method="GET",
      path="/"
    }[10m]
  )
)

注意:更多 PromQL 函数见 mng.bz/X7OY。 该图表大致会像图 3.21 那样。

image.png

图 3.21 Grafana 的时间序列图展示了过去 10 分钟的请求数。这里仅显示一条线,因为所有响应都只有一种 HTTP 状态码。

现在我们已经能够把指标存入 Prometheus,并在 Grafana 中可视化。Prometheus 文档(prometheus.io/docs/introd…)包含 PromQL 示例,也介绍了在需要时如何通过 Prometheus Pushgateway 让批处理任务推送指标。Grafana 文档则更深入说明如何构建仪表盘,以及可创建的不同图表类型。Grafana 也可以对接 Prometheus 之外的其他数据源(grafana.com/docs/grafan…)。

在本章中,我们为规模化部署与管理应用打下了工具与实践基础。尽管 Docker、K8s、CI/CD 与监控看起来与 ML 日常工作有些距离,但它们是把一个胜任的 ML 工程师与数据科学家区分开的关键技能。随着本书推进,你会看到这些基本功如何支撑我们构建复杂的 ML 平台,并部署能够在生产中可靠提供预测服务的 ML 系统。接下来的章节会在这些概念之上直接继续,深入 ML 专属工具与实践;但容器化、编排、自动化与监控这些原则,仍将始终处于我们工作的中心。

总结

  • 在 ML 平台工作中,具备一些处理自动化、部署与监控的 DevOps 工具能力非常重要。
  • Docker 可用于把应用容器化,从而能部署到任意环境。
  • Kubernetes(K8s)作为容器编排器,帮助在生产环境管理容器。
  • 使用 CI/CD 自动化容器构建与部署可减少人工错误、加速开发周期,并确保变更被一致地测试与发布。
  • 监控应用有助于维持生产环境应用的可靠性与性能。
  • 工具是手段而非目的。工具的有效性取决于它们被如何选择、集成与使用,以支撑组织更大的目标与愿景。