Développement Web

WASI et serveurs WebAssembly : exécuter un module avec Wasmtime pas-à-pas

12 min de lecture

Faire tourner du code WebAssembly côté serveur change la donne par rapport au navigateur. On bascule de l’environnement wasm32-unknown-unknown, où tout doit transiter par JavaScript, à une cible WASI où le module peut lire un fichier, exposer un service HTTP, et recevoir des capacités explicites de l’hôte. Wasmtime, maintenu par la Bytecode Alliance, est devenu le runtime de référence du modèle de composants : il supporte WASI 0.2 et 0.3, dispose d’un mode AOT, embarque un debugger GDB et se distribue à la fois comme binaire autonome et comme crate Rust.

Ce tutoriel construit pas-à-pas un projet WASI complet : on compile un programme Rust vers wasm32-wasip2, on l’exécute via la CLI Wasmtime, on lui ajoute un endpoint HTTP via wasi-http, puis on embarque le tout dans un binaire hôte Rust qui pilote l’instanciation et restreint les capacités. Pour le panorama des choix de runtime, on se référera au guide principal sur WebAssembly en production.

Étape 1 — Installer Wasmtime et la cible Rust WASI

Wasmtime se distribue via un installeur officiel multi-OS qui pose le binaire dans ~/.wasmtime/ et met à jour le PATH. Il existe aussi des paquets Homebrew, Scoop et apt — la voie installeur reste la plus directe pour figer une version précise.

Sur Linux ou macOS, on récupère la version stable courante avec le script publié sur wasmtime.dev. Le script télécharge l’archive correspondant à l’OS, vérifie sa signature, et expose les commandes wasmtime et wasmtime-cli.

curl https://wasmtime.dev/install.sh -sSf | bash
exec $SHELL
wasmtime --version

Le résultat attendu affiche la version 44.0.0 (ou supérieure, selon la date d’installation). Côté Rust, on ajoute la cible wasm32-wasip2 via rustup. C’est la cible Tier 2 introduite dans Rust 1.82 qui produit directement un composant WASI 0.2, prêt à charger dans Wasmtime sans étape de conversion.

rustup target add wasm32-wasip2
rustup show

La sortie liste les cibles installées : on doit y voir wasm32-wasip2 (installed). Cette cible nécessite Rust 1.82 minimum ; un message « unknown target » trahit une toolchain plus ancienne — on met à jour avec rustup update stable.

Étape 2 — Premier programme WASI en Rust

On crée un projet binaire standard avec Cargo. La différence avec un projet desktop est qu’il sera compilé vers wasm32-wasip2 et qu’il s’exécutera sous le contrôle d’un hôte qui ne fournit que les capacités autorisées.

cargo new --bin hello-wasi
cd hello-wasi

Le code par défaut affiche « Hello, world! ». On le remplace par un programme légèrement plus parlant qui lit la variable d’environnement NAME et affiche un message. Cela permettra de tester rapidement le modèle de capabilités.

// src/main.rs
use std::env;

fn main() {
    let name = env::var("NAME").unwrap_or_else(|_| "anonyme".to_string());
    println!("Bonjour, {} ! Le binaire tourne dans Wasmtime.", name);
}

On compile en mode release pour la cible WASI. L’argument --target fait toute la différence : on n’obtient pas un binaire ELF, mais un fichier .wasm sous target/wasm32-wasip2/release/.

cargo build --target wasm32-wasip2 --release
ls target/wasm32-wasip2/release/hello-wasi.wasm

Le binaire pèse typiquement 100-200 ko en mode release standard. file target/wasm32-wasip2/release/hello-wasi.wasm retourne « WebAssembly (wasm) binary module », ce qui confirme le format de sortie.

Étape 3 — Exécuter le module via la CLI Wasmtime

