WebAssembly 在雲原生中的實踐指南

1 WebAssembly 介紹

WebAssembly(Wasm)是一種通用字節碼技術,它可以將其他編程語言(如 Go、Rust、C/C++ 等)的程序代碼編譯爲可在瀏覽器環境直接執行的字節碼程序。

WebAssembly 的初衷之一是解決 JavaScript 的性能問題,讓 Web 應用程序能夠達到與本地原生應用程序類似的性能。作爲底層 VM 的通用、開放、高效的抽象,許多編程語言,例如 C、C++ 和 Rust,都可以將現有應用程序編譯成 Wasm 的目標代碼,以便它們在瀏覽器中運行。這將應用程序開發技術與運行時技術解耦,並大大提高了代碼的可重用性。

2019 年 3 月,Mozilla 推出了 WebAssembly 系統接口(Wasi),以標準化 WebAssembly 應用程序與系統資源之間的交互抽象,例如文件系統訪問、內存管理和網絡連接,該接口類似於 POSIX 等標準 API。Wasi 規範的出現極大地擴展了 WebAssembly 的應用場景,使得 Wasm 不僅限於在瀏覽器中運行,而且可以在服務器端得到應用。同時,平臺開發者可以針對特定的操作系統和運行環境提供 Wasi 接口的不同實現,允許跨平臺的 WebAssembly 應用程序運行在不同的設備和操作系統上。

2 WebAssembly 會取代容器嗎?

Docker 的創始人 Solomon Hykes 是這樣評價 WASI 的:

如果 WASM+WASI 在 2008 年就存在,我們就不需要創建 Docker 了。這就是它的重要性。服務器上的 WebAssembly 是計算的未來。

Solomon Hykes 後續還發布了一條推文,表示  WebAssembly 將與容器一起工作,而不是取代它們。WebAssembly 可以成爲一種容器類型,類似於 Linux 容器或 Windows 容器。它將成爲標準的跨平臺應用程序分發和運行時環境。

3 WebAssembly 的優勢

WebAssembly 相較於傳統的容器有着許多顯著的優勢:

關於 WebAssembly 和容器詳細的對比,可以查看這個表格: WebAssembly vs Linux Container [1]

4 使用 Rust 開發 Wasm 應用

是否可以將應用程序編譯爲 Wasm 在很大程度上取決於所使用的編程語言。Rust、C、C++ 等語言對 Wasm 有很好的支持。從 Go 1.21 版本開始,Go 官方也初步支持了 Wasi,之前需要使用第三方工具如 tinygo 進行編譯。由於 Rust 對 Wasm 的一流支持以及無需 GC、零運行時開銷的特點,使其成爲了開發 Wasm 應用的理想選擇。因此,本文選用 Rust 來開發 Wasm 應用程序。

4.1 安裝 Rust

執行以下命令安裝 rustup,並通過 rustup 安裝 Rust 的最新穩定版本,rustup 是用於管理 Rust 版本和工具鏈的命令行工具。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

4.2 爲 Rust 添加 wasm32-wasi 編譯目標

前面提到過,Wasi(WebAssembly System Interface)是用於 WebAssembly 的系統級接口,旨在實現 WebAssembly 在不同環境中與宿主系統交互。它提供標準化的方式,使得 WebAssembly 可以進行文件 I/O、網絡操作和系統調用等系統級功能訪問。

rustc 本身是一個跨平臺的編譯器,其編譯的目標有很多,具體可以通過 rustup target list 命令來查看。wasm32-wasi 是 Rust 的編譯目標之一,用於將 Rust 代碼編譯爲符合 Wasi 標準的 Wasm 模塊。通過將 Rust 代碼編譯爲 wasm32-wasi 目標,可以將 Rust 的功能和安全性引入到 WebAssembly 環境中,同時利用 wasm32-wasi 提供的標準化系統接口實現與宿主系統的交互。

執行以下命令,爲 Rust 編譯器添加 wasm32-wasi 目標。

rustup target add wasm32-wasi

4.3 編寫 Rust 程序

首先執行以下命令構建一個新的 Rust 項目。

cargo new http-server

編輯 Cargo.toml 添加如下依賴。這裏我們使用 wrap_wasi 來開發一個簡單的 HTTP Server, warp_wasi 構建在 Warp 框架之上,Warp 是一個輕量級的 Web 服務器框架,用於構建高性能的異步 Web 應用程序。

原生的 Warp 框架編寫的代碼無法直接編譯成 Wasm 模塊。因此我們可以使用 warp_wasi,通過它我們可以在 Rust 中利用 Wasi 接口來開發 Web 應用程序。

[dependencies]
tokio_wasi = { version = "1"features = ["rt""macros""net""time""io-util"]}
warp_wasi = "0.3"

編寫一個簡單的 HTTP Server,在 8080 端口暴露服務,當接收到請求時返回 Hello, World!。

use warp::Filter;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let hello = warp::get()
        .and(warp::path::end())
        .map(|| "Hello, World!");

    warp::serve(hello).run(([0, 0, 0, 0], 8080)).await;
}

執行以下命令,將程序編譯爲 Wasm 模塊。

cargo build --target wasm32-wasi --release

4.4 安裝 WasmEdge

編譯完成的 Wasm 模塊需要使用相應的 Wasm 運行時來運行。常見的 Wasm 運行時包括 WasmEdge、Wasmtime 和 Wasmer 等。

