Reed's News
← 返回精选

将Kubernetes移植到浏览器:一个工程实践报告

Tech 75 peterdemin 2026/6/30 2161 字 原文 ↗

上周我发布了webernetes——这是Kubernetes的TypeScript部分移植版本,让集群能够在浏览器中运行。整个项目历时2个月,跨越629个文件、552次提交,最终生成了近10万行代码。

下方就是一个webernetes集群演示。它完全在你的浏览器中运行,并且真正实现了真实Kubernetes集群的多项核心功能:Pod生命周期管理、集群DNS与网络、容器垃圾回收、IP分配、DeploymentReplicaSet状态追踪等。蓝色圆点代表互相发送请求的Pod。

交互式Webernetes演示:展示来自某个Deployment的三个Pod,在三个Kubernetes节点间传递HTTP请求的过程。

浏览器中的集群

正在启动模拟集群。

node-1

node-2

node-3

我被问到最多的问题是:“你是不是把Kubernetes编译成WebAssembly了?”答案是否定的。一个简单的Go语言“hello, world!”程序编译为WebAssembly后,压缩大小约为540KiB,这已经比webernetes的压缩体积(约140KiB)还大。如果把整个Kubernetes编译成WebAssembly,无疑需要传输数兆字节的数据。我确实尝试过编译,但Kubernetes调用了浏览器环境不支持的系统级API,最终出现编译错误,只能作罢。

webernetes的实现方式是:

为了保持轻量,webernetes不会从Docker Hub这类镜像仓库拉取真实镜像,而是自带一个基于浏览器的镜像仓库,开发者可以通过TypeScript API定义镜像。示例如下:

1import * as w8s from "@ngrok/webernetes";2 3class HelloWorld extends w8s.BaseImage {4 static readonly imageName = "hello-world";5 static readonly imageVersion = "1.0";6 7 async exec(ctx: w8s.ProcessContext, argv: string[]): Promise<number> {8 ctx.listenHttp(8080, async (_ctx, request) => {9 return {10 status: 200,11 body: "Hello, world!",12 };13 });14 return await ctx.waitUntilKilled();15 }16}

要将镜像部署到集群中,只需执行以下代码:

