Yoke:真正用代码定义基础设施(Infrastructure as code)
Xe Blog Contact Resume Talks Xecast Signalboost
Yoke 真的很酷
发布于 2025/03/02,3827 字,阅读时长 14 分钟
真正用代码定义基础设施(Infrastructure as code)
对开发者友好的安全,终于来了。不再有警报疲劳,只有真正的保护。Aikido.dev
由 EthicalAds 提供的广告
关闭广告
一群海豹在加利福尼亚州圣克鲁斯附近的海里游泳 - Xe Iaso 拍摄,Canon EOS R6 mk II, Helios 44-2 58mm f/2
站点可靠性中最大的梗之一是“Infrastructure as code”。这通常是出于好意,但有一个小问题:
data "aws_route53_zone" "cetacean_club" {
name = "cetacean.club."
}
resource "aws_route53_record" "A" {
zone_id = data.aws_route53_zone.cetacean_club.zone_id
name = "ingressd.${data.aws_route53_zone.cetacean_club.name}"
type = "A"
ttl = "300"
records = [resource.vultr_instance.my_instance.main_ip]
}
这不是代码。这是配置。当然,你使用管理代码的相同工具来管理配置,你可以像代码一样对它进行 linting,但它不是代码。它是一个相当有限的 DSL,可以轻松启动并运行基础设施。假设你创建了一个新服务器,并且想要将其添加到 DNS。你必须声明实例,然后使用来自实例的数据声明 DNS 记录。
Cadey
如果你真的认为 Terraform 是代码,那么请尝试基于动态数量的实例,为每个随机实例 ID 创建多个 DNS 记录。如果我错了请纠正我,但我认为你无法在 Terraform 中做到这一点。
如果事情更灵活一点会怎样?如果你可以创建一个通用的 "dns_for_instance" 方法,然后在任何地方使用它会怎样?
这就是 Pulumi 背后的基本思想。与其使用配置文件管理基础设施,不如用代码管理它。你可以创建可以在项目之间共享的辅助函数,并且可以使用任何编程语言的全部功能来管理你的基础设施。
但是,Pulumi 有一些缺点:
- 你必须安装你正在使用的语言的运行时和依赖项
- 代码必须在管理基础设施的服务器上运行
这听起来起初很合理,但是当你震惊地意识到在主机上运行的代码可以做它想做的任何事情时,你就会发现问题所在。这意味着如果一个依赖项崩溃,你的基础设施现在就会受到攻击,并且很可能在其上运行着加密货币矿工。 这就是 Yoke 的用武之地。
Yoke:真正用代码定义基础设施(Infrastructure as code)
Yoke 是一个将这个基本思想提升到新高度的项目。 使用 Yoke,你可以用 Go 或 Rust 编写你的基础设施定义,将其编译为 WebAssembly,然后获取输入和输出 Kubernetes manifest,这些 manifest 将应用于集群。
Aoi
等等,这里有些东西我没明白。为什么要将代码编译为 WebAssembly,而不是直接在服务器上运行它?
Numa
嗯,一切都是权衡。让我们想象一个你直接在服务器上运行代码的世界。
如果你使用像 Python 这样的语言,你需要安装 Python 运行时和任何依赖项。这意味着你必须承受 pip 的臭名昭著的愤怒(pip 地狱是一个真实的地方,你会在不知不觉中去那里)。如果你使用像 Go 这样的语言,你需要安装 Go 编译器工具链,或者为每个你想在其上运行基础设施的 CPU 架构和 OS 排列预构建二进制文件。这无法很好地扩展。
在此处使用 WebAssembly 的主要优势之一是,你可以将代码编译一次,然后在任何具有 WebAssembly 运行时的地方运行它,例如使用
yoke
CLI 或 Air Traffic Controller。这意味着你可以在 Windows、Linux、macOS 上,甚至在你 aarch64 MacBook 的 VM 中执行基础设施应用,而无需注意或关心。
这种方法的主要缺点之一是,WebAssembly 二进制文件不容易让用户内省,这意味着你必须执行代码才能查看其作用。 WebAssembly 是一个难以穿透的沙箱层,并且 Yoke 不会向主机公开任何系统调用,但这再次是在将基础设施建模为实际代码和内省已发布二进制文件的能力之间进行权衡。
想象一下,如果有人发布了一个恶意依赖项,该依赖项以某种方式渗透到你的基础设施代码中。如果你直接在你的笔记本电脑或服务器上运行代码,那么基本上没有真正的方法可以轻松地对代码进行沙箱化;这意味着它可以窃取你的 Bitcoin 钱包,泄露你的 SSH 密钥,或者做任何它想做的事情。现代操作系统是通用的,并且会_完全_按照它们的指示执行。如果你在 WebAssembly 沙箱中运行代码,你可以确保它不会对你的系统做任何恶意的事情,因为它实际上无法访问沙箱之外的任何内容。
我想攻击者可能会创建一个渗透上去的依赖项,并导致 Yoke flight 在你的集群中创建一个加密货币矿工或类似的东西,但在此过程中,它可能会破坏很多其他东西,并且这将是一个非常明显的攻击。
我认为这种权衡是值得的,即使它可能会限制用户之间共享 flight 的能力。
将 Yoke flight 视为函数。它们接受输入并输出 Kubernetes 资源。此处使用 WebAssembly 的一大优势是,你可以使用 Kubernetes 本身使用的相同 Kubernetes manifest 类型。这意味着你无需编写自己的类型,并且可以积极地重用代码。这是一个创建 Kubernetes ServiceAccount 的代码示例:
Cadey
在本文中,KubernetesTerms 将采用 JavaClassNameCase。如果你不确定其中一个是什么,请在 DuckDuckGo 中搜索它:
site:kubernetes.io KubernetesTerm
像 App CustomResourceDefinition 这样的其他内容特定于我的设置,你不会在 Kubernetes 文档中找到它们。
func createServiceAccount(app v1.App) *corev1.ServiceAccount {
return &corev1.ServiceAccount{
TypeMeta: metav1.TypeMeta{
APIVersion: corev1.SchemeGroupVersion.Identifier(),
Kind: "ServiceAccount",
},
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
Labels: app.Labels,
},
AutomountServiceAccountToken: ptr.To(true),
}
}
这与以下 Helm 模板大致相同:
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "simpleapp.serviceAccountName" . }}
labels:
{{- include "simpleapp.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}
请注意此处的差异:
- Go 代码接受变量并直接在结构中替换值
- Helm 模板使用 text/template 替换 YAML 中的值,并且为了使 YAML 有效,你必须将值传递给
nindent
函数以确保 YAML 正确缩进 - Go 代码由 Go 编译器进行类型检查
- Helm 模板在应用到集群之前不会被任何东西进行类型检查,此时可能为时已晚
诚然,这是一个经过精心设计的简单示例,但是你可以看到这种情况如何快速失控。相比之下,Go 代码看起来很糟糕,因为所有类型名称都很冗长,但是它经过了完全的类型检查,并且你可以确保它在运行时可以工作,因为编译器会拒绝明显无效的代码。
Cadey
请注意,类型检查与语义正确性不同。 Go 编译器可以确保你将字符串放在类型想要字符串的位置,但是如果你使某些东西在语义上无效,它无法阻止你。例如,你可以创建一个与同一命名空间中另一个 ServiceAccount 同名的 ServiceAccount,这会在你尝试将 manifest 应用到集群时导致冲突。
Yoke 很酷,但在较高层次上,它实际上只是一种稍微不方便的方式来编写 manifest,乍一看使 Helm 看起来更容易。但是,他们并没有就此止步。他们引入了一项功能,坦率地说,这让我完全放弃了 Helm:Air Traffic Control。
Air Traffic Control
Air Traffic Control 是一个 Kubernetes operator,可让你将基础设施定义为 CustomResourceDefinitions。 CustomResource 中的数据会传递到你与之关联的 Flight 中,并且 Flight 会生成应用于集群的 manifest。
这部分真正将 Yoke 从“这很棒,但我不知道在哪里可以使用它”转变为“这是将使 Yoke 在我的工作流程中不可或缺的唯一事物”。
Air Traffic Control 和 Helm 等其他工具之间的关键区别在于,Helm 主要在 Kubernetes 集群的一侧运行。你运行 Helm 以生成应用于集群的 manifest,但是从集群内部来看,实际上无法了解 Helm 所做的事情。当然,有一些像 k3s 的 HelmChart 资源之类的东西可以让你声明性地定义 Helm chart,但这真的与拥有作为集群本机部分的东西不同。
这解决的最大问题是编辑器支持理解你的 CustomResources(实际上其功能类似于 Helm values.yaml
文件)。使用我在 VSCode 副本中使用的 Kubernetes 扩展,它会自动导入 CustomResource 类型的 OpenAPI 规范,因此我可以免费获得编辑器的语法突出显示、文档和自动完成功能。
Mara
-Wpedantic:可以使用 Helm 插件(例如 Helm Intellisense)和 定义 values json schema 来实现这一点,但是该过程需要手动干预和维护。 Air Traffic Control 会在你定义 CustomResourceDefinition 后立即自动执行此操作。
为了真正理解何时以及何时可以使用它,让我们讨论一下我是如何使用 Air Traffic Control 使部署东西到我的集群比我在任何工作中所部署的东西都更容易。
我的 App CustomResourceDefinition
当我将自己的应用程序部署到 Kubernetes 时,我通常遵循一些运行方式的常见“形状”:
- 一个内部 Web 应用程序,通过 Service 公开给集群
- 一个外部 Web 应用程序,通过 Ingress 公开给互联网
- 一个不需要公开的 worker 应用程序
- 一个作为 Tor 隐藏服务公开的 Web 应用程序
我还通常需要一些常见的配置位:
- 通过 PersistentVolumeClaim 进行持久存储(通常指向 Longhorn 或 [Tigris](https://xeiaso.net/blog/2025/yoke-k8s/https:/tigrisdata.com))
- pod 副本的数量
- 应用程序是否应自动更新
- 应用程序侦听的端口
- 应用程序是否应以 root 身份运行
- 应用程序的运行状况检查路由
- 应用程序的日志级别
- 应用程序的任意环境变量
- 应用程序的任何 Kubernetes 角色权限
- 来自 1Password 的密码,通过 1Password operator
我一直在开发 simpleapp
chart 以编码许多这些常见模式,但是使用起来相当烦人,因为最终我最终与 Helm 的模板系统作斗争,而不是利用它来发挥我的优势。
在某种程度上,我实际上只是在进行从一种格式(一组简短的配置标志)到另一种格式(一组 Kubernetes manifest)的纯数据转换。这就是我认为 Yoke 真正可以提供帮助的地方。
所以我做到了。这是一个来自为 sticker server 供电的 manifest 的示例:
apiVersion: x.within.website/v1
kind: App
metadata:
name: stickers
spec:
image: ghcr.io/xe/x/stickers:latest
autoUpdate: true
healthcheck:
enabled: true
ingress:
enabled: true
host: stickers.xeiaso.net
secrets:
- name: tigris-creds
itemPath: "vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/kvc2jqoyriem75ny4mvm6keguy"
environment: true
就是这样。其他一切都由 Yoke 环境创建和部署。这是它创建的所有资源:
tigris-creds
密码的 OnePasswordItem- 包含 Tigris 凭据以预签名贴纸 URL 的
tigris-creds
密码 - sticker server 的 Deployment,侦听端口 3000(除非你使用
spec.port
字段覆盖它) - sticker server 的 Service,将流量从 Service 端口 80 转发到 Deployment 的端口 3000
- 指向 Service 端口 80 的 Ingress,主机名为
stickers.xeiaso.net
- 用于自动续订的
stickers.xeiaso.net
的 cert-manager 证书 - 指向 Ingress 的
stickers.xeiaso.net
的 DNS 记录
与以前的状态相比,这是一个巨大的改进。我删除了超过 150 行 YAML 代码(让我们说实话,我是从同一存储库中的另一个 manifest 中复制粘贴的),并用一个确定性程序代替了它,该程序只是按照我的意愿执行操作。 App CustomResource 的大多数功能默认情况下是关闭的,但这是一个一次显示所有功能的示例: 单击以展开``` apiVersion: x.within.website/v1 kind: App metadata: name: maximum-settings spec: autoUpdate: true # 如果为 true,则设置 Keel 以自动更新映像 image: ghcr.io/xe/x/stickers:latest # 要运行的映像 logLevel: info # 应用程序的日志级别,特定于我的应用程序 replicas: 3 # 要运行的副本数,默认为 1 port: 3000 # 应用程序侦听的端口,默认为 3000,设置 PORT 和 BIND runAsRoot: false # 如果为 true,则以 root 身份运行应用程序,默认为 false env: # 要设置的任意环境变量,与 Deployment 中的 env 相同
- name: FOO value: bar
- name: BAZ value: qux healthcheck: # 应用程序的运行状况检查配置,默认为应用程序端口上的 / enabled: true path: / port: 3000 ingress: # 应用程序的 Ingress 配置,默认为关闭 enabled: true host: maximum-settings.xeiaso.net # Ingress 要使用的主机名 clusterIssuer: letsencrypt-prod # Ingress 要使用的 cert-manager ClusterIssuer className: nginx # Ingress 要使用的 Ingress 类,默认为 nginx onion: # 应用程序的 Tor 隐藏服务配置,默认为关闭 enabled: true nonAnonymous: true # 如果为 true,则创建非匿名 OnionService,默认为 false haproxy: true # 如果为 true,则配置 Tor 以在 haproxy 格式中公开隐藏服务电路 ID,默认为 false proofOfWorkDefense: false # 如果为 true,则配置 Tor 以要求对隐藏服务连接进行工作量证明,默认为 false storage: # 为此应用程序配置 PersistentVolumeClaim enabled: true path: /data # 要将持久存储挂载到的路径 size: 10Gi # 持久存储的大小 storageClass: longhorn # 用于 PersistentVolumeClaim 的存储类 role: # 此应用程序的 Kubernetes 角色配置 enabled: true rules: # 应用于角色的规则
- apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] secrets: # 要注入到应用程序中的 1Password 密码
- name: tigris-creds itemPath: "vaults/Kubernetes/items/stickers tigris creds" environment: true # 如果为 true,则将密码值作为环境变量注入
- name: another-secret itemPath: "vaults/Kubernetes/items/another secret" folder: true # 如果为 true,则将密码值注入到 /run/secrets/another-secret

[Numa](https://xeiaso.net/blog/2025/yoke-k8s/</characters#numa>)
在实践中,大多数 Helm `values.yaml` 文件最终变得如此复杂,其中包含大量 `if` 语句来处理所有不同的配置排列。
这看起来像是大量的配置,但我发现实际上对于任何给定的应用程序,我只需要这些选项中的一小部分。通常,我最终只需要以下内容:
* 如果我希望应用程序自动更新,则为 `autoUpdate`
* 要运行的映像的 `image`(尽管我可能会根据应用程序的名称默认设置此值)
* `replicas` 以定义我要运行的应用程序实例数
* `env` 设置环境变量
* `healthcheck` 定义运行状况检查路由
* `ingress` 将应用程序公开到互联网
* `secrets` 从 1Password 注入密码
其余的都在那里,以备我需要时使用,但默认情况下是关闭的,因此事情很简单。这意味着在我的一个集群上创建一个新应用程序可以归结为以下步骤:
* 编写代码
* 构建/推送 docker 映像
* 编写 App manifest
* `kubectl apply -f app.yaml`
* 与朋友分享 URL
现在可以肯定的是,我可以使用 [operator-sdk](https://xeiaso.net/blog/2025/yoke-k8s/<https:/sdk.operatorframework.io/>) 或 [kubebuilder](https://xeiaso.net/blog/2025/yoke-k8s/<https:/book.kubebuilder.io/>) 来创建我自己的 operator 来执行此操作,但这对于我的需求来说太过分了。 Yoke 的 Air Traffic Control 让我可以将一个简单的“生成 manifest”程序变成一个 Kubernetes operator,只需几个小时的工作即可完成。
如果你想查看我的 App 资源,你可以在 [GitHub 上找到它](https://xeiaso.net/blog/2025/yoke-k8s/<https:/github.com/Xe/yoke-stuff/tree/main/within-website-app>)。它远未准备好用于生产环境或供其他人使用,但我认为这是一个很好的例子,说明如何使用 Yoke 减少必须为基础设施编写的样板代码量。
## 安全
在本文的早期,我提到了 Yoke 使用 WebAssembly 作为沙箱化生成 manifest 的代码的一种方式。当上周五我 [直播我的第一反应](https://xeiaso.net/blog/2025/yoke-k8s/<https:/www.twitch.tv/videos/2393595012>) 时,我在聊天中得到的最常见的问题之一是“你如何知道你正在运行的代码不是恶意的?”
因此,让我们看一下 Yoke 的安全模型。 Yoke flight 在 [Wazero](https://xeiaso.net/blog/2025/yoke-k8s/<https:/wazero.io>) 中运行,Wazero 是一个用于 Go 程序的 WebAssembly 运行时。自从 Wazero 发布以来,我一直在广泛地使用它,并且我喜欢它。 Yoke flight 还以 [WASI](https://xeiaso.net/blog/2025/yoke-k8s/<https:/wasi.dev/>)(WebAssembly 系统接口,WebAssembly 的 POSIX)为目标。
WASI 通常具有以下限制:
* WASI 无法打开传出的网络连接(在我看来,这有点痛苦,因为它限制了我的可用性,但这是一个很棒的安全功能)
* 默认情况下,WASI 程序未分配文件系统权限
* WASI 程序无法访问主机的环境变量
* 如果你将文件系统挂载到 WASI 程序中,则它只能访问该文件系统(无论是主机文件系统中的 chroot 还是代码中的用户定义的文件系统)
* 如果你将预先打开的套接字传递给 WASI 程序,则它只能访问该套接字(这确实允许 WASI 程序侦听端口以服务 HTTP)
Yoke flight 在 Wazero 中运行,没有文件系统访问权限,也没有套接字传递给它们。以下是 Yoke flight 与外界交互的方式:
* 到 `yoke takeoff` 命令的标准输入
* 来自 flight 的标准输出
* 来自 flight 的标准错误
* 到 `yoke takeoff` 命令的命令行标志
* 选择加入 [集群访问](https://xeiaso.net/blog/2025/yoke-k8s/<https:/yokecd.github.io/docs/concepts/cluster-access/>),允许 flight 查找集群中的资源
最后一点听起来可能很可怕,但实际上它比你想象的要有限得多:Yoke flight 只能访问由该 flight 管理的资源。例如,假设一个 flight 在 Secret 中创建一个密码。集群访问允许 flight 查找 Secret,如果它不存在,则创建它。如果 flight 没有集群访问权限,则它无法查找 Secret,并且要么无法创建它,要么每次运行都会重新生成它。
我想攻击者可能会创建一个 flight,以某种方式通过其发布名称检测到它正在部署到哪个集群,然后做一些恶意的事情,例如运行加密货币矿工,但我认为在实践中这是一个非常有限的攻击向量。 flight 必须非常明显地说明它在做什么,并且很容易被检测到并停止。
从长远来看,这可以通过 WebAssembly 二进制文件的签名验证来解决,但这是另一个问题。目前,我对 Yoke 的安全模型感到非常满意。
## WebAssembly 切线
Yoke 最酷的部分之一是 WebAssembly 的集群访问功能。这种工作方式是一种非常优雅的 hack,我觉得我必须告诉某人,否则我可能会爆炸或什么。
嵌入 WebAssembly 程序最烦人的问题之一是处理系统调用。如果你以前从未进行过 OS 开发,那么系统调用是程序要求操作系统为其执行某些操作的方式,例如从文件读取或写入文件。 WebAssembly 默认不指定任何系统调用,因此你必须使用像 [WASI](https://xeiaso.net/blog/2025/yoke-k8s/<https:/wasi.dev/>) 这样的标准,或者你必须编写自己的系统调用接口。
WASI 可以工作,但是它没有一个很好的接口用于从 Kubernetes 资源中读取数据。 Yoke 通过向组合中添加一个 `k8s_lookup` 系统调用来实现集群访问。流程如下所示:
* Guest 使用资源标识符运行 `k8s_lookup`
* Guest 将资源标识符放入缓冲区中,并将指向缓冲区的指针发送到 `k8s_lookup` 系统调用中
* Host 从 guest 内存中读取缓冲区,将其解析为 JSON,并在集群中查找资源
* Host 将资源作为 JSON 序列化到内存中
* Host 在 guest 内存上运行 `malloc`,以分配响应的缓冲区
* Host 将序列化的资源写入缓冲区
* Host 将指向缓冲区的指针返回给 guest
让我真正关注 Yoke 的部分是 Buffer 类型的定义:
type Buffer uint64 func (buffer Buffer) Address() uint32 { return uint32(buffer >> 32) } func (buffer Buffer) Length() uint32 { return uint32((buffer << 32) >> 32) }
这个 hack 之所以有效,是因为 WebAssembly 的几个特性以及 Go 的 WebAssembly 端口的几个限制。这其中的第一个重要部分是 WebAssembly 的调用约定。 WebAssembly 本机是一个 32 位环境,但是函数参数存储在堆栈中(该堆栈位于线性内存之外)。函数参数本身可以是几种类型:
* 32 位整数(有符号或无符号)
* 64 位整数(有符号或无符号)
* 32 位浮点数
* 64 位浮点数
* 函数引用
* 用户数据引用
Go 的 WebAssembly 端口仅允许你从函数返回单个值。通常,这意味着你必须分别返回缓冲区的地址和长度,但是由于 Go 只是不支持这一点,因此这是不可能的。
但是,它可以返回 64 位整数。你可以将两个 32 位整数打包到 64 位整数中。这就是 `Buffer` 类型的作用。 “顶部”32 位是缓冲区的地址,“底部”32 位是缓冲区的长度。这是一个非常优雅的 hack,我很惊讶我以前没有见过。
这意味着你可以在 guest 中分配内存,将指针传递给 host,然后 host 可以读取和写入该内存。
我非常喜欢这个 hack,我将在我自己的项目中使用它。我一直在考虑通过类似这样的东西构建(仅限一元)gRPC 客户端函数,我认为这将非常有趣。
## 结论
Yoke 真的很令人兴奋,我迫不及待地想看看它如何发展。我认为这有很大的潜力使你的 Infrastructure as code 成为真正的代码,我很高兴看到它的发展方向。我希望我能在某个时候与维护者一起喝杯咖啡。
分享
自发布以来,事实和情况可能发生了变化。如果某些内容看起来错误或不清楚,请在得出结论之前与我联系。
标签:
版权所有 2012-2025 Xe Iaso。此处列出的任何和所有意见均为我自己的意见,不代表我的任何雇主,无论是过去的、将来的和/或现在的。
喜欢你所看到的?像 [这些很棒的人](https://xeiaso.net/blog/2025/yoke-k8s/</patrons>) 一样,在 [Patreon](https://xeiaso.net/blog/2025/yoke-k8s/<https:/patreon.com/cadey>) 上捐款!
由 xesite v4 (/app/xesite) 提供服务,站点版本为 [e30bab04](https://xeiaso.net/blog/2025/yoke-k8s/<https:/github.com/Xe/site/commit/e30bab048fcacbc5960e53af0332c091869b478e>),源代码可在 [此处](https://xeiaso.net/blog/2025/yoke-k8s/<https:/github.com/Xe/site>) 获得。