Skip to content

필사 모드: 用 Rust 编写 Kubernetes Operator(kube-rs)

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 把运维知识写进代码

Kubernetes 刚入门时,我们用 Deployment、Service、ConfigMap 这些基础资源部署应用。但在真正的运维中,需要的远不止这些。要把一个数据库真正运维好,需要设置备份、在故障发生时进行故障转移(failover)、迁移 schema、并在存储写满时进行扩容。这类操作步骤,通常只存在于某个人的脑子里,也就是 runbook。

Operator 正是把这份运维知识搬进代码。就像人盯着集群反复琢磨"现在的状态是 A,但应该是 B,所以要这样修"一样,Operator 用一个控制器程序自动做同样的事。2016 年 CoreOS 确立了这个概念,如今 Prometheus、Cert-Manager,以及各种数据库,都由 Operator 来管理。

本文梳理 Operator 模式的原理,并讲解如何用 Rust 生态的 kube-rs 构建真正的 Operator。同时也会谈谈,为什么 Rust 在这个领域是一个有吸引力的选择。

Operator 模式 — 三个组成部分

Operator 在概念上由三个部分组成。

  • CRD(Custom Resource Definition,自定义资源定义): 向 Kubernetes 注册一种新资源类型的 schema。如果说内置资源是 Pod、Service,那么 CRD 就能让我们把自己的资源(例如 Database、Backup)添加到 API 服务器上。注册好 CRD 之后,用户就能用 YAML 声明这种资源,并像 kubectl get database 这样操作它。
  • 自定义资源(CR): 按 CRD 定义的 schema 实际创建出的实例。它承载了用户的意图(desired state,期望状态),比如"名字是 my-db,副本数是 3,存储是 10Gi"。
  • 控制器(controller): 监视自定义资源,把实际状态(actual state)调整到期望状态的程序。这个调整过程是核心,被称为 reconcile(调谐)。

这套结构的底层,贯穿着 Kubernetes 整体的一种思维方式,就是声明式(declarative)控制。用户只声明"想要什么",而"如何到达那里"由控制器负责。

reconcile 循环 — Operator 的心脏

控制器的核心是 reconcile 循环。它的运行原理简单得惊人。

  1. 读取期望状态(spec)
  2. 观察实际状态
  3. 计算两者的差异
  4. 把实际状态朝期望状态推进一步
  5. 回到第 1 步(或在一段时间后重新执行)

这里有两个重要原则。

第一,reconcile 必须幂等(idempotent)。不管用相同的输入执行多少次,结果都必须一样。reconcile 函数不该写成"创建它",而要写成"确保达到这个状态"。如果已经是期望状态,就什么都不做;如果还有缺口,就补上,仅此而已。

第二,reconcile 是基于状态(level-triggered)的,而不是基于事件(edge-triggered)的。它看的是"现在的状态是什么(level)",而不是"刚刚发生了什么事件(edge)"。正因如此,即便漏掉几个事件、控制器重启,最终也只需要看当前状态就能正确收敛。Operator 的健壮性正来自这一点。

kube-rs — 用 Rust 操作 Kubernetes

kube-rs 是用 Rust 编写 Kubernetes 客户端和控制器的事实标准 crate。大致由三部分组成。

  • kube::Client — 与 API 服务器通信的客户端
  • kube::Api — 对特定资源类型的类型安全访问(get、list、patch 等)
  • kube::runtime — 编写控制器所需的上层工具,例如 Controllerwatcherreflector

依赖大致这样配置。具体版本请以 crates.io 上的最新版为准。

[dependencies]
kube = { version = "0.99", features = ["runtime", "derive", "client"] }
k8s-openapi = { version = "0.24", features = ["latest"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
tokio = { version = "1", features = ["full"] }
thiserror = "2"
futures = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"

k8s-openapi 把 Pod、Deployment 这类内置资源类型提供为 Rust 结构体,schemars 则用来自动生成 CRD 的 schema(JSON Schema)。

CustomResource 派生 — 用代码定义 CRD

kube-rs 最优雅的部分,是把 CRD 定义为一个 Rust 结构体。只要加上 CustomResource 派生宏,就能从一个 spec 结构体生成完整的自定义资源类型和 CRD 定义。

use kube::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// 我们要管理的应用的「期望状态」
#[derive(CustomResource, Debug, Clone, Deserialize, Serialize, JsonSchema)]
#[kube(
    group = "example.com",
    version = "v1",
    kind = "WebApp",
    namespaced,
    status = "WebAppStatus",
    shortname = "wa"
)]
pub struct WebAppSpec {
    /// 要运行的容器镜像
    pub image: String,
    /// 期望的副本数
    pub replicas: i32,
}

/// 控制器填入的「观测到的状态」
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct WebAppStatus {
    pub available_replicas: i32,
    pub ready: bool,
}

这一个宏做了好几件事。它会生成一个叫 WebApp 的类型(把 WebAppSpec 包在 spec 字段里),调用 WebApp::crd() 就能得到要注册到集群的 CRD manifest。status 子资源与 spec 分离,因此控制器更新状态时不会与用户的 spec 冲突。