在這裏,我們選擇使用 WasmEdge,它是一個輕量、高性能且可擴展的 WebAssembly Runtime。執行以下命令安裝 WasmEdge。

curl -sSf https://raw.githubusercontent.com/WasmEdge/WasmEdge/master/utils/install.sh | bash

運行以下命令以使已安裝的二進制文件在當前會話中可用。

source $HOME/.wasmedge/env

4.5 運行 Wasm 模塊

使用 wasmedge 來運行前面編譯好的 Wasm 模塊。

wasmedge target/wasm32-wasi/release/http-server.wasm

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

5 運行 Wasm 工作負載

5.1 在 Linux 容器中運行 Wasm 工作負載

在容器生態系統中運行 Wasm 應用程序最簡單的方法就是將 Wasm 模塊直接嵌入到 Linux 容器鏡像中。具體來說,我們可以將容器內的 Linux 操作系統精簡到足夠支持 Wasmedge 運行時,然後通過 Wasmedge 來運行 Wasm 模塊。由於 Wasm 應用程序包裝在常規容器中,因此它可以與任何容器生態系統無縫地協作。通過這種方式,整個 Linux 操作系統和 Wasmedge 運行時的內存佔用可以減少到僅爲 4MB。

相較於常規的 Linux 操作系統,精簡版的 Linux 操作系統大大減少了攻擊面。然而,這種方法仍然需要啓動 Linux 容器。即使是精簡版的 Linux 操作系統,在鏡像大小上仍然佔據了整個容器大小的 80%,因此仍然有很大的優化空間。

接下來根據前面編寫的 Rust 代碼構建出 Linux 容器鏡像。首先在前面創建的 http-server 項目根目錄下創建一個名爲 Dockerfile-wasmedge-slim 的 Dockerfile,將編譯完成的 Wasm 模塊添加到安裝了 wasmedge 的精簡 linux 鏡像中,並指定通過 wasmedge 命令來啓動 Wasm 模塊。

FROM wasmedge/slim-runtime:0.10.1
COPY target/wasm32-wasi/release/http-server.wasm /
CMD ["wasmedge""--dir"".:/""/http-server.wasm"]

執行以下命令構建容器鏡像。

docker build -f Dockerfile-wasmedge-slim -t cr7258/wasm-demo-app:slim .

啓動容器。

docker run -itd -p 8080:8080 \
--name wasm-demo-app \
docker.io/cr7258/wasm-demo-app:slim

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

5.2 在支持 Wasm 的容器運行時中運行 Wasm 工作負載

前面我們介紹瞭如何將 Wasm 模塊直接嵌入到 Linux 容器中來運行 Wasm 工作負載,這種方式的好處就是可以無縫地與現有的環境進行集成,同時享受到 Wasm 帶來的性能的提升。

然而這種方法的性能和安全性不如直接在支持 Wasm 的容器運行時中運行 Wasm 程序那麼好。一般我們將容器運行時分爲高級運行時和低級運行時:

以下是一個概念圖,可以幫助你瞭解高級和低級運行時是如何協同工作的。

接下來將會分別介紹如何通過高級和低級容器運行時來運行 Wasm 模塊,首先構建一個 Wasm 鏡像。

5.2.1 構建鏡像

在前面創建的 http-server 項目根目錄下創建一個 Dockerfile 文件,這次我們直接使用 scratch 空鏡像來構建,scratch 是 Docker 中預留的最小的基礎鏡像。

FROM scratch
COPY target/wasm32-wasi/release/http-server.wasm /
CMD ["/http-server.wasm"]

執行以下命令構建容器鏡像。

docker build -t docker.io/cr7258/wasm-demo-app:v1 .

將鏡像推送到 Docker Hub 上,方便後續的實驗使用。

# 登錄 Docker Hub
docker login
# 推送鏡像
docker push docker.io/cr7258/wasm-demo-app:v1

在 Docker Hub 上可以看到這次構建的鏡像僅有 989.89 KB(壓縮後),大小僅有前面構建的 wasm-demo-app:slim 鏡像的 1/4。

5.2.2 低級容器運行時

在 5.2.2 章節中將會介紹使用 crun 和 youki 這兩種低級容器運行時在不依賴高級容器運行時的情況下,使用準備好的 config.json 和 rootfs 文件來直接啓動 Wasm 應用。

5.2.2.1 Crun

crun 是用 C 編寫的快速輕量的 OCI 容器運行時,並且內置了對 WasmEdge 的支持。本小節將演示如何通過 crun 來運行 Wasm 模塊。

請確保按照 4.4 小節安裝好了 WasmEdge。

然後在 Ubuntu 系統上從源代碼來構建它,執行以下命令安裝編譯所需的依賴。

apt update
apt install -y make git gcc build-essential pkgconf libtool \
     libsystemd-dev libprotobuf-c-dev libcap-dev libseccomp-dev libyajl-dev \
     go-md2man libtool autoconf python3 automake

接下來,配置、構建和安裝支持 WasmEdge 的 crun 二進制文件。

git clone https://github.com/containers/crun
cd crun
./autogen.sh
./configure --with-wasmedge
make
make install

接下來,運行 crun -v 檢查是否安裝成功。看到有 +WASM:wasmedge,說明已經在 crun 中安裝了 WasmEdge 了。

