WebAssembly 容器是通过 OCI 分发、由 Wasm 运行时执行的模块或组件;“容器”描述的是交付和编排方式,并不表示其中有 Linux 用户空间。
当工作负载能编译到受支持的 WASI 或组件世界,并且需要小型制品、显式宿主能力或跨 CPU 架构交付时,再考虑这种运行方式。
先固定 Wasm 与 WASI 契约,再构建并检查 OCI 描述符,最后让 containerd shim 和 Kubernetes RuntimeClass 在正确的节点上选择匹配运行时。
是什么,为什么存在
WebAssembly 模块(WebAssembly module) 是一段经过验证的紧凑二进制代码,由宿主运行时实例化。模块只能调用自身指令、内存和宿主提供的导入,不能像原生 Linux 进程那样直接发起任意系统调用。核心 Wasm 规定计算模型;它本身不规定文件、套接字、环境变量或时钟。
“WebAssembly 容器”不是另一种 Linux 容器格式。它通常指一个放进 OCI 镜像或 OCI 制品的 Wasm 模块或组件,由 Wasmtime 等引擎以及接入 containerd 的专用 shim 执行。注册表、内容摘要、Kubernetes Pod 和部署控制器仍可复用,执行边界却从原生进程变成了 Wasm 实例。
WASI(WebAssembly System Interface) 补上标准化宿主接口。运行时可以向组件提供文件系统、随机数、时钟、命令行或 HTTP 等接口,也可以拒绝提供。WASI 应用从无环境权限(ambient authority)的状态开始,只有宿主明确授予的能力才可用。
这种模式适合接口窄、可移植需求明确的任务,例如事件处理器、插件、边缘函数和策略执行器。它不是现有容器镜像的透明加速开关。依赖 fork、任意动态库、Linux 设备、shell 脚本或未移植原生扩展的应用,通常仍应使用普通容器,或先完成有边界的移植验证。
| 维度 | Linux 容器 | Wasm 容器工作负载 |
|---|---|---|
| 可执行内容 | 面向 OS 与 CPU 架构的原生进程 | 面向 Wasm 与特定宿主接口的模块或组件 |
| 系统访问 | 经内核、命名空间、cgroup 和安全策略约束 | 经运行时提供的导入约束,外层仍可能有容器隔离 |
| 分发 | OCI 镜像及其文件系统层 | 常规 OCI 镜像中的 .wasm,或带 Wasm 媒体类型的 OCI 制品 |
| 编排选择 | 默认 CRI 运行时 | 已配置的 shim 与 RuntimeClass |
| 兼容性问题 | libc、内核、CPU 和镜像平台 | 核心 Wasm 特性、WASI 版本、world、宿主扩展和运行时 |
工作原理
交付链有三个彼此独立的契约。编译契约决定产物是核心模块还是组件,以及目标是 wasip1、wasip2 或别的宿主接口。分发契约决定 OCI 清单、配置和层如何按摘要保存。执行契约决定哪个 shim、引擎及其配置会提供模块声明的导入。
核心模块把依赖写成导入,把可调用能力写成导出。宿主在实例化时必须用名称与类型都匹配的实现满足导入,否则实例化失败。这种链接机制解释了“默认不能访问宿主”,但完整的能力安全还取决于运行时怎样构造 WASI 上下文、预打开哪些目录,以及允许哪些网络目标。
组件模型(Component Model) 在核心模块之上增加类型化接口与组合规则。WIT 描述接口、world 的导入与导出;组件工具生成不同语言之间的绑定和规范 ABI 适配。一个 wasip1 核心模块与一个导入 wasi:http 的 wasip2 组件不是同一执行契约,文件扩展名也不能说明它们是否兼容。
OCI 只负责按内容寻址和分发。CNCF Wasm OCI Artifact 布局使用 OCI 镜像清单、application/vnd.wasm.config.v0+json 配置和 application/wasm 层;配置中的层摘要必须与清单顺序一致。runwasi 也展示了另一种兼容路径:把 .wasm 文件放在普通 OCI 文件系统镜像中。选择哪一种格式要看注册表、构建器、扫描器、containerd 版本和 shim 是否共同支持。
containerd shim 把 containerd 的任务生命周期映射到具体 Wasm 宿主。Kubernetes 通过 CRI 提交 Pod 后, RuntimeClass 的 handler 必须对应每个合格节点上的 CRI 运行时配置。Pod 引用的是 RuntimeClass 的 metadata.name,不是直接引用 shim 二进制名。
示例
实例化一个无导入模块
第一个样例包含一个导出 double 的真实核心 Wasm 模块。字节数组让样例不依赖 WAT 编译器;生产构建应由语言工具链生成 .wasm,不应手写字节码。
const bytes = Uint8Array.from([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f,
0x03, 0x02, 0x01, 0x00,
0x07, 0x0a, 0x01, 0x06, 0x64, 0x6f, 0x75, 0x62,
0x6c, 0x65, 0x00, 0x00,
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x41,
0x02, 0x6c, 0x0b,
]);
const module = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(module);
console.log(`valid: ${WebAssembly.validate(bytes)}`);
console.log(`imports: ${WebAssembly.Module.imports(module).length}`);
console.log(`exports: ${WebAssembly.Module.exports(module)[0].name}`);
console.log(`double(21): ${instance.exports.double(21)}`);valid: true
imports: 0
exports: double
double(21): 42验证成功只说明字节满足核心 Wasm 规则。imports: 0 说明模块不要求宿主函数,所以 Node 24 可以直接实例化它。它还不是 WASI 应用,也没有从 OCI 制品启动;这三个阶段需要分别验证。
显式满足宿主导入
第二个模块导入 host.audit,并在 run 中调用它。第一次实例化没有提供导入,Node 24 实际抛出 TypeError;第二次才把狭窄函数交给模块。
const bytes = Uint8Array.from([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x05, 0x01, 0x60, 0x01, 0x7f, 0x00,
0x02, 0x0e, 0x01, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x05, 0x61, 0x75, 0x64, 0x69, 0x74, 0x00, 0x00,
0x03, 0x02, 0x01, 0x00,
0x07, 0x07, 0x01, 0x03, 0x72, 0x75, 0x6e, 0x00,
0x01,
0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10,
0x00, 0x0b,
]);
const module = await WebAssembly.compile(bytes);
const required = WebAssembly.Module.imports(module)[0];
console.log(`requires: ${required.module}.${required.name} (${required.kind})`);
try {
await WebAssembly.instantiate(module, {});
} catch (error) {
console.log(`without grant: ${error.name}`);
}
const imports = { host: { audit: value => console.log(`audit: ${value}`) } };
const instance = await WebAssembly.instantiate(module, imports);
instance.exports.run(7);requires: host.audit (function)
without grant: TypeError
audit: 7这段代码展示的是导入链接,不是假装实现完整 WASI。真实运行时还要限制导入实现本身,例如目录句柄能到达的路径、HTTP 客户端能连接的主机,以及一次调用可消耗的时间和内存。模块内没有某项导入,并不能撤销宿主在别处授予的 Pod 权限。
构建按内容寻址的 OCI 布局
第三个样例把同一模块写入本地 OCI image layout。它计算模块、Wasm 配置和清单的真实摘要,并让 index.json 指向清单;输出中的五个文件包括三个 blob、oci-layout 和索引。
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
const root = "double-oci";
const wasm = Buffer.from([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f,
0x03, 0x02, 0x01, 0x00,
0x07, 0x0a, 0x01, 0x06, 0x64, 0x6f, 0x75, 0x62,
0x6c, 0x65, 0x00, 0x00,
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x41,
0x02, 0x6c, 0x0b,
]);
const encode = value => Buffer.from(JSON.stringify(value));
const digest = data => `sha256:${createHash("sha256").update(data).digest("hex")}`;
const descriptor = (mediaType, data) => ({ mediaType, digest: digest(data), size: data.length });
await mkdir(`${root}/blobs/sha256`, { recursive: true });
const put = async data => writeFile(`${root}/blobs/sha256/${digest(data).slice(7)}`, data);
const layer = descriptor("application/wasm", wasm);
const configBytes = encode({ architecture: "wasm", os: "wasip1", layerDigests: [layer.digest] });
const config = descriptor("application/vnd.wasm.config.v0+json", configBytes);
const manifestBytes = encode({
schemaVersion: 2,
mediaType: "application/vnd.oci.image.manifest.v1+json",
config,
layers: [layer],
});
const manifest = descriptor("application/vnd.oci.image.manifest.v1+json", manifestBytes);
manifest.annotations = { "org.opencontainers.image.ref.name": "double:v1" };
await Promise.all([put(wasm), put(configBytes), put(manifestBytes)]);
await writeFile(`${root}/oci-layout`, encode({ imageLayoutVersion: "1.0.0" }));
await writeFile(`${root}/index.json`, encode({ schemaVersion: 2, manifests: [manifest] }));
console.log(`layer: ${layer.digest} (${layer.size} bytes)`);
console.log(`config: ${config.mediaType}`);
console.log(`manifest: ${manifest.digest}`);
console.log("files: 5");layer: sha256:45eb05dbe0a0a2df47788f382ba1d87adab365e30c522c7bc86e0d0e705b1395 (43 bytes)
config: application/vnd.wasm.config.v0+json
manifest: sha256:8572cf38f180477993582dc7bd712ea50f8382e201d4799e417af28952c78725
files: 5这里的 wasip1 按制品规范表示普通核心模块;样例本身没有 WASI 导入。组件需要 wasip2 和 component 元数据,包括实际导入、导出与可选 target。不要把示例摘要复制到其他模块,也不要在写完清单后再修改 blob;内容一变,描述符摘要和上层清单摘要都必须重算。
检查 RuntimeClass 映射
最后一个样例检查三项容易混淆的名称:Pod 选择 RuntimeClass 名称,RuntimeClass 指向 CRI handler,自定义节点标签限制调度范围。节点标签声明宿主已安装相应运行时,而不是把宿主 CPU 架构伪装成 wasm32。
const runtimeClass = {
apiVersion: "node.k8s.io/v1",
kind: "RuntimeClass",
metadata: { name: "wasm-wasmtime" },
handler: "wasmtime",
scheduling: {
nodeSelector: { "runtime.codewiki.dev/wasmtime": "true" },
},
};
const pod = {
metadata: { name: "receipt-worker" },
spec: {
runtimeClassName: "wasm-wasmtime",
containers: [{ name: "worker", image: "registry.example/receipt:v1" }],
},
};
const configuredHandlers = new Set(["runc", "wasmtime"]);
const eligibleNodes = [
{ name: "worker-a", labels: { "runtime.codewiki.dev/wasmtime": "true" } },
{ name: "worker-b", labels: {} },
].filter(node =>
Object.entries(runtimeClass.scheduling.nodeSelector)
.every(([key, value]) => node.labels[key] === value),
);
console.log(`class selected: ${pod.spec.runtimeClassName === runtimeClass.metadata.name}`);
console.log(`handler configured: ${configuredHandlers.has(runtimeClass.handler)}`);
console.log(`eligible nodes: ${eligibleNodes.map(node => node.name).join(", ")}`);class selected: true
handler configured: true
eligible nodes: worker-a这个本地检查不能证明集群已安装 shim。部署前还要在每类节点上检查 containerd 的 CRI 配置、shim 二进制、运行时版本和镜像格式支持,再创建一次真实 Pod。若集群用另一套 handler 名称,只应修改 RuntimeClass 和节点配置,不应让业务清单猜测二进制路径。
陷阱
把 Wasm 当作普通 Linux 镜像
修复: 先确定 shim 接受的是带 .wasm 文件的常规文件系统镜像,还是 Wasm OCI 制品。探针、命令覆盖和调试流程必须针对该运行时验证。需要 sidecar 工具时,把它作为独立 Linux 容器部署,不要假设它能进入 Wasm 实例的文件系统。
把导入声明当作权限授予
修复: 在运行时配置中逐项授予预打开目录、网络目标、环境变量和秘密,并测试未授权访问确实失败。把 Kubernetes ServiceAccount、卷、Pod 网络和节点权限也纳入审查,因为 Wasm 沙箱位于这些外层权限之内。
混用 WASI 世代与组件 world
修复: 在构建输出中记录目标、组件 world 与 WIT 依赖版本,并在目标运行时上执行导入检查。不要靠扩展名推断格式。升级编译器、绑定生成器或运行时时,把同一个制品重新跑一遍兼容测试,而不是只验证源码能编译。
伪造节点架构与 handler
修复: 保留 kubelet 报告的真实 CPU 标签,另加组织管理的运行时能力标签。让平台团队统一创建 RuntimeClass,并验证其 handler 在所有匹配节点的 CRI 配置中存在。部署测试必须覆盖调度失败、镜像拉取失败和 shim 启动失败,而不只是 YAML 校验。
把沙箱等同于完整供应链信任
修复: 固定并更新 shim 与引擎,按摘要部署经过签名和准入检查的制品,不接受来自镜像层的未受信任原生预编译缓存。即使内部模块受 Wasm 约束,也要保留外层容器隔离、只读挂载、网络策略和最小 ServiceAccount。
模块、组件与 WASI 版本
核心 Wasm 二进制由段组成,类型段定义函数签名,导入段声明外部依赖,导出段公开函数、内存、表或全局值。验证器检查二进制结构、指令类型和控制流,但不知道 host.audit 应该记录到哪里。链接与实例化阶段才把宿主值放进这些导入槽位。
WASI 0.1,也称 Preview 1,主要服务命令风格核心模块。WASI 0.2,也称 Preview 2,以组件模型和 WIT 接口为基础;WASI 0.3 又加入了原生异步接口。版本号存在并不等于所选语言 SDK、绑定生成器和生产运行时已经实现相同提案,所以部署单位必须固定实际 world 及依赖版本。
组件可以使用字符串、记录、变体、资源等高级类型,规范 ABI 再把它们降级到核心模块可交换的表示。组合器可以把一个组件的导出连接到另一个组件的导入。这样能减少自定义 JSON 或 HTTP 边界,但不会消除版本管理;接口名称相同、版本或资源语义不同,仍然可能无法组合。
| 需要核对的值 | 构建阶段 | 制品检查 | 运行时检查 |
|---|---|---|---|
| Wasm 形态 | 核心模块或组件 | 二进制编码与配置元数据 | 引擎是否支持该形态 |
| 系统接口 | wasip1、wasip2、wasip3 或自定义 | imports、exports、world | 宿主实现及允许列表 |
| CPU 特性 | 编译器启用的 Wasm 特性 | 目标特性与制品元数据 | 引擎特性开关 |
| 资源边界 | 应用预期 | 无法只靠 OCI 推断 | 内存、燃料、epoch、超时和并发限制 |
OCI 制品不是执行契约
OCI 制品(OCI artifact) 由描述符组成的内容图。描述符记录媒体类型、字节数和内容摘要;清单再指向配置与层,索引可以指向一个或多个清单。注册表能保存和复制未知内容,并不表示节点上的 snapshotter、客户端或 shim 能解释这种内容。
CNCF v0 Wasm 布局把入口放在第一层,并要求消费者拒绝当前版本中的多层表示。layerDigests 使配置身份随层集合变化。对于组件,配置里的 imports、exports 与 target 是便于索引和提前拒绝不兼容宿主的元数据;运行时仍应检查实际二进制,不能只信发布者填写的 JSON。
常规文件系统镜像提供兼容性更好的退路:把 .wasm 放进 tar 层,再让 shim 从 OCI bundle 中打开它。代价是镜像平台、入口路径和命令约定依赖具体 shim。Wasm 专用制品避免把模块伪装成文件系统,但会暴露旧注册表或客户端对自定义配置和层媒体类型支持不足的问题。
发布流水线应在推送后重新拉取清单,比较 digest、size、媒体类型和平台字段,再在与生产相同的 containerd 与 shim 组合上启动它。标签适合人阅读,不是不可变身份。部署清单应记录摘要,并让更新流程显式替换它。
两层安全边界
Wasm 引擎首先验证模块,然后只连接配置允许的宿主接口。这个内层边界控制模块能调用什么。运行时进程、shim 和宿主函数仍是原生代码,它们位于内层边界之外;其中的漏洞可能把攻击者带到外层容器边界。
外层边界由 containerd、内核、Kubernetes 和节点配置形成。它控制 shim 进程看到的挂载、凭据、网络、设备和系统调用。如果外层给了宿主根目录或高权限 ServiceAccount,即使模块只能通过一个“读取配置”接口访问,该接口实现也可能暴露远超预期的数据。
因此,能力审查要从模块导入一路追到宿主资源。对每项能力记录调用者、宿主实现、资源范围、失败方式、审计信号和撤销方式。负向测试比配置截图更可靠:实际尝试读取未预打开路径、连接未允许主机、耗尽计算预算和调用不存在的接口,并确认运行时在边界内失败。
生产升级还要把 shim 视为安全关键依赖。先在隔离节点池验证新版本与既有制品,再滚动节点并检查 RuntimeClass 覆盖范围。保留普通容器回退路径时,要明确哪些工作负载允许回退;静默改用默认 runc 可能直接失败,也可能以不同权限模型运行错误制品。
生产验证矩阵
一份能在开发机上运行的 .wasm 还不是可发布工作负载。生产证据要覆盖构建器、制品仓库、节点运行时和工作负载策略,而且每一层都要保存可重复执行的命令或测试结果。只记录工具版本截图,无法证明同一个 digest 穿过了整条链路。
构建门禁
构建阶段应从干净环境生成模块,并检查实际导入与导出。对于组件,还要检查 world 和 WIT 依赖是否与锁定版本一致。随后构建 OCI 制品,重算描述符,再从目标注册表拉回同一 digest。
- 记录编译器、目标三元组、组件工具和依赖锁文件。
- 对最终二进制运行验证器与导入导出检查,而不是只检查源代码。
- 生成软件物料清单与来源证明,并把它们关联到不可变摘要。
- 用生产使用的客户端推送并拉回制品,比较每个 blob 的字节数和摘要。
节点门禁
节点验证关心的是宿主能力,而不是业务代码。每个 RuntimeClass 覆盖的节点都应报告相同的 CRI handler、兼容 shim 和引擎版本。节点升级后要重新运行冒烟制品,避免标签仍在而二进制或配置已经丢失。
- 确认节点标签由安装或合规流程维护,不能由工作负载自行声明。
- 确认 containerd 配置中的 handler 能解析到预期 shim,且服务重载已生效。
- 验证注册表认证、镜像媒体类型、内容拉取和缓存清理路径。
- 用一个应成功的制品和一个缺少导入的制品检查正向与负向结果。
工作负载门禁
工作负载测试必须使用最终 Pod 规范,因为卷、秘密、网络和资源限制都在这一层组合。健康检查应观察应用真正提供的接口,不能依赖制品中不存在的 shell。故障测试还要确认重启、驱逐和节点迁移不会悄悄换到不同运行时。
- 核对
runtimeClassName、制品 digest、ServiceAccount 和网络策略。 - 测试启动、就绪、正常终止、超时与资源耗尽时的行为。
- 从应用日志、shim 日志和 kubelet 事件保留同一工作负载标识。
- 在禁用相应 RuntimeClass 或删除节点能力标签后,确认部署以可诊断方式失败。
通过这三道门禁后,版本升级仍应当作兼容性变更处理。先发布小比例制品与节点组合,观察实例化失败、能力拒绝、重启和资源限制信号,再扩大范围。回滚目标必须同时包含制品 digest 与运行时版本,仅回滚其中一个可能保留不兼容组合。
发布证据
把上述结果关联到同一个不可变摘要,并保留生成时间、工具版本与执行环境。这样,故障响应人员能够区分应用回归、制品损坏、节点漂移和运行时升级,而不必从一个可变标签重新猜测部署内容。
延伸阅读
4个问题 · 1 道输出预测题 · 1 道找错题