把生成的 CRD 导出为 YAML,大致是这个样子。里面包含花括号和尖括号这类 schema 表示法,所以必须放在代码块里,而不能写在正文中。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: webapps.example.com
spec:
  group: example.com
  names:
    kind: WebApp
    plural: webapps
    shortNames: ["wa"]
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image: { type: string }
                replicas: { type: integer }

用户现在就可以这样声明一个自定义资源。

apiVersion: example.com/v1
kind: WebApp
metadata:
  name: hello
  namespace: default
spec:
  image: nginx:1.27
  replicas: 3

reconcile 函数 — 核心逻辑

接下来编写控制器的心脏——reconcile 函数。kube-rs 的 Controller 会在被监视的资源发生变化时(或定期地)调用这个函数。它的签名是:"接收一个自定义资源和一个共享上下文,返回下次什么时候再被调用(Action)"。

use std::sync::Arc;
use std::time::Duration;
use k8s_openapi::api::apps::v1::Deployment;
use kube::api::{Api, Patch, PatchParams};
use kube::runtime::controller::Action;
use kube::{Client, ResourceExt};

pub struct Context {
    pub client: Client,
}

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let name = obj.name_any();
    let deployments: Api<Deployment> = Api::namespaced(ctx.client.clone(), &ns);

    // 构建一个符合期望状态的 Deployment
    let desired = build_deployment(&obj)?;

    // 用 server-side apply 来「确保达到这个状态」(幂等)
    let pp = PatchParams::apply("webapp-operator").force();
    deployments
        .patch(&name, &pp, &Patch::Apply(&desired))
        .await?;

    tracing::info!(%ns, %name, "reconciled WebApp");

    // 成功后,5 分钟后再周期性检查一次
    Ok(Action::requeue(Duration::from_secs(300)))
}

关键在于使用了 Patch::Apply(server-side apply)。它表达的不是"创建它",而是"让结果和这份 manifest 一致",所以调用多少次都是安全的。这正是前面所说幂等性的具体实现。

build_deployment 是一个纯函数,用 spec 的 imagereplicas 构造标准的 Deployment 结构体。只需要填充 k8s-openapi 提供的类型即可。

错误处理与 requeue — 指数退避

reconcile 可能会失败。API 服务器可能暂时无响应,可能与其他控制器发生冲突,也可能出现瞬时网络错误。Rust 的 Result 和 kube-rs 的 Action 能优雅地处理这些情况。

首先用 thiserror 定义错误类型。

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Kube API error: {0}")]
    Kube(#[from] kube::Error),

    #[error("Missing object key")]
    MissingKey,
}

然后编写 error_policy,决定 reconcile 失败时如何重试。这里可以表达指数退避的第一步。

fn error_policy(_obj: Arc<WebApp>, err: &Error, _ctx: Arc<Context>) -> Action {
    tracing::warn!("reconcile failed: {err}, retrying");
    // 失败后先短暂等待,再重试
    Action::requeue(Duration::from_secs(10))
}

Action 提供三种选择。

  • Action::requeue(duration) — 在这段时间后再次 reconcile(周期性复查或重试)。
  • Action::await_change() — 等到资源真正发生变化为止(没有事可做时)。
  • 成功后如果给 requeue 一个较长的间隔,即便没有事件,也能周期性地检查状态(漂移检测)。

reconcile 一旦返回 ErrController 就会自动调用 error_policy,并在返回的间隔之后重试。这套组合把失败自然地吸收进了重试机制。

Finalizer — 清理逻辑的安全阀

自定义资源被删除时,往往还需要一并清理相关的外部资源(云端负载均衡器、外部数据库、对象存储 bucket 等等)。问题在于,资源一旦被删除,控制器就再也看不到它的 spec 了。解决这个问题的机制就是 finalizer

finalizer 是挂在对象元数据上的一个字符串列表。只要这个列表不为空,Kubernetes 就不会真正删除对象,只会打上一个 deletionTimestamp。也就是说,对象进入了「预约删除」的状态。只有控制器完成清理工作、移除 finalizer 之后,对象才会真正消失。这就为执行清理逻辑争取到了时间。

kube-rs 用 finalizer 辅助函数封装了这个模式,让创建/更新与删除分成两条分支来处理。

use kube::runtime::finalizer::{finalizer, Event as FinalizerEvent};

async fn reconcile(obj: Arc<WebApp>, ctx: Arc<Context>) -> Result<Action, Error> {
    let ns = obj.namespace().unwrap_or_default();
    let api: Api<WebApp> = Api::namespaced(ctx.client.clone(), &ns);

    finalizer(&api, "webapp.example.com/cleanup", obj, |event| async {
        match event {
            // 创建或更新时: 正常 reconcile
            FinalizerEvent::Apply(app) => apply(app, ctx.clone()).await,
            // 删除时: 先清理外部资源,再移除 finalizer
            FinalizerEvent::Cleanup(app) => cleanup(app, ctx.clone()).await,
        }
    })
    .await
    .map_err(|_| Error::MissingKey)
}