1import * as w8s from "@ngrok/webernetes";2 3class HelloWorld extends w8s.BaseImage {4 // 同上...5}6 7const cluster = new w8s.Cluster();8await cluster.registerImage(HelloWorld);9 10const [pod] = await cluster.apply([11 {12 apiVersion: "apps/v1",13 kind: "Deployment",14 metadata: { name: "hello-world-deployment" },15 spec: {16 replicas: 1,17 selector: {18 matchLabels: { app: "hello-world-pod" },19 },20 template: {21 metadata: {22 labels: { app: "hello-world-pod" },23 },24 spec: {25 containers: [26 {27 name: "hello-world-container",28 image: "hello-world:1.0",29 },30 ],31 },32 },33 },34 },35]);

之后你就可以通过webernetes API与集群交互,示例如下:

1// 列出默认命名空间下的所有Pod2const pods = await cluster.api.corev1.listNamespacedPod({3 namespace: "default",4});5 6// 监听所有命名空间下Pod的变更7const informer = cluster.informer("pods", (type, pod) => {8 console.log(`pod ${type}: ${pod.metadata?.name}`);9});10 11// 使用完毕后停止监听12await informer.stop();13 14// 监听Pod间的请求与响应。15// 这就是上方演示中动态圆点的实现逻辑。16cluster.on("request", (event) => {17 console.log(`request: ${event.request.method} ${event.request.url}`);18});19cluster.on("response", (event) => {20 console.log(`response: ${event.response?.status}`);21});22 23// 通过集群网络向某个Pod发送请求,这也会触发24// 上方定义的请求/响应事件处理器。25const pod = pods.items[0];26const resp = await cluster.fetch(`http://${pod.status?.podIP}:8080/`);27console.log(resp.body); // "Hello, world!"

webernetes代码仓库中还有更多示例。

webernetes的定位是制作交互式Kubernetes教学内容,并非可用于生产环境的Kubernetes发行版。它不需要运行真实镜像,只需让内容创作者能够搭建特定工作负载,以演示想要教授的知识点即可。

我计划逐步扩展webernetes,支持更多Kubernetes功能。目前它还不支持ConfigMap、Secret、Pod资源配置、持久化卷等功能——这些都是我暂时用不到的。随着我用这个工具制作更多内容,会按需实现更多功能。

如果你基于webernetes开发时遇到不支持的功能,请随时联系我!我的邮箱是s.rose@ngrok.com,很乐意协助你成为项目贡献者。

webernetes的几乎所有代码都是由大语言模型(LLM)生成的。我知道大家可能会因此对项目存疑,甚至觉得我只是为了博眼球而粗糙移植Kubernetes,但我想证明事实并非如此。

我做了两件事,确保这个项目绝非粗制滥造:

第一点(也是耗时最长的一点),是如何确保绝大多数代码与Kubernetes的Go代码库逐行等价;第二点,则是如何保证这种代码层面的相似性能转化为实际运行时的一致行为。

经过我的审核后,代码库中仍可能存在错误——我对此毫不怀疑。如果你发现任何问题,请通过提交Issue告知我。

我读过一些关于LLM的案例,比如用它编写C语言编译器,或是将Bun从Zig语言移植到Rust,这些案例的成功都依赖自动化的正确性验证机制:Anthropic有现成的C编译器可作对比,Bun则有一套成熟的测试套件,开发者甚至能基于它合并超过100万行未经人工审核的Rust代码。

但我没有这些条件。如果需要测试套件,我得自己写;如果要与真实Kubernetes对比,我得自己想办法。

webernetes的大部分代码都是从Kubernetes的Go代码库移植而来。我选择用LLM完成这项工作,是因为它比手动编写更快,但很快就发现LLM并不擅长代码移植。无论我怎么尝试,它总会出错,常见的错误类型包括:

误用Map导致行为异常。

我知道肯定有人会说“是你提示词写得不好”,还会评论说我应该提升提示词技巧。这话也许没错!如果有人能给出一个完美的提示词,一次性将这个表格测试从Go语言移植到TypeScript,那真能帮我节省大量时间,我非常期待这样的示例。

在那之前,要让我信任LLM的移植结果,就必须人工审核输出内容——据我所知没有捷径可走。

仅仅知道代码逐行等价还不够,关键是它实际运行起来是否正常?Go和JavaScript的运行环境截然不同,即便代码相同,行为也可能存在差异。我还不得不为JavaScript实现了通道(channel)互斥锁(mutex)Go语言的select语句等Go特有的特性,必须确保它们在复杂场景下能正常工作。

为了验证这一点,我编写了测试,让同一代码同时在webernetes和k3s集群上运行。为此,我需要webernetes拥有与现有Kubernetes兼容的API,最终选择了kubernetes-client/javascript——这是官方的Kubernetes JavaScript客户端库,还自带TypeScript类型定义。

以下是测试示例:

1import { expect, it } from "vitest";2import { kubernetes } from "../../test/harnesses/kubernetes";3 4// `kubernetes.describe`会在后台自动搭建5// k3s(https://k3s.io/)集群或webernetes集群,并通过6// `context`参数传入。7//8// 我可以通过`pnpm test:node`在Node环境中针对k3s运行测试,9// 或通过`pnpm test:browser`在无头浏览器中针对webernetes运行测试。10kubernetes.describe("Pods", (context) => {11 const { core } = context;12 const { getTestNamespace, waitFor } = context.helpers;13 14 it("should be able to delete a pod", async () => {15 // 每个测试都会获得独立的命名空间,避免相互干扰16 const namespace = await getTestNamespace();17 18 await core.createNamespacedPod({19 namespace,20 body: {21 metadata: { name: "delete-test" },22 spec: {23 containers: [24 {25 name: "pause",26 // Webernetes内置了该镜像的实现27 image: "registry.k8s.io/pause:3.10",28 },29 ],30 },31 },32 });33 34 // 确保Pod已创建完成,再继续执行后续步骤35 await waitFor(async () => {36 const pods = await core.listNamespacedPod({ namespace });37 const found = pods.items.find((pod) => pod.metadata?.name === "delete-test");38 expect(found).toBeDefined();39 });40 41 await core.deleteNamespacedPod({42 name: "delete-test",43 namespace,44 });45 46 // 等待Pod被彻底删除后,再判定测试成功47 await waitFor(async () => {48 const pods = await core.listNamespacedPod({ namespace });49 const found = pods.items.find((pod) => pod.metadata?.name === "delete-test");50 expect(found).toBeUndefined();51 });52 });53});

示例中的core对象及其createNamespacedPoddeleteNamespacedPod方法,就是kubernetes-client/javascript API的一部分。我编写的kubernetes.describe(..)测试工具会注入一个core对象:当执行pnpm test:node时,它指向k3s集群;当执行pnpm test:browser时,则指向webernetes集群。

这些是项目的集成测试,用于验证我的移植工作是否正确,以及自定义浏览器容器运行时集群网络的行为是否与真实集群一致。

每当我在使用库的过程中发现Bug,第一步就是编写一个在k3s上能通过、但在webernetes上会失败的测试,然后借助这个反馈回路,让LLM帮我分析并修复问题。

截至本文撰写时,webernetes已有204个集成测试,同时还有1855个单元测试——其中大部分是直接从Kubernetes的Go代码库移植而来。

我认为答案是肯定的。

以前人类同事提交PR时,我期望看到高质量的测试和代码;对LLM生成的内容,我也有同样的期待。到了2026年,情况有了变化:我通常会信任人类同事的工作,但对LLM的输出却不敢掉以轻心。你必须审核它的输出,并且一定要配套测试。

只做其中一件事还不够:如果连测试代码都不审核,你怎么知道LLM是基于什么标准生成结果的?如果审核了所有代码却没有测试,你真的相信自己的大脑能考虑到所有可能性吗?反正我不信——哪怕是我自己手写的代码,我也不敢完全信任。

LLM不会疲劳,打字速度又极快,恰好能弥补人类的弱点。让LLM提出一些你没想到的边缘情况,再把合理的情况写成测试,这个过程很有意思。你可以重复几十次,直到它给出的建议毫无意义为止——LLM根本不会介意!

将我自身对技术的判断力和理解能力,与LLM不知疲倦、快速输出的能力相结合,是自2012年我入行以来,最能提升工作可能性的一次变革。

考虑到本文是回顾性质的,我制作了几张图表展示项目的演进过程。第一张是代码行数随时间的变化:

webernetes每周代码行数变化

图表展示了2026年4月20日至6月15日期间,webernetes每周的代码新增量、删除量、净增量及累计总量。

聚焦图表后,可通过左右方向键查看每周具体数值。

周次 新增行数 删除行数 净增行数 累计总行数
4月20日当周 17,074 5,434 11,640 11,640
4月27日当周 14,759 5,739 9,020 20,660
5月4日当周 5,732 1,344 4,388 25,048
5月11日当周 9,520 4,151 5,369 30,417
5月18日当周 14,675 2,791 11,884 42,301
5月25日当周 12,927 1,073 11,854 54,155
6月1日当周 31,372 5,823 25,549 79,704
6月8日当周 25,165 6,337 18,828 98,532
6月15日当周 29,967 1,857 28,110 126,642

这张图表并没有完全反映真实情况。早期工作是在这个博客网站仓库的一个分支中完成的,当时我还不确定它会发展成独立项目。最终成为https://github.com/ngrok/webernetes仓库的首次提交是在4月21日。

图表显示累计行数约为12.6万,而我在文章开头说的是近10万——这是因为10万行的统计排除了非TypeScript代码、注释以及演示应用

LLM令牌消耗随时间变化

图表展示了2026年4月20日至6月15日期间,webernetes项目在Codex和Claude会话中,每周的非缓存输入令牌、缓存输入令牌及输出令牌的总消耗量。

聚焦图表后,可通过左右方向键查看每周具体数值。

周次 非缓存输入令牌 缓存输入令牌 输出令牌
4月20日当周 3,874,487 78,082,526 606,678
4月27日当周 7,645,946 254,519,999 726,881
5月4日当周 2,885,324 121,050,752 282,128
5月11日当周 10,309,560 344,972,032 827,846
5月18日当周 15,866,022 637,270,656 1,288,182
5月25日当周 11,077,746 318,710,272 892,938
6月1日当周 33,380,099 837,972,608 2,834,083
6月8日当周 28,875,156 794,703,104 2,407,530
6月15日当周 104,155,857 2,196,467,968 6,420,826

请注意两个Y轴的数量级差异。代码生成工具消耗的缓存输入令牌远多于其他类型的令牌,尤其是在频繁填充长上下文窗口时。

嗯……说到这个。

当时我正在开发演示应用,觉得如果能支持Deployment会很酷,原本以为不会花太多时间,结果大错特错。情急之下,我投入了大量令牌来解决这个问题。

LLM第一次尝试移植所需组件时,遗漏了大量功能,于是我启动了一组智能体来梳理依赖链,再用更多子智能体逐个移植组件,之后又用另一组子智能体审核所有内容。

我不确定这种与LLM协作的方式是否合理,但不可否认,它确实比我自己手动完成要快。最后我还是做了人工审核,但令牌的使用效率实在太低了。

每周LLM API等效成本

图表展示了2026年4月20日至6月15日期间,webernetes项目在Codex和Claude会话中产生的每周API等效成本。

聚焦图表后,可通过左右方向键查看每周具体数值。

周次 API等效成本
4月20日当周 $40.52
4月27日当周 $187.26
5月4日当周 $83.42
5月11日当周 $248.87
5月18日当周 $436.61
5月25日当周 $241.53
6月1日当周 $670.91
6月8日当周 $613.95
6月15日当周 $1,811.64

即便到最后,我的时间成本仍是项目中最高的一项。

如果你读到了这里,或许也会喜欢我和同事Ryan Blunden录制的系列视频——它记录了webernetes的开发全过程,你能看到我早期盲目乐观的样子,以及我如何通过语音控制和眼动追踪实现“几乎不用手”的工作方式。

  • Part 1
  • Part 2
  • Part 3 即将上线!

欢迎试用webernetes!欢迎提交Issue!如果你做出了有趣的成果,或是遇到了问题,欢迎发邮件到s.rose@ngrok.com告诉我!我希望这个项目能蓬勃发展、发挥价值,而这离不开你的帮助。