Wasmtime peut exécuter directement un composant WASI. Sans aucune option, le module hérite du strict minimum : pas de fichiers, pas de variables d’environnement, pas de horloge réelle. C’est l’inverse d’un binaire natif qui hérite par défaut de tout l’environnement du parent.

wasmtime target/wasm32-wasip2/release/hello-wasi.wasm

La sortie affiche « Bonjour, anonyme ! Le binaire tourne dans Wasmtime. » — la variable NAME n’étant pas fournie, le code retombe sur la valeur par défaut. On accorde maintenant la capacité variable d’environnement en la passant explicitement.

wasmtime --env NAME=Mariama target/wasm32-wasip2/release/hello-wasi.wasm

Cette fois, la sortie devient « Bonjour, Mariama ! ». L’argument --env CLE=VALEUR peut se répéter ; il peut aussi prendre la forme --env CLE (sans valeur) pour hériter de la variable du shell parent. On voit déjà la différence avec un binaire natif : le code WebAssembly n’a accès à aucune variable que l’hôte n’a explicitement câblée.

Étape 4 — Accorder un accès fichier limité

Pour démontrer le modèle préopen, on modifie le programme pour lire un fichier. Si l’hôte n’a pas accordé l’accès au répertoire, l’appel échoue avec une erreur WASI explicite.

// src/main.rs
use std::env;
use std::fs;

fn main() {
    let path = env::var("INPUT").unwrap_or_else(|_| "/data/message.txt".into());
    match fs::read_to_string(&path) {
        Ok(s) => println!("Contenu : {}", s.trim()),
        Err(e) => eprintln!("Erreur lecture {} : {}", path, e),
    }
}

On recompile, puis on crée un fichier d’entrée local et on l’expose comme préopen /data côté module. La syntaxe --dir LOCAL::WASM précise le mapping entre un chemin hôte et le nom vu par le module.

cargo build --target wasm32-wasip2 --release
mkdir -p data
echo "WebAssembly serveur" > data/message.txt
wasmtime --dir ./data::/data --env INPUT=/data/message.txt   target/wasm32-wasip2/release/hello-wasi.wasm

La sortie affiche « Contenu : WebAssembly serveur ». Si l’on retire l’argument --dir, l’appel échoue avec « No such file or directory » alors même que le fichier existe : le module ne voit tout simplement pas le chemin tant qu’il n’a pas été préouvert. C’est la principale différence avec un processus Linux où la non-existence et l’absence d’autorisation se mélangent.

Étape 5 — Exposer un service HTTP via wasi-http

WASI 0.2 inclut une interface HTTP standardisée appelée wasi:http/proxy. Wasmtime sait l’exécuter via la sous-commande wasmtime serve, disponible depuis Wasmtime 18.0.0. On va construire un composant qui reçoit une requête et renvoie une réponse JSON.

On crée un nouveau projet et l’on installe cargo-component, le pendant officiel de cargo pour les composants WebAssembly.

cargo install cargo-component
cargo component new --lib http-service --proxy
cd http-service

cargo component initialise un projet déjà câblé pour produire un composant WASI 0.2. Le fichier wit/world.wit déclare le world que le composant implémente — pour un service HTTP, on utilise wasi:http/proxy.

On édite src/lib.rs pour implémenter le handler. Le code reste idiomatique Rust ; les types sont générés depuis le fichier WIT.

use wasi::http::types::{Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam};
use wasi::exports::http::incoming_handler::Guest;

struct Component;