crun -v

# 返回結果
crun version 1.8.5.0.0.0.23-3856
commit: 385654125154075544e83a6227557bfa5b1f8cc5
rundir: /run/crun
spec: 1.0.0
+SYSTEMD +SELINUX +APPARMOR +CAP +SECCOMP +EBPF +WASM:wasmedge +YAJL

創建一個目錄來存放運行容器所需的文件。

mkdir test-crun
cd test-crun
mkdir rootfs
# 將編譯好的 Wasm 模塊拷貝到 rootfs 目錄中,注意替換成自己對應的目錄
cp ~/hands-on-lab/wasm/runtime/http-server/target/wasm32-wasi/release/http-server.wasm rootfs

使用 crun spec 命令生成默認的 config.json 配置文件,然後進行修改:

修改完成後的配置文件如下:

{
 "ociVersion""1.0.0",
 "process"{
  "terminal": true,
  "user"{
   "uid": 0,
   "gid"0
  },
  "args"[
   "/http-server.wasm"
  ],
  "env"[
   "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
   "TERM=xterm"
  ],
  "cwd""/",
  "capabilities"{
   "bounding"[
    "CAP_AUDIT_WRITE",
    "CAP_KILL",
    "CAP_NET_BIND_SERVICE"
   ],
   "effective"[
    "CAP_AUDIT_WRITE",
    "CAP_KILL",
    "CAP_NET_BIND_SERVICE"
   ],
   "inheritable"[
   ],
   "permitted"[
    "CAP_AUDIT_WRITE",
    "CAP_KILL",
    "CAP_NET_BIND_SERVICE"
   ],
   "ambient"[
    "CAP_AUDIT_WRITE",
    "CAP_KILL",
    "CAP_NET_BIND_SERVICE"
   ]
  },
  "rlimits"[
   {
    "type""RLIMIT_NOFILE",
    "hard": 1024,
    "soft"1024
   }
  ],
  "noNewPrivileges"true
 },
 "root"{
  "path""rootfs",
  "readonly"true
 },
 "hostname""crun",
 "mounts"[
  {
   "destination""/proc",
   "type""proc",
   "source""proc"
  },
  {
   "destination""/dev",
   "type""tmpfs",
   "source""tmpfs",
   "options"[
    "nosuid",
    "strictatime",
    "mode=755",
    "size=65536k"
   ]
  },
  {
   "destination""/dev/pts",
   "type""devpts",
   "source""devpts",
   "options"[
    "nosuid",
    "noexec",
    "newinstance",
    "ptmxmode=0666",
    "mode=0620",
    "gid=5"
   ]
  },
  {
   "destination""/dev/shm",
   "type""tmpfs",
   "source""shm",
   "options"[
    "nosuid",
    "noexec",
    "nodev",
    "mode=1777",
    "size=65536k"
   ]
  },
  {
   "destination""/dev/mqueue",
   "type""mqueue",
   "source""mqueue",
   "options"[
    "nosuid",
    "noexec",
    "nodev"
   ]
  },
  {
   "destination""/sys",
   "type""sysfs",
   "source""sysfs",
   "options"[
    "nosuid",
    "noexec",
    "nodev",
    "ro"
   ]
  },
  {
   "destination""/sys/fs/cgroup",
   "type""cgroup",
   "source""cgroup",
   "options"[
    "nosuid",
    "noexec",
    "nodev",
    "relatime",
    "ro"
   ]
  }
 ],
 "annotations"{
  "module.wasm.image/variant""compat"
 },
 "linux"{
  "resources"{
   "devices"[
    {
     "allow": false,
     "access""rwm"
    }
   ]
  },
  "namespaces"[
   {
    "type""pid"
   },
   {
    "type""network",
    "path""/proc/1/ns/net"
   },
   {
    "type""ipc"
   },
   {
    "type""uts"
   },
   {
    "type""cgroup"
   },
   {
    "type""mount"
   }
  ],
  "maskedPaths"[
   "/proc/acpi",
   "/proc/asound",
   "/proc/kcore",
   "/proc/keys",
   "/proc/latency_stats",
   "/proc/timer_list",
   "/proc/timer_stats",
   "/proc/sched_debug",
   "/sys/firmware",
   "/proc/scsi"
  ],
  "readonlyPaths"[
   "/proc/bus",
   "/proc/fs",
   "/proc/irq",
   "/proc/sys",
   "/proc/sysrq-trigger"
  ]
 }
}

通過 crun 啓動容器。

crun run wasm-demo-app

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

crun kill wasm-demo-app SIGKILL
5.2.2.2 Youki

youki 是一個使用 Rust 編寫的符合 OCI 規範的容器運行時。相較於 C,Rust 的使用帶來了內存安全的優勢。和 crun 一樣,Youki 同樣支持了 WasmEdge。

請確保按照 4.1 小節安裝好了 Rust。

然後 Ubuntu 系統上從源代碼來構建它,執行以下命令安裝編譯所需的依賴。

apt-get update
sudo apt-get -y install    \
      pkg-config          \
      libsystemd-dev      \
      libdbus-glib-1-dev  \
      build-essential     \
      libelf-dev          \
      libseccomp-dev      \
      libclang-dev        \
      libssl-dev

執行以下命令編譯支持 WasmEdge 的 youki 二進制文件。

