- 引言 — 把运维知识写进代码
- Operator 模式 — 三个组成部分
- reconcile 循环 — Operator 的心脏
- kube-rs — 用 Rust 操作 Kubernetes
- CustomResource 派生 — 用代码定义 CRD
- reconcile 函数 — 核心逻辑
- 错误处理与 requeue — 指数退避
- Finalizer — 清理逻辑的安全阀
- 组装 Controller — main 函数
- 为什么选 Rust 而不是 Go/kubebuilder
- 动手试一试
- 结语
- 参考资料
引言 — 把运维知识写进代码
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— 编写控制器所需的上层工具,例如Controller、watcher、reflector
依赖大致这样配置。具体版本请以 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 的 image 和 replicas 构造标准的 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 一旦返回 Err,Controller 就会自动调用 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 是一组值得认真考虑的组合。
参考资料
- kube-rs 官方网站: https://kube.rs/
- kube-rs GitHub: https://github.com/kube-rs/kube
- Kubernetes — Operator 模式: https://kubernetes.io/docs/concepts/extend-kubernetes/operator/
- Kubernetes — Custom Resources: https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/
- Kubernetes — Finalizers: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/
- k8s-openapi crate: https://docs.rs/k8s-openapi/
- 本站的 Kubernetes 网络实验室: /tools/k8s-network-lab
- 本站的容器实验室: /tools/container-lab
현재 단락 (1/195)
Kubernetes 刚入门时,我们用 Deployment、Service、ConfigMap 这些基础资源部署应用。但在真正的运维中,需要的远不止这些。要把一个数据库真正运维好,需要设置备份、在故...