impl Guest for Component {
    fn handle(_req: IncomingRequest, out: ResponseOutparam) {
        let headers = Fields::new();
        headers.set(&"content-type".to_string(), &[b"application/json".to_vec()]).unwrap();
        let resp = OutgoingResponse::new(headers);
        resp.set_status_code(200).unwrap();
        let body = resp.body().unwrap();
        ResponseOutparam::set(out, Ok(resp));
        let stream = body.write().unwrap();
        stream.blocking_write_and_flush(br#"{"status":"ok","runtime":"wasmtime"}"#).unwrap();
        drop(stream);
        OutgoingBody::finish(body, None).unwrap();
    }
}

wasi::http::proxy::export!(Component);

On compile et l’on sert le composant. wasmtime serve bind par défaut 0.0.0.0:8080 ; on peut changer via --addr.

cargo component build --release
wasmtime serve --addr 127.0.0.1:8080   target/wasm32-wasip2/release/http_service.wasm

Dans un autre terminal, on teste avec curl. La réponse attendue est « {« status »: »ok », »runtime »: »wasmtime »} ». On a là un service HTTP complet, fonctionnel, sans serveur Node ou Go en arrière-plan : c’est Wasmtime qui parle le protocole HTTP et qui passe la requête au composant.

curl -s http://127.0.0.1:8080/ping
# {"status":"ok","runtime":"wasmtime"}

Étape 6 — Embarquer Wasmtime dans un hôte Rust

La CLI sert pour le développement ; en production, on embarque souvent Wasmtime dans un hôte Rust qui contrôle finement les capabilités et le cycle de vie des instances. La crate wasmtime expose cette API.

On crée un projet hôte avec les dépendances appropriées. wasmtime et wasmtime-wasi doivent rester alignés sur la même version mineure.

cargo new --bin wasm-host
cd wasm-host
cargo add wasmtime@44 wasmtime-wasi@44 anyhow

On écrit l’hôte minimal qui charge le composant, configure les capabilités et l’exécute. Le pattern Linker + Store est central : Linker définit ce que l’on rend disponible aux composants, Store contient l’état d’une instance donnée.

use anyhow::Result;
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::p2::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};

struct AppState { ctx: WasiCtx, table: ResourceTable }
impl WasiView for AppState {
    fn ctx(&mut self) -> WasiCtxView<'_> {
        WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
    }
}

fn main() -> Result<()> {
    let mut cfg = Config::new();
    cfg.wasm_component_model(true);
    let engine = Engine::new(&cfg)?;
    let component = Component::from_file(&engine, "hello.wasm")?;
    let mut linker = Linker::<AppState>::new(&engine);
    wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
    let ctx = WasiCtxBuilder::new().inherit_stdio().env("NAME", "hôte").build();
    let mut store = Store::new(&engine, AppState { ctx, table: ResourceTable::new() });
    let cmd = wasmtime_wasi::p2::bindings::sync::Command::instantiate(&mut store, &component, &linker)?;
    cmd.wasi_cli_run().call_run(&mut store)?.map_err(|()| anyhow::anyhow!("module failed"))?;
    Ok(())
}

Ce squelette charge un composant CLI hello.wasm, lui accorde l’accès au stdio hôte et à la variable NAME, puis appelle son run. Toute capacité non listée (fichiers, sockets, horloge) est refusée par défaut. C’est exactement le contraire d’un std::process::Command qui hérite de tout.

Étape 7 — Décrire ses propres interfaces avec WIT

Pour aller au-delà du proxy HTTP standard, on définit ses propres interfaces avec WIT. Un fichier .wit liste des types (records, variants, listes, ressources) et des fonctions. Le compilateur wit-bindgen génère ensuite les bindings Rust côté composant et l’hôte.

Un exemple minimal : on définit une fonction convert qui prend un montant et une devise et retourne un objet structuré. Le fichier wit/world.wit contient la déclaration.

package itskc:demo;

interface fx {
  record amount { value: f64, currency: string }
  record converted { fcfa: f64, rate: f64 }
  convert: func(input: amount) -> result<converted, string>;
}

world fx-service {
  export fx;
}

Côté composant Rust, cargo-component génère automatiquement les traits à implémenter. Côté hôte, on peut consommer le même fichier WIT pour charger plusieurs composants concurrents avec les mêmes garanties typées. Le binaire produit est composable : on peut chaîner deux composants dont la sortie de l’un alimente l’entrée de l’autre, ce qui ouvre la voie aux pipelines polyglot.