./scripts/build.sh -o . -r -f wasm-wasmedge

指定 wasm-wasmedge 參數將在 $HOME/.wasmedge 目錄中安裝 WasmEdge 運行時庫。要使該庫在系統中可用,請運行以下命令:

export LD_LIBRARY_PATH=$HOME/.wasmedge/lib

或者:

source $HOME/.wasmedge/env

最後將編譯完成後的 youki 文件移動到任意 $PATH 所包含的目錄。

mv youki /usr/local/bin

創建一個目錄來存放運行容器所需的文件。

mkdir test-youki
cd test-youki
mkdir rootfs
# 將編譯好的 Wasm 模塊拷貝到 rootfs 目錄中,注意替換成自己對應的目錄
cp ~/hands-on-lab/wasm/runtime/http-server/target/wasm32-wasi/release/http-server.wasm rootfs

使用 youki spec 命令生成默認的 config.json 配置文件,然後進行修改,和前面修改 crun 配置文件的內容是一樣的:

修改完成後的配置文件如下:

{
  "ociVersion""1.0.2-dev",
  "root"{
    "path""rootfs",
    "readonly"true
  },
  "mounts"[
    {
      "destination""/proc",
      "type""proc",
      "source""proc"
    },
    {
      "destination""/dev",
      "type""tmpfs",
      "source""tmpfs",
      "options"[
        "nosuid",
        "strictatime",
        "mode=755",
        "size=65536k"
      ]
    },
    {
      "destination""/dev/pts",
      "type""devpts",
      "source""devpts",
      "options"[
        "nosuid",
        "noexec",
        "newinstance",
        "ptmxmode=0666",
        "mode=0620",
        "gid=5"
      ]
    },
    {
      "destination""/dev/shm",
      "type""tmpfs",
      "source""shm",
      "options"[
        "nosuid",
        "noexec",
        "nodev",
        "mode=1777",
        "size=65536k"
      ]
    },
    {
      "destination""/dev/mqueue",
      "type""mqueue",
      "source""mqueue",
      "options"[
        "nosuid",
        "noexec",
        "nodev"
      ]
    },
    {
      "destination""/sys",
      "type""sysfs",
      "source""sysfs",
      "options"[
        "nosuid",
        "noexec",
        "nodev",
        "ro"
      ]
    },
    {
      "destination""/sys/fs/cgroup",
      "type""cgroup",
      "source""cgroup",
      "options"[
        "nosuid",
        "noexec",
        "nodev",
        "relatime",
        "ro"
      ]
    }
  ],
  "process"{
    "terminal": false,
    "user"{
      "uid": 0,
      "gid"0
    },
    "args"[
      "/http-server.wasm"
    ],
    "env"[
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "TERM=xterm"
    ],
    "cwd""/",
    "capabilities"{
      "bounding"[
        "CAP_KILL",
        "CAP_NET_BIND_SERVICE",
        "CAP_AUDIT_WRITE"
      ],
      "effective"[
        "CAP_KILL",
        "CAP_NET_BIND_SERVICE",
        "CAP_AUDIT_WRITE"
      ],
      "inheritable"[
        "CAP_KILL",
        "CAP_NET_BIND_SERVICE",
        "CAP_AUDIT_WRITE"
      ],
      "permitted"[
        "CAP_KILL",
        "CAP_NET_BIND_SERVICE",
        "CAP_AUDIT_WRITE"
      ],
      "ambient"[
        "CAP_KILL",
        "CAP_NET_BIND_SERVICE",
        "CAP_AUDIT_WRITE"
      ]
    },
    "rlimits"[
      {
        "type""RLIMIT_NOFILE",
        "hard": 1024,
        "soft"1024
      }
    ],
    "noNewPrivileges"true
  },
  "hostname""youki",
  "annotations"{
     "module.wasm.image/variant""compat"
  },
  "linux"{
    "resources"{
      "devices"[
        {
          "allow": false,
          "access""rwm"
        }
      ]
    },
    "namespaces"[
      {
        "type""pid"
      },
      {
        "type""network",
        "path""/proc/1/ns/net"
      },
      {
        "type""ipc"
      },
      {
        "type""uts"
      },
      {
        "type""mount"
      },
      {
        "type""cgroup"
      }
    ],
    "maskedPaths"[
      "/proc/acpi",
      "/proc/asound",
      "/proc/kcore",
      "/proc/keys",
      "/proc/latency_stats",
      "/proc/timer_list",
      "/proc/timer_stats",
      "/proc/sched_debug",
      "/sys/firmware",
      "/proc/scsi"
    ],
    "readonlyPaths"[
      "/proc/bus",
      "/proc/fs",
      "/proc/irq",
      "/proc/sys",
      "/proc/sysrq-trigger"
    ]
  }
}

通過 youki 啓動容器。

youki run wasm-demo-app

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

youki kill wasm-demo-app SIGKILL

5.2.3 高級容器運行時

在高級容器運行時中,使用不同的 shim 來對接各種低級容器運行時。在本節中,我們將以 containerd 爲例進行介紹。containerd shim 充當 containerd 和低級容器運行時之間的橋樑,其主要功能是抽象了底層運行時的細節,使 containerd 能夠統一地管理各種運行時。在 5.3 章節中將會介紹兩種 containerd 管理 Wasm 工作負載的方式:

