WebAssembly容器

WebAssembly容器把Wasm模块作为OCI制品交给专用运行时;重点讲清WASI能力、containerd shim、RuntimeClass与部署边界。

难度 高级 时长 标准深度约12分钟
版本 Node 24
what

WebAssembly容器是通过OCI分发、由Wasm运行时执行的模块或组件;“容器”描述的是交付和编排方式,并不表示其中有Linux用户空间。

when

当工作负载能编译到受支持的WASI或组件世界,并且需要小型制品、显式宿主能力或跨CPU架构交付时,再考虑这种运行方式。

how

先固定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,不应手写字节码。

inspect_core.mjs
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;第二次才把狭窄函数交给模块。

grant_import.mjs
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和索引。

build_oci_layout.mjs
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。

check_runtime_class.mjs
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版本

模块、组件与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道找错题

前置内容 Docker
下一篇 Kubernetes guide 即将上线 Serverless 即将上线 Platform engineering 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?