Étape 8 — Restreindre les capabilités en production

Le principe directeur en production est de n’accorder que les capabilités strictement nécessaires. Voici les leviers principaux fournis par WasiCtxBuilder et la CLI.

preopened_dir(path, guest_path, dir_perms, file_perms) expose un répertoire avec des permissions séparées pour les listes (entrée du dossier) et les fichiers (lecture/écriture). On peut autoriser la lecture seule sur un répertoire de configuration et l’écriture sur un répertoire de scratch.

env(key, value) et args(values) exposent uniquement les variables et arguments choisis. Les modules ne peuvent pas itérer sur l’environnement de l’hôte.

inherit_stdio() ou stdout(...)/stderr(...) contrôlent où vont les écritures du module. On peut les rediriger vers un buffer mémoire pour audit, vers un fichier de log dédié ou vers un sink null.

Pour le réseau, WASI 0.2 introduit wasi-sockets. Wasmtime expose allow_ip_name_lookup, socket_addr_check et plus généralement un trait pour autoriser ou refuser chaque connexion sortante. Refuser tout par défaut puis whitelister une route est la posture sécurité recommandée pour exécuter du code tiers.

Au-delà de ces leviers, Wasmtime expose deux limites quantitatives critiques en production. La limite mémoire via StoreLimitsBuilder::memory_size() plafonne la linear memory du module : un composant ne pourra pas allouer plus que ce qui a été autorisé. La limite de fuel via Store::set_fuel(n) capture le temps CPU sous forme d’instructions WebAssembly exécutées ; quand le module dépasse, Wasmtime lève un trap propre, sans tuer l’hôte. Combinées, ces deux limites permettent d’exécuter du code adversaire avec des garanties dures, ce qu’un conteneur Linux ne fournit pas sans cgroups complémentaires.

Étape 9 — Erreurs fréquentes

Symptôme Cause Résolution
error: unknown target "wasm32-wasip2" Toolchain Rust antérieure à 1.82 rustup update stable
Permission denied à la lecture Préopen --dir manquant Ajouter --dir HOST::GUEST
wasmtime serve: unknown subcommand Wasmtime < 18.0.0 Mettre à jour vers 44.x
Composant rejeté par Wasmtime Compilé en wasm32-wasip1 au lieu de wasip2 Recompiler avec la bonne cible
Crash traps: out of fuel Limite de fuel configurée trop basse Augmenter via Store::set_fuel ou désactiver
Latence d’instanciation élevée Pas de pré-compilation AOT wasmtime compile + cache disque

Aller plus loin

Une fois le runtime maîtrisé, deux directions s’ouvrent. Pour distribuer ce même composant sur l’edge, le tutoriel Déployer un module WebAssembly Rust sur Cloudflare Workers détaille le packaging vers le runtime Workers et l’intégration avec KV, D1 et R2. Pour exécuter du code utilisateur avec garanties d’isolation, le tutoriel Isolation d’un agent IA dans une sandbox WebAssembly approfondit le modèle de capabilités côté hôte. Le guide principal sur WebAssembly en production donne le panorama des choix de runtime alternatifs (Wasmer, WasmEdge, Spin).

Ressources officielles

Avec Wasmtime, on dispose d’un environnement d’exécution WebAssembly serveur complet : binaires petits, instanciation rapide, capabilités explicites. La marche suivante consiste à industrialiser le déploiement — l’edge avec Cloudflare Workers ou la sandbox stricte pour code utilisateur sont les deux scénarios qui exploitent le mieux ces propriétés.

Service ITSkillsCenter

Site ou application web sur mesure

Conception Pro + Nom de domaine 1 an + Hébergement 1 an + Formation + Support 6 mois. Accès et code livrés. À partir de 350 000 FCFA.

Demander un devis
Publicité