WebAssembly außerhalb des Browsers: WASI und server-side Wasm
1. Überblick
WebAssembly (Wasm) wurde entwickelt, um hochperformanten Code im Browser auszuführen. Aber seine Eigenschaften (kompakt, strikte Sandbox, CPU-architekturunabhängig) sind auch auf der Serverseite sehr attraktiv.
In diesem Artikel wirst du verstehen, was WASI ist und warum server-side Wasm gerade an Aufmerksamkeit gewinnt. Du wirst ein Wasm-Modul bauen, es mit einer CLI-Runtime ausführen, es in eine Java-21-Anwendung einbetten und Tests schreiben. Am Ende des Artikels folgen Fälle, in denen man Wasm nicht verwenden sollte.
2. Warum WebAssembly WASI braucht
2.1. „Nacktes” Wasm kann nicht mit der Außenwelt interagieren
Ein reines Wasm-Modul kann nur auf seinem linearen Speicher (linear memory) rechnen. Es kann nicht selbstständig Dateien öffnen, Umgebungsvariablen lesen, die Systemzeit abrufen oder Sockets öffnen. Jede Interaktion mit der Außenwelt muss über Funktionen erfolgen, die der Host (die Ausführungsumgebung des Moduls) als Imports bereitstellt.
Im Browser ist der Host JavaScript. Außerhalb des Browsers gab es keinen vereinheitlichten Host. Wenn jede Runtime ihre eigene API definiert, läuft ein Modul, das für diese Runtime gebaut ist, nicht auf einer anderen.
2.2. WASI: Standard-Systemschnittstelle für Wasm
WASI (WebAssembly System Interface) ist ein Standard-API-Satz für betriebssystem-ähnliche Operationen: Dateien, Uhr, Zufallszahlen, Umgebungsvariablen, Befehlszeilenargumente und in neueren Versionen auch Netzwerk. Module, die auf WASI abzielen, können auf jeder Runtime laufen, die WASI unterstützt, beispielsweise Wasmtime, WasmEdge, Wasmer oder eine in deine Anwendung eingebettete Runtime.
Der wichtigste Unterschied ist das Capability-Modell. Module haben standardmäßig keine Berechtigungen. Der Host muss Berechtigungen explizit erteilen, beispielsweise „Zugriff auf das Verzeichnis ./data“. Ohne Berechtigung schlägt der Aufruf fehl. Das ist das Gegenteil von normalen Prozessen, die alle Berechtigungen des ausführenden Benutzers erben.
2.3. WASI Preview 1, Preview 2 und Component Model
WASI hat mehrere Phasen durchlaufen. Du wirst auf zwei häufige Namen treffen:
- Preview 1 (wasip1): API in C-ähnlicher Funktionsform, am weitesten verbreitet.
- Preview 2 (wasip2): aufgebaut auf dem Component Model. Schnittstellen werden in einer IDL-Sprache namens WIT beschrieben. Komponenten in verschiedenen Sprachen können über reichere Datentypen wie Strings, Records und Listen miteinander kommunizieren, anstatt nur mit Ganzzahlen.
Die Unterstützung variiert je nach Runtime und Toolchain. Bevor du dich entscheidest, solltest du die Dokumentation der Runtime überprüfen, die du verwenden möchtest.
3. Warum wird server-side Wasm beachtet?
Es gibt vier Hauptgründe:
- Standard-Sandbox: Module können nicht auf Speicher außerhalb ihres Bereichs zugreifen und haben keine Systemberechtigungen, wenn diese nicht erteilt wurden. Das eignet sich sehr gut zum Ausführen von nicht vertrauenswürdigem Code, wie Plugins von Kunden oder von Benutzern hochgeladene Skripte.
- Schneller Start, kleiner Footprint: Module sind typischerweise leicht und starten viel schneller als ein Container oder eine neue JVM. Darum eignet sich Wasm für Serverless und Edge, wo jede Anfrage eine eigene Instanz benötigen kann.
- Portabel: Eine
.wasm-Datei läuft auf x86 oder ARM, Linux oder macOS, solange eine Runtime vorhanden ist. - Mehrsprachig: Rust, C/C++, Go, Zig, AssemblyScript… können alle zu Wasm kompiliert werden. Deine Java-Anwendung kann Logik in anderen Sprachen laden, ohne JNI zu benötigen.
Schnellvergleich mit Containern:
| Kriterium | Container | Wasm + WASI |
|---|---|---|
| Packungseinheit | Image enthält komplettes Userland-OS | Eine .wasm-Datei |
| Isolierung | Kernel-Namespaces/Cgroups | Sandbox der Runtime + Capabilities |
| Architekturabhängig | Image pro Architektur erforderlich | Nein |
| Ökosystem | Sehr reif | Sich entwickelnd |
Wasm ersetzt Container nicht. Die beiden ergänzen sich oft: Du kannst eine Wasm-Runtime immer noch in einem Container ausführen.
4. Ein Wasm-Programm bauen und ausführen
Das Beispiel verwendet Rust, weil die Rust-Wasm-Toolchain recht bequem ist. Die gleiche Idee lässt sich auf andere Sprachen anwenden.
4.1. Ein Programm zum Lesen von Bestellungsdateien
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("Gesamtbestellwert: {total}");
}
Err(e) => eprintln!("Datei konnte nicht gelesen werden: {e}"),
}
}
Das Programm liest orders.csv, nimmt die zweite Spalte (Betrag) und addiert sie auf. Der Code hat nichts Besonderes für Wasm: std::fs wird beim Kompilieren auf WASI-Aufrufe abgebildet.
Target hinzufügen und bauen:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
Das Ergebnis ist die Datei target/wasm32-wasip1/release/order-report.wasm (Name nach deinem Paket).
4.2. Mit Wasmtime ausführen und Capabilities in Aktion sehen
Erstelle eine Datendatei:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
Führe ohne Verzeichnisberechtigung aus:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
Datei konnte nicht gelesen werden: failed to find a pre-opened file descriptor through which "orders.csv" could be opened
Das Modul läuft, aber es sieht keine Dateien. Es hat keine Berechtigung für das Dateisystem. Jetzt geben wir dem Verzeichnis Zugriff:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Gesamtbestellwert: 500
Das --dir-Flag „öffnet vor” das Verzeichnis für das Modul. Das Modul sieht nur dieses Verzeichnis, nicht /etc oder dein Home-Verzeichnis. Der Fehlertext kann zwischen Runtime-Versionen unterschiedlich sein, aber das Verhalten ist gleich.
Hinweis: Die Flag-Syntax (
--dir,--env, Host-Gast-Verzeichnismapping) kann sich zwischen Wasmtime-Versionen ändern. Überprüfewasmtime run --helpauf deinem Rechner.
5. Wasm in eine Java-Anwendung einbetten
Server-side Wasm ist nicht nur das Ausführen von .wasm-Dateien über CLI. Eine sehr praktische Anwendung ist das Einbetten einer Runtime in eine bestehende Anwendung, um Plugins oder von Drittanbietern bereitgestellte Logik auszuführen.
Mit Java gibt es zwei Ansätze:
- Bindings zur nativen Runtime (Wasmtime, WasmEdge) über JNI oder FFM.
- Eine Runtime, die vollständig in Java geschrieben ist, wie Chicory. Keine nativen Bibliotheken erforderlich, daher einfach zu verpacken und überall auf einer JVM auszuführen.
Das folgende Beispiel verwendet Chicory.
5.1. Versandgebühren-Modul
Angenommen, ein Partner stellt die Versandgebühren-Logik als Wasm zur Verfügung:
// src/lib.rs, Cargo.toml hat: [lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
Dieses Modul verwendet WASI nicht. Es rechnet nur, also genügt das wasm32-unknown-unknown-Target und das Modul benötigt keine Imports.
5.2. Maven-Abhängigkeit
<properties>
<java.version>21</java.version>
<!-- Überprüfe die neueste Version auf Maven Central -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Wenn du Spring Boot 3.x verwendest, werden die Versionen von JUnit und AssertJ bereits über spring-boot-starter-test verwaltet.
5.3. Eine Wasm-Funktion aus Java aufrufen
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("Modul konnte nicht geladen werden " + resource, e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
Erklärung:
Parser.parse(...)liest den Wasm-Bytecode in ein Modul-Modell.Instance.builder(module).build()erstellt eine Instanz mit eigenem Speicher. Das Modul importiert nichts, daher müssen keine Host-Funktionen bereitgestellt werden.instance.export("shipping_fee")ruft die exportierte Funktion ab. Der Name muss mit dem#[no_mangle]in Rust übereinstimmen.apply(...)empfängt und gibtlong[]zurück, da Wasm-Zahlentypen (i32,i64…) alle alslongdargestellt werden. Du casten selbst zuint.
Test ausführen:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
Das Ergebnis besteht aus 15.000 Basisgebühr, plus 2 × 5.000 nach Gewicht (1.200 / 500 = 2), plus 10 × 1.000 nach Entfernung.
5.4. Verhalten testen
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void givenMissingModule_whenCreate_thenFailFast() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
Der dritte Test stellt sicher, dass die Anwendung sofort beim Start fehlschlägt, wenn das Modul fehlt, anstatt beim ersten Request zu brechen.
Hinweis: Eine
Instancehat ihren eigenen Speicher und Zustand. Gehe nicht davon aus, dass sie standardmäßig zwischen mehreren Threads sicher geteilt wird. Für ein reines Rechenmodul ist die einfachste Lösung, pro Request eine Instanz zu erstellen oder einen Instance-Pool zu verwenden. Das geparste Modul kann wiederverwendet werden, um mehrere Instanzen zu erstellen.
5.5. Wenn das Modul WASI benötigt
Wenn das Plugin Dateien lesen oder in stdout schreiben muss, importiert es WASI-Funktionen. Du musst diese Funktionen beim Erstellen der Instanz bereitstellen. Chicory hat ein separates wasi-Modul dafür. Das Prinzip bleibt Capabilities: Gebe nur die Verzeichnisse, Umgebungsvariablen und stdout/stderr an, die das Plugin wirklich braucht. Erbe nicht die ganze Host-Umgebung der Bequemlichkeit halber.
6. Praktische Anwendungsmuster
- Plugin-Systeme: Lassen Sie Kunden oder Partner Logik anpassen (Preisberechnung, Regelprüfung, Datentransformation), ohne ihren Code direkt in der JVM auszuführen.
- Policy Engine: Verpacke Autorisierungsregeln in Wasm-Modulen, um sie zwischen mehreren Services in verschiedenen Sprachen zu teilen.
- Serverless und Edge Functions: Jede Anfrage läuft in einer isolierten Instanz mit schnellem Start.
- Wiederverwendung nativer Bibliotheken: Kompiliere eine C/Rust-Bibliothek (Parser, Codec, Algorithmus) zu Wasm und rufe sie aus Java auf, um JNI und die Risiken von JVM-Crashes zu vermeiden.
- Erweiterungen für die Infrastruktur: Einige Proxys, Datenbanken und Message Broker ermöglichen Erweiterungen mit Wasm.
7. Wann man Wasm NICHT verwenden sollte
Server-side Wasm ist verlockend, aber nicht immer die richtige Wahl:
- Logik, die bereits gut in Java geschrieben ist und keine Isolierung benötigt: Das Hinzufügen einer Wasm-Schicht erhöht nur die Komplexität, die Datenübertragungskosten und erschwert das Debugging.
- Workloads, die schweres Multithreading, GPU oder spezialisierte SIMD benötigen: Thread-Unterstützung und Hardware-Features in Wasm sind immer noch eingeschränkt und nicht einheitlich zwischen Runtimes.
- Anwendungen, die stark von Netzwerk und Ökosystem abhängen: Sockets, TLS, Datenbanktreiber über WASI sind nicht so vollständig wie auf einem echten OS. Viele Bibliotheken lassen sich noch nicht zu Wasm kompilieren.
- Häufiger Austausch großer Datenmengen: Jeder Transfer von Strings oder Arrays zwischen Host und Guest muss in den linearen Speicher kopiert werden. Wenn du Wasm-Funktionen Millionen Male mit großen Payloads aufrufen wirst, ist dieser Kostenpunkt erheblich. Versuche, die Arbeit in größere, weniger häufige Aufrufe zusammenzufassen.
- Annahme, dass Sandbox gleichbedeutend mit absoluter Sicherheit ist: Wasm isoliert Speicher, blockiert aber nicht von selbst Endlosschleifen oder CPU-Erschöpfung. Du benötigst immer noch Timeouts, Speicherlimits und ein Unterbrechungsmechanismus, wenn die Runtime ihn unterstützt (Fuel, Epoch Interruption…).
- Team nicht bereit: Toolchain, Debugger und Observability für Wasm sind immer noch in Entwicklung. Füge es nicht in kritische Systeme nur wegen des Hype ein.
Einige häufige Fehler:
- Falsches Target gebaut. Module, die
std::fsoderprintln!verwenden, benötigen das WASI-Target. Reine Rechenmodule verwendenwasm32-unknown-unknown. #[no_mangle]oderextern "C"vergessen, sodass die Funktion nicht in der Exportliste erscheint.- Typverwechslung: Wasm hat nur primitive Zahlentypen. Um Strings zu übergeben, musst du selbst die Zuordnung im linearen Speicher verwalten oder das Component Model mit WIT verwenden.
- Zu breite Berechtigungen erteilt (
--dir=/, ganze Env erbt), wodurch der größte Vorteil von WASI verloren geht.
8. Fazit
WASI verwandelt WebAssembly von einer Browser-Technologie in ein portables, leichtgewichtiges und sicher isoliertes Ausführungsformat auf der Serverseite. Das Capability-Modell (keine Berechtigungen ohne explizite Gewährung) ist der Kernunterschied zu traditionellen Prozessen und Containern.
In diesem Artikel hast du ein WASI-Programm gebaut und gesehen, wie Dateisystem-Berechtigungen kontrolliert werden. Du hast auch ein Wasm-Modul in eine Java-21-Anwendung mit einer reinen Java-Runtime eingebettet und Tests dafür geschrieben.
Erwäge Wasm, wenn du nicht vertrauenswürdigen Code ausführen musst, mehrsprachige Plugins benötigst oder extrem schnelle Starts brauchst. Aber wenn das Problem nur normale Geschäftslogik in einem Java-Service ist, ist die einfachste Lösung meist immer noch die beste.