这个辅助函数会自动处理 finalizer 的添加与移除,我们只需要填好「应用时该做什么」和「删除时该清理什么」。只有 Cleanup 分支成功执行完,finalizer 才会被移除,对象才会真正被删除。如果清理失败,finalizer 就会留下来,对象也随之留下,不会造成资源泄漏。

组装 Controller — main 函数

最后,把这一切用 Controller 串起来运行。Controller 会同时监视目标资源(我们的 WebApp),以及这个 Operator 所拥有(own)的子资源(这里是 Deployment)。即便子资源发生变化(有人动了 Deployment),reconcile 也会被重新触发。这正是自愈(self-healing)的来源。

use futures::StreamExt;
use kube::runtime::watcher::Config;
use kube::runtime::Controller;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();
    let client = Client::try_default().await?;

    let webapps: Api<WebApp> = Api::all(client.clone());
    let deployments: Api<Deployment> = Api::all(client.clone());
    let ctx = Arc::new(Context { client });

    Controller::new(webapps, Config::default())
        .owns(deployments, Config::default())
        .run(reconcile, error_policy, ctx)
        .for_each(|res| async move {
            match res {
                Ok((obj, _action)) => tracing::info!("reconciled {:?}", obj.name),
                Err(e) => tracing::warn!("reconcile error: {e}"),
            }
        })
        .await;

    Ok(())
}

Controller 在内部通过 watcher 和 reflector 维护目标资源的缓存,相关事件到来时,会把对应对象放入工作队列并调用 reconcile。即便大量事件同时涌来,同一个对象的 reconcile 也会被串行化,重复的会被合并,因此我们几乎不用操心并发问题。

为什么选 Rust 而不是 Go/kubebuilder

Kubernetes 本身是用 Go 写的,主流的 Operator 框架(controller-runtime、kubebuilder、Operator SDK)也都基于 Go。在生态成熟度和示例数量上,Go 依然占绝对优势。即便如此,Rust 仍然有它的吸引力。

  • 更小的资源占用。 Operator 是常驻在集群里持续运行的进程。Rust 二进制没有垃圾回收器,内存占用小且稳定,容器镜像也能靠静态链接压缩到几 MB。如果要把 Operator 部署到成百上千个集群,这些节省会不断累积。没有 GC 造成的间歇性停顿(stop-the-world),这一点对延迟敏感的控制器也很有利。
  • 内存安全与强类型。 reconcile 逻辑要处理多种资源的状态,是相当微妙的代码。Rust 的所有权模型和基于 Result 的错误处理,能在编译期就大量捕获空引用、数据竞争、未处理的错误。「编译通过,基本就能跑」这种感觉,在运维代码里格外可贵。
  • 用富有表现力的类型对状态建模。 Rust 的枚举(enum)和模式匹配非常适合把资源的状态迁移(例如 Pending → Provisioning → Ready → Failed)精确地表达为类型。可以让不合法的状态组合从一开始就无法被表示出来。

当然,这也有代价。学习曲线陡峭,编译时间长,示例也不如 Go 丰富。如果团队已经熟悉 Go,需要快速批量产出 Operator,kubebuilder 是务实的选择。反过来,如果看重 Operator 的效率和健壮性,尤其是在资源受限的边缘环境或大规模多集群场景下,Rust 与 kube-rs 就是一个不错的选择。

动手试一试

Operator 终究运行在 Kubernetes 的网络、调度和资源模型之上。把这些基础打牢,会让 Operator 到底在调谐什么这件事变得清晰得多。如果想亲自试验一下集群内 Pod、Service、网络策略是如何连接起来的,可以在本站的Kubernetes 网络实验室里可视化地摸索一番。而且 Operator 部署的终究是容器,如果好奇容器是怎么实现隔离和资源限制的,也可以在容器实验室里看看其中的原理。

结语

Operator 是把运维知识写进代码的控制器,其心脏是一个幂等的 reconcile 循环,不断缩小期望状态与实际状态之间的差距。通过 CRD 向 Kubernetes 注册一种新资源类型,控制器监视它,把世界维持在我们想要的样子。

Rust 生态的 kube-rs 对这套模式的支持相当顺滑。用 CustomResource 派生宏把 CRD 定义为类型,用 Api 类型安全地操作资源,用 Controller 组装 watcher 和工作队列,用 Action 表达 requeue 和退避,用 finalizer 辅助函数安全地处理删除时的清理逻辑。

选择 Rust,归根结底是因为它和 Operator 这类工作负载的性质相契合。常驻集群、持续运行的进程,需要小的资源占用和可预测的性能;微妙的状态调谐逻辑,需要强类型和内存安全。如果想构建健壮的 Operator,Rust 与 kube-rs 是一组值得认真考虑的组合。

参考资料

현재 단락 (1/195)

Kubernetes 刚入门时,我们用 Deployment、Service、ConfigMap 这些基础资源部署应用。但在真正的运维中,需要的远不止这些。要把一个数据库真正运维好,需要设置备份、在故...

작성 글자: 0원문 글자: 8,920작성 단락: 0/195