5.2.3.1 Containerd + Crun

請確保按照 5.2.2.1 小節安裝好了 crun。

使用以下命令安裝 containerd。

export VERSION="1.7.3"
sudo apt install -y libseccomp2
sudo apt install -y wget

wget https://github.com/containerd/containerd/releases/download/v${VERSION}/cri-containerd-cni-${VERSION}-linux-amd64.tar.gz
wget https://github.com/containerd/containerd/releases/download/v${VERSION}/cri-containerd-cni-${VERSION}-linux-amd64.tar.gz.sha256sum
sha256sum --check cri-containerd-cni-${VERSION}-linux-amd64.tar.gz.sha256sum

sudo tar --no-overwrite-dir -C / -xzf cri-containerd-cni-${VERSION}-linux-amd64.tar.gz
sudo systemctl daemon-reload
sudo systemctl start containerd

然後我們可以通過 containerd 運行 Wasm 程序:

# 先拉取鏡像
ctr i pull docker.io/cr7258/wasm-demo-app:v1 

# 啓動容器
ctr run --rm --net-host \
--runc-binary crun \
--runtime io.containerd.runc.v2 \
--label module.wasm.image/variant=compat \
docker.io/cr7258/wasm-demo-app:v1 \
wasm-demo-app

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

ctr task kill wasm-demo-app --signal SIGKILL
5.2.3.2 Containerd + Youki

請確保按照 5.2.2.2 小節安裝好了 youki。

我們可以通過 containerd 運行 Wasm 程序,並指定使用 youki 來啓動容器。

ctr run --rm --net-host \
--runc-binary youki \
--runtime io.containerd.runc.v2 \
--label module.wasm.image/variant=compat \
docker.io/cr7258/wasm-demo-app:v1 wasm-demo-app

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

ctr task kill wasm-demo-app --signal SIGKILL
5.2.3.3 Containerd + Runwasi

runwasi 是一個用 Rust 編寫的庫,屬於 containerd 的子項目,使用 runwasi 可以編寫用於對接 Wasm 運行時的 containerd wasm shim,通過 Wasm 運行時可以管理 Wasm 工作負載。當前使用 runwasi 編寫的 containerd wasm shim 有以下幾個:

我們直接使用 runwasi 提供的 wasmedge shim 來運行 Wasm 應用,首先克隆 runwasi 倉庫。

git clone https://github.com/containerd/runwasi.git
cd runwasi

然後安裝編譯所需的依賴。

sudo apt-get -y install    \
      pkg-config          \
      libsystemd-dev      \
      libdbus-glib-1-dev  \
      build-essential     \
      libelf-dev          \
      libseccomp-dev      \
      libclang-dev        \
      libssl-dev

執行以下命令編譯文件。

make build
sudo make install

然後我們使用 containerd 通過 WasmEdge shim 來運行 Wasm 應用:

ctr run --rm --net-host \
--runtime=io.containerd.wasmedge.v1 \
docker.io/cr7258/wasm-demo-app:v1 \
wasm-demo-app

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

ctr task kill wasm-demo-app --signal SIGKILL

5.3 在編排平臺運行 Wasm 工作負載

5.3.1 Docker Desktop 運行 Wasm

Docker Desktop 也使用了 runwasi 來支持 Wasm 工作負載,要在 Docker Desktop 中運行 Wasm 工作負載需要確保勾選以下兩個選項:

點擊 Apply & restart 應用更新,Docker Desktop 會下載並安裝以下可用於運行 Wasm 工作負載的運行時:

在 Docker 中運行 WebAssembly 應用的方式與普通的 Linux 容器沒有太大區別,只需要通過 --runtime=io.containerd.wasmedge.v1 指定使用相應的 Wasm 運行時即可。

docker run -d -p 8080:8080 \
--name=wasm-demo-app \
--runtime=io.containerd.wasmedge.v1 \
docker.io/cr7258/wasm-demo-app:v1

在本地通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

如果想要停止並刪除容器,可以執行以下命令。

docker rm -f wasm-demo-app

5.3.2 在 Kubernetes 中運行 Wasm 模塊

Kubernetes 作爲容器編排領域的事實標準,WebAssembly 正在推動雲計算的第三次浪潮 [4],而 Kubernetes 正在不斷髮展以利用這一優勢。

在 Kubernetes 中運行 Wasm 工作負載有兩種方式:

Kind(Kubernetes in Docker) 是一個使用 Docker 容器運行本地 Kubernetes 集羣的工具。爲了方便實驗,在 5.3.2 章節中將使用 Kind 來創建 Kubernetes 集羣。使用以下命令安裝 Kind。

[ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind

Kubectl 是用於管理 Kubernetes 集羣的命令行工作,執行以下命令安裝 Kubectl。

curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/kubectl
5.3.2.1 Kubernetes + Containerd + Crun

使用以下命令創建一個單節點的 Kubernetes 集羣。

kind create cluster --name wasm-demo

每個 Kubernetes Node 都是一個 Docker 容器,通過 docker exec 命令進入該節點。

docker exec -it  wasm-demo-control-plane bash

進入節點後,請確保按照 5.2.2.1 小節安裝好了 crun。

修改 containerd 配置文件 /etc/containerd/config.toml,在文件末尾添加以下內容。

cat >> /etc/containerd/config.toml << EOF
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun]
    runtime_type = "io.containerd.runc.v2"
    pod_annotations = ["module.wasm.image/variant"]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.crun.options]
    BinaryName = "crun"
EOF

然後重啓 containerd。

systemctl restart containerd

創建一個名爲 crun 的 RuntimeClass 資源,並使用之前在 containerd 中設置的 crun handler。接下來,在 Pod Spec 中指定 runtimeClassName 來使用該 RuntimeClass,以告知 kubelet 使用所指定的 RuntimeClass 來運行該 Pod。此外,設置 Annotation module.wasm.image/variant: compat,告訴 crun 這是一個 Wasm 工作負載。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: crun
handler: crun
---
apiVersion: v1
kind: Pod
metadata:
  name: wasm-demo-app
  annotations:
    module.wasm.image/variant: compat
spec:
  runtimeClassName: crun
  containers:
  - name: wasm-demo-app
    image: docker.io/cr7258/wasm-demo-app:v1

可以通過 port-forward 將端口轉發到本地進行訪問。

kubectl port-forward pod/wasm-demo-app 8080:8080

然後在另一個終端通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

測試完畢後,銷燬該集羣。

kind delete cluster --name wasm-demo
5.3.2.2 KWasm Operator

Kwasm 是一個 Kubernetes Operator,可以爲 Kubernetes 節點添加 WebAssembly 支持。當你想爲某個節點增加 Wasm 支持時,只需爲該節點添加 kwasm.sh/kwasm-node=true 的 Annotation 。隨後,Kwasm 會自動創建一個 Job,負責在該節點上部署運行 Wasm 所需的二進制文件,並對 containerd 的配置進行相應的修改。

使用以下命令創建一個單節點的 Kubernetes 集羣。

kind create cluster --name kwasm-demo

Kwasm 提供了 Helm chart 方便用戶進行安裝,先執行以下命令安裝 Helm。

curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod 700 get_helm.sh
./get_helm.sh

然後安裝 Kwasm Operator,爲所有節點添加 Annotation kwasm.sh/kwasm-node=true 啓用對 Wasm 的支持。

# 添加 Helm repo
helm repo add kwasm http://kwasm.sh/kwasm-operator/
# 安裝 KWasm operator
helm install -n kwasm --create-namespace kwasm-operator kwasm/kwasm-operator
# 爲節點添加 Wasm 支持
kubectl annotate node --all kwasm.sh/kwasm-node=true

創建一個名爲 crun 的 RuntimeClass 資源,並使用之前在 containerd 中設置的 crun handler。接下來,在 Pod Spec 中指定 runtimeClassName 來使用該 RuntimeClass,以告知 kubelet 使用所指定的 RuntimeClass 來運行該 Pod。此外,設置 Annotation module.wasm.image/variant: compat,告訴 crun 這是一個 Wasm 工作負載。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: crun
handler: crun
---
apiVersion: v1
kind: Pod
metadata:
  name: wasm-demo-app
  annotations:
    module.wasm.image/variant: compat
spec:
  runtimeClassName: crun
  containers:
  - name: wasm-demo-app
    image: docker.io/cr7258/wasm-demo-app:v1

Pod 運行成功後,可以通過 port-forward 將端口轉發到本地進行訪問。

kubectl port-forward pod/wasm-demo-app 8080:8080

我們在另一個終端通過 curl 命令訪問該服務。

curl http://localhost:8080
Hello, World!

測試完畢後,銷燬該集羣。

kind delete cluster --name kwasm-demo
5.3.2.3 Krustlet

Krustlet 是一個由 Rust 語言編寫的 kubelet,它在 Kubernetes 集羣中作爲一個節點,專門用於運行 Wasm 工作負載。當 Kubernetes 調度器將 Pod 調度到 Krustlet 節點時,Krustlet 會利用 Wasm 運行時來啓動相應的 Wasm 工作負載。儘管 Krustlet 項目目前已經很久沒有更新了,但是還是值得了解一番。

使用以下命令創建一個單節點的 Kubernetes 集羣。這裏通過 --image 參數指定創建 1.21.14 版本的 Kubernetes 集羣,Krustlet 最近一次更新還是在去年,可能不兼容最新的 Kubernetes 版本。我在最新的 Kubernetes 集羣上測試後,發現 Krustlet 無法正常工作。

kind create cluster --name krustlet-demo --image kindest/node:v1.21.14@sha256:8a4e9bb3f415d2bb81629ce33ef9c76ba514c14d707f9797a01e3216376ba093

接下來我們需要啓動一個 Krustlet 節點,並將它加入集羣。對於普通的節點,我們可以使用 kubeadm join 命令很方便的將節點加入集羣。因爲 kubeadm 會替你做很多事,例如生成 bootstrap token,生成 kubelet 證書等等。

對於 Krustlet 節點我們就需要手動處理這些事情了,我們可以使用 Krustlet 官方準備的腳本。這個腳本會爲我們創建 bootstrap token,這個 token 是 Krustlet 初始化時和 API Server 臨時通信而使用的。腳本還會根據 token 生成 Krustlet 臨時的 kubeconfig 文件,默認在 console ~/.krustlet/config/kubeconfig

```bash
bash <(curl https://raw.githubusercontent.com/krustlet/krustlet/main/scripts/bootstrap.sh)

接着執行以下命令安裝 Krustlet 二進制文件。

wget https://krustlet.blob.core.windows.net/releases/krustlet-v1.0.0-alpha.1-linux-amd64.tar.gz
tar -xzvf krustlet-v1.0.0-alpha.1-linux-amd64.tar.gz
mv krustlet-wasi /usr/local/bin/krustlet-wasi

最後,運行以下命令來啓動 Krustlet:

KUBECONFIG=~/.krustlet/config/kubeconfig \
krustlet-wasi \
--node-ip 172.17.0.1 \
--node-name=krustlet \
--bootstrap-file=${HOME}/.krustlet/config/bootstrap.conf

啓動 Krustlet 後,提示我們需要手動批准 CSR 請求。當然我們也可以設置自動批准,這裏先不展開說明。

BOOTSTRAP: TLS certificate requires manual approval. Run kubectl certificate approve instance-2-tls

執行以下命令,手動批准 CSR 請求。我們只需要在 Krustlet 第一次啓動時執行此步驟,之後它會將所需的憑證保存下來。

kubectl certificate approve instance-2-tls

然後查看節點,就可以看到 Krustlet 節點已經成功註冊到 Kubernetes 集羣中了。

# kubectl get nodes -o wide
NAME                          STATUS   ROLES                  AGE     VERSION         INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION    CONTAINER-RUNTIME
krustlet                      Ready    <none>                 30s     1.0.0-alpha.1   172.17.0.1    <none>        <unknown>                        <unknown>         mvp
krustlet-demo-control-plane   Ready    control-plane,master   4m17s   v1.21.14        172.18.0.2    <none>        Debian GNU/Linux 11 (bullseye)   5.19.0-1030-gcp   containerd://1.7.1

查看節點信息,其架構顯示是 wasm-wasi,並且節點上有 kubernetes.io/arch=wasm32-wasi:NoExecute 和 kubernetes.io/arch=wasm32-wasi:NoSchedule 兩個污點,我們在創建 Pod 時需要指定容忍該污點才能調度到 Krustlet 節點上。

# kubectl describe node krustlet
Name:               krustlet
Roles:              <none>
Labels:             beta.kubernetes.io/arch=wasm32-wasi
                    beta.kubernetes.io/os=wasm32-wasi
                    kubernetes.io/arch=wasm32-wasi
                    kubernetes.io/hostname=instance-2
                    kubernetes.io/os=wasm32-wasi
                    type=krustlet
Annotations:        node.alpha.kubernetes.io/ttl: 0
                    volumes.kubernetes.io/controller-managed-attach-detach: true
CreationTimestamp:  Tue, 29 Aug 2023 02:55:19 +0000
Taints:             kubernetes.io/arch=wasm32-wasi:NoExecute
                    kubernetes.io/arch=wasm32-wasi:NoSchedule
Unschedulable:      false
Lease:
  HolderIdentity:  krustlet
  AcquireTime:     Tue, 29 Aug 2023 02:55:49 +0000
  RenewTime:       Tue, 29 Aug 2023 02:55:49 +0000
Conditions:
  Type        Status  LastHeartbeatTime                 LastTransitionTime                Reason                     Message
  ----        ------  -----------------                 ------------------                ------                     -------
  Ready       True    Tue, 29 Aug 2023 02:55:49 +0000   Tue, 29 Aug 2023 02:55:19 +0000   KubeletReady               kubelet is posting ready status
  OutOfDisk   False   Tue, 29 Aug 2023 02:55:19 +0000   Tue, 29 Aug 2023 02:55:19 +0000   KubeletHasSufficientDisk   kubelet has sufficient disk space available
Addresses:
  InternalIP:  172.17.0.1
  Hostname:    instance-2
Capacity:
  cpu:                4
  ephemeral-storage:  61255492Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             4032800Ki
  pods:               110
Allocatable:
  cpu:                4
  ephemeral-storage:  61255492Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             4032800Ki
  pods:               110
System Info:
  Machine ID:
  System UUID:
  Boot ID:
  Kernel Version:
  OS Image:
  Operating System:           linux
  Architecture:               wasm-wasi
  Container Runtime Version:  mvp
  Kubelet Version:            1.0.0-alpha.1
  Kube-Proxy Version:         v1.17.0
PodCIDR:                      10.244.0.0/24
PodCIDRs:                     10.244.0.0/24
Non-terminated Pods:          (0 in total)
  Namespace                   Name    CPU Requests  CPU Limits  Memory Requests  Memory Limits  Age
  ---------                   ----    ------------  ----------  ---------------  -------------  ---
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests  Limits
  --------           --------  ------
  cpu                0 (0%)    0 (0%)
  memory             0 (0%)    0 (0%)
  ephemeral-storage  0 (0%)    0 (0%)
  hugepages-1Gi      0 (0%)    0 (0%)
  hugepages-2Mi      0 (0%)    0 (0%)
Events:
  Type    Reason          Age   From             Message
  ----    ------          ----  ----             -------
  Normal  RegisteredNode  36s   node-controller  Node krustlet event: Registered Node krustlet in Controller

和前面直接在容器運行時裏運行 Wasm 鏡像不同,Krustlet 只支持 media types 是 application/vnd.wasm.config.v1+json 的 OCI 鏡像,我們之前構建的鏡像的 media types 是 application/vnd.oci.image.layer.v1.tar+gzip。詳情參見:Open Containers Initiative [5]

因此我們需要使用 wasm-to-oci 這個工具來構建鏡像,wasm-to-oci 是一個用於將 Wasm 模塊發佈到註冊表的工具,它打包模塊並將其上傳到註冊表。執行以下命令,安裝 wasm-to-oci。

wget https://github.com/engineerd/wasm-to-oci/releases/download/v0.1.2/linux-amd64-wasm-to-oci
mv linux-amd64-wasm-to-oci /usr/local/bin/wasm-to-oci
chmod +x /usr/local/bin/wasm-to-oci

當前暫不支持將 Wasm 模塊直接推送到 Docker Hub 上,因此這裏我們選擇使用 GitHub Package Registry [6] 來存放 Wasm 模塊。

docker login ghcr.io
Username:  # Github 用戶名
Password:  # Github Token

另外由於 Krustlet 是基於 wasmtime 來運行 Wasm 工作負載的,並且 wasmitime 目前暫不支持 HTTP,詳情參見:WASI Proposals Support [7]

因此我們這裏寫一個簡單的打印 Hello, World 的 Rust 程序。執行以下命令構建一個新的 Rust 項目。

cargo new hello-world

然後在 main.rs 文件中添加以下代碼。

use std::thread;
use std::time::Duration;

fn main() {
    loop {
        println!("Hello, World!");
        thread::sleep(Duration::from_secs(1));
    }
}

執行以下命令,將程序編譯爲 Wasm 模塊。

cargo build --target wasm32-wasi --release

使用 wasm-to-oci 將編譯好的 Wasm 模塊上傳到 GitHub Package Registry。

wasm-to-oci push target/wasm32-wasi/release/hello-world.wasm ghcr.io/cr7258/wasm-demo-app:oci

可以看到鏡像的 media types 是 application/vnd.wasm.config.v1+json

爲了方便測試,我們將鏡像設置爲公開的。

然後創建 Pod 使用該鏡像,添加容忍運行調度到 Krustlet 節點上,由於我們的 Kubernetes 集羣中只有一個節點,因此不用設置節點選擇器。

apiVersion: v1
kind: Pod
metadata:
  name: wasm-demo-app
spec:
  containers:
    - name: wasm-demo-app
      image: ghcr.io/cr7258/wasm-demo-app:oci
  tolerations:
    - key: "kubernetes.io/arch"
      operator: "Equal"
      value: "wasm32-wasi"
      effect: "NoExecute"
    - key: "kubernetes.io/arch"
      operator: "Equal"
      value: "wasm32-wasi"
      effect: "NoSchedule"

查看 Pod 日誌可以看到每隔 1s 打印 Hello, World!。

kubectl logs wasm-demo-app

Hello, World!
Hello, World!
Hello, World!

測試完畢後,銷燬該集羣。

kind delete cluster --name krustlet-demo

6 總結

本文首先闡述了 WebAssembly 基本概念以及其相較於傳統容器的優勢,然後介紹了使用 Rust 開發 Wasm 應用的流程。接着,爲讀者詳細展示了在各種環境中運行 Wasm 工作負載的方法,涵蓋了在 Linux 容器、支持 Wasm 的容器運行時,以及編排平臺上的運行方法。

本文使用到的代碼以及配置文件可以在我的 Github 上找到:https://github.com/cr7258/hands-on-lab/tree/main/wasm/runtime 。

7 附錄

7.1 關於 compat 和 compat-smart 註解

本文中使用 "module.wasm.image/variant": "compat" Annotation 來告訴容器運行時這是 Wasm 工作負載,當前 crun 支持了一個新的 Annotation "module.wasm.image/variant": "compat" 。詳情參見:WasmEdge issue: Add crun "-smart" annotation [8]

當使用 compat-smart 註解時,crun 可以根據工作負載是 Wasm 還是普通 OCI 容器來智能地選擇容器的啓動方式。這種選擇只會在標準 OCI 容器和 Wasm 應用程序位於同一個 pod 中時產生影響。下面是一個示例的 Pod 資源文件,其中包含一個 Wasm 應用程序和一個普通的 Linux 應用程序。

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: crun
handler: crun
---
apiVersion: v1
kind: Pod
metadata:
  name: wasm-demo-app
  annotations:
    module.wasm.image/variant: compat-smart
spec:
  runtimeClassName: crun
  containers:
  - name: wasm-demo-app
    image: docker.io/cr7258/wasm-demo-app:v1
  - name: linux-demo-app
    image: nginx:1.20

7.2 Krustlet 報錯

在啓動 Krustlet 的時候可能會遇到以下報錯:

libssl.so.1.1: cannot open shared object file: No such file or directory

原因是 Krustlet 依賴 openssl 1.1 版本,可以參考該鏈接解決:解決報錯 libssl.so.1.1 [9]

7.3 WasmEdge 報錯

在用容器運行時啓動容器的時候可能會出現以下報錯。

FATA[0000] failed to create shim task: OCI runtime create failed: could not load `libwasmedge.so.0``libwasmedge.so.0: cannot open shared object file: No such file or directory`: unknown

重新執行 WasmEdge 安裝命令。

curl -sSf https://raw.githubusercontent.com/WasmEdge/WasmEdge/master/utils/install.sh | bash

8 參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/vRtNd-eMgUhP9hq4s3vkeA