WebAssembly hors du navigateur: WASI et Wasm côté serveur

1. Aperçu

WebAssembly (Wasm) est né pour exécuter du code haute performance dans les navigateurs. Mais ses caractéristiques (compact, sandbox strict, indépendant de l’architecture CPU) sont aussi très attrayantes côté serveur.

Dans cet article, vous comprendrez ce qu’est WASI et pourquoi Wasm côté serveur attire l’attention. Vous allez compiler un module Wasm, l’exécuter avec un runtime en ligne de commande, l’intégrer dans une application Java 21 et écrire des tests. En fin d’article, les cas où ne pas utiliser Wasm.

2. Pourquoi WebAssembly a besoin de WASI ?

2.1. Wasm “brut” ne peut rien faire avec le monde extérieur

Un module Wasm pur sait seulement calculer sur sa mémoire linéaire (linear memory). Il ne peut pas par lui-même ouvrir un fichier, lire des variables d’environnement, obtenir l’heure système ou ouvrir un socket. Toute interaction avec l’extérieur doit passer par des fonctions que l’hôte (l’environnement d’exécution du module) fournit sous forme d’imports.

Dans un navigateur, l’hôte est JavaScript. En dehors du navigateur, aucun hôte n’est encore unifié. Si chaque runtime définissait son propre ensemble d’API, un module compilé pour ce runtime ne fonctionnerait pas sur un autre.

2.2. WASI : l’interface système standard pour Wasm

WASI (WebAssembly System Interface) est un ensemble d’API standard pour les opérations de type système d’exploitation : fichiers, horloge, aléatoire, variables d’environnement, arguments en ligne de commande, et dans les versions plus récentes, le réseau. Un module compilé en ciblant WASI peut s’exécuter sur n’importe quel runtime qui le supporte, par exemple Wasmtime, WasmEdge, Wasmer, ou un runtime intégré dans votre application.

La différence la plus importante est le modèle capability. Un module n’a pas de permissions par défaut. L’hôte doit accorder explicitement des permissions, par exemple « lire le répertoire ./data ». Sans permission, l’appel échoue. C’est l’inverse d’un processus ordinaire, qui hérite de toutes les permissions de l’utilisateur qui l’exécute.

2.3. WASI Preview 1, Preview 2 et Component Model

WASI a traversé plusieurs phases. Vous rencontrerez deux noms courants :

  • Preview 1 (wasip1) : API de style fonction C, la plus largement supportée.
  • Preview 2 (wasip2) : basée sur le Component Model. L’interface est décrite dans un langage IDL appelé WIT. Les composants écrits dans différents langages peuvent s’appeler les uns les autres via des types de données plus riches comme string, record, list, au lieu de simples entiers.

Le niveau de support varie selon le runtime et la toolchain. Avant de choisir, vous devriez consulter la documentation du runtime que vous prévoyez d’utiliser.

3. Pourquoi Wasm côté serveur attire l’attention ?

Il y a quatre raisons principales :

  1. Sandbox par défaut : un module ne peut pas accéder à la mémoire en dehors de sa zone et n’a pas de permissions système s’il ne les reçoit pas. C’est idéal pour exécuter du code non fiable, comme des plugins clients ou des scripts téléchargés par l’utilisateur.
  2. Démarrage rapide, faible empreinte : les modules sont généralement légers et s’initialisent bien plus rapidement qu’un conteneur ou une nouvelle JVM. Wasm convient donc au serverless et à l’edge, où chaque requête peut nécessiter sa propre instance.
  3. Portable : un fichier .wasm fonctionne sur x86 ou ARM, Linux ou macOS, du moment qu’il y a un runtime.
  4. Multilingue : Rust, C/C++, Go, Zig, AssemblyScript… peuvent tous compiler vers Wasm. Votre application Java peut charger de la logique écrite dans un autre langage sans avoir besoin de JNI.

Comparaison rapide avec les conteneurs :

Critère Conteneur Wasm + WASI
Unité d’empaquetage Image contenant tout le userland OS Un fichier .wasm
Isolation Namespaces/cgroups du kernel Sandbox du runtime + capability
Dépendance d’architecture Image requise par architecture Non
Écosystème Très mature En développement

Wasm ne remplace pas les conteneurs. Les deux sont généralement complémentaires : vous pouvez toujours exécuter un runtime Wasm à l’intérieur d’un conteneur.

4. Compiler et exécuter un programme WASI

L’exemple utilise Rust car la toolchain Wasm de Rust est assez pratique. L’idée s’applique de la même manière à d’autres langages.

4.1. Un programme pour lire des fichiers de commandes

// 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!("Valeur totale de la commande : {total}");
        }
        Err(e) => eprintln!("Impossible de lire le fichier : {e}"),
    }
}

Le programme lit orders.csv, prend la deuxième colonne (montant) et additionne. Le code n’a rien de particulier pour Wasm : std::fs sera mappé vers les appels WASI lors de la compilation.

Ajouter la cible et compiler :

rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1

Le résultat est le fichier target/wasm32-wasip1/release/order-report.wasm (le nom dépend de votre package).

4.2. Exécuter avec Wasmtime et observer le modèle capability en action

Créer un fichier de données :

ORD-1,120.5
ORD-2,79.5
ORD-3,300

Exécuter sans accorder les permissions du répertoire :

wasmtime run target/wasm32-wasip1/release/order-report.wasm
Impossible de lire le fichier : failed to find a pre-opened file descriptor through which "orders.csv" could be opened

Le module s’exécute mais ne voit aucun fichier. Il n’a pas la capability pour le système de fichiers. Maintenant, accordez la permission du répertoire courant :

wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
Valeur totale de la commande : 500

Le drapeau --dir « pré-ouvre » le répertoire pour le module. Le module ne voit que ce répertoire, pas /etc ou le répertoire d’accueil. Le contenu du message d’erreur peut différer entre les versions du runtime, mais le comportement est identique.

Note : la syntaxe des drapeaux (--dir, --env, mappage des répertoires hôte vers invité) peut changer entre les versions de Wasmtime. Vérifiez wasmtime run --help sur votre machine.

5. Intégrer Wasm dans une application Java

Wasm côté serveur ne consiste pas seulement à exécuter un fichier .wasm depuis la CLI. Une approche très pratique consiste à intégrer le runtime dans une application existante pour exécuter des plugins ou de la logique fournie par des tiers.

Avec Java, il y a deux approches :

  • Lier à un runtime natif (Wasmtime, WasmEdge) via JNI ou FFM.
  • Utiliser un runtime écrit entièrement en Java, par exemple Chicory. Pas besoin de bibliothèque native, donc facile à empaqueter et exécuter partout où il y a une JVM.

L’exemple ci-dessous utilise Chicory.

5.1. Un module pour calculer les frais de livraison

Supposons qu’un partenaire fournit la logique de calcul des frais d’expédition sous forme de Wasm :

// src/lib.rs, Cargo.toml avec : [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/

Ce module n’utilise pas WASI. Il ne fait que calculer, donc la cible wasm32-unknown-unknown suffit et le module n’a besoin d’importer rien.

5.2. Dépendances Maven

<properties>
    <java.version>21</java.version>
    <!-- Vérifiez la version la plus récente sur 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>

Si vous utilisez Spring Boot 3.x, les versions de JUnit et AssertJ sont déjà gérées via spring-boot-starter-test.

5.3. Appeler une fonction Wasm depuis Java

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("Impossible de charger le module " + resource, e);
        }
    }

    public int fee(int weightGrams, int distanceKm) {
        long[] result = shippingFee.apply(weightGrams, distanceKm);
        return (int) result[0];
    }
}

Explication :

  • Parser.parse(...) lit le bytecode Wasm en un modèle de module.
  • Instance.builder(module).build() crée une instance avec sa propre mémoire. Le module n’importe rien, donc pas besoin de fournir de fonctions hôte.
  • instance.export("shipping_fee") récupère la fonction exportée. Le nom doit correspondre au #[no_mangle] en Rust.
  • apply(...) accepte et retourne long[], car les types numériques Wasm (i32, i64…) sont tous représentés en tant que long. Vous effectuez vous-même le cast vers int.

Test rapide :

var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000

Le résultat comprend 15 000 de frais de base, plus 2 × 5 000 selon le poids (1 200 / 500 = 2), plus 10 × 1 000 selon la distance.

5.4. Tester le comportement

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");
    }
}

Le troisième test garantit que l’application signale une erreur au démarrage si le module est manquant, au lieu d’attendre la première requête.

Note : une Instance a sa propre mémoire et son propre état. Ne supposez pas qu’elle est thread-safe par défaut. Avec un module purement computationnel, le plus simple est de créer une instance par requête, ou d’utiliser un pool d’instances. Un module déjà parsé peut être réutilisé pour créer plusieurs instances.

5.5. Quand un module a besoin de WASI

Si le plugin a besoin de lire des fichiers ou d’écrire sur stdout, il importera des fonctions WASI. Vous devez fournir ces fonctions lors de la création de l’instance. Chicory a un module wasi dédié pour cela. Le principe reste le modèle capability : accordez uniquement les répertoires, les variables d’environnement et stdout/stderr que le plugin a vraiment besoin. Ne « transmettez » pas tout l’environnement de l’hôte par commodité.

6. Modèles d’application pratiques

  • Système de plugins : permettez aux clients ou partenaires de personnaliser la logique (tarification, vérification des règles, transformation de données) sans avoir besoin d’exécuter directement leur code dans la JVM.
  • Moteur de politique : empaquetez les règles d’autorisation en modules Wasm pour les partager entre plusieurs services écrits dans des langages différents.
  • Serverless et edge functions : chaque requête s’exécute dans une instance isolée avec un démarrage rapide.
  • Réutilisation de bibliothèques natives : compilez une bibliothèque C/Rust (parseur, codec, algorithme) en Wasm et appelez-la depuis Java, en évitant JNI et le risque de crash de la JVM entière.
  • Extensions d’infrastructure : certains proxies, bases de données et courtiers de messages permettent l’extension via Wasm.

7. Quand ne PAS utiliser Wasm

Wasm côté serveur est attrayant, mais ce n’est pas toujours le bon choix :

  • Logique déjà bien écrite en Java et ne nécessitant pas d’isolation : ajouter une couche Wasm n’augmenterait que la complexité, le coût de transfert de données et la difficulté de débogage.
  • Charge de travail nécessitant du multithreading lourd, du GPU ou du SIMD spécialisé : le support des threads et des fonctionnalités matérielles dans Wasm est toujours limité et inégal entre les runtimes.
  • Applications dépendant fortement du réseau et de l’écosystème des bibliothèques : les sockets, TLS, les pilotes de base de données via WASI ne sont pas aussi complets que sur un vrai système d’exploitation. De nombreuses bibliothèques ne peuvent pas encore être compilées vers Wasm.
  • Appels répétés avec de grandes quantités de données : chaque transfert de chaîne ou de tableau entre l’hôte et l’invité doit être copié dans la mémoire linéaire. Si vous appelez des fonctions Wasm des millions de fois avec une charge utile importante, ce coût est considérable. Essayez de regrouper le travail en moins d’appels plus importants.
  • Penser que sandbox signifie sécurité absolue : Wasm isole la mémoire, mais ne bloque pas automatiquement les boucles infinies ou la consommation excessive de CPU. Vous avez toujours besoin de timeouts, de limites de mémoire, et d’un mécanisme d’interruption d’exécution si le runtime le supporte (fuel, epoch interruption…).
  • Équipe pas encore prête : la toolchain, le débogueur et l’observabilité pour Wasm sont toujours en développement. Ne l’intégrez pas dans des systèmes critiques juste parce que c’est « à la mode ».

Quelques erreurs courantes :

  • Compiler avec la mauvaise cible. Un module utilisant std::fs ou println! a besoin de la cible WASI. Un module purement computationnel utilise wasm32-unknown-unknown.
  • Oublier #[no_mangle] ou extern "C", ce qui empêche la fonction d’apparaître dans la liste d’exports.
  • Confusion de types : Wasm n’a que des types numériques primitifs. Pour transmettre une chaîne, vous devez gérer vous-même l’allocation dans la mémoire linéaire, ou utiliser le Component Model avec WIT.
  • Accorder des permissions trop larges (--dir=/, hériter tout l’environnement), ce qui perd le principal avantage de WASI.

8. Conclusion

WASI transforme WebAssembly d’une technologie de navigateur en un format d’exécution portable, léger et strictement sandboxé pour le côté serveur. Le modèle capability (aucune permission sauf si accordée) est la différence fondamentale par rapport aux processus et conteneurs traditionnels.

Dans cet article, vous avez compilé un programme WASI et vu comment les permissions du système de fichiers sont contrôlées. Vous avez également intégré un module Wasm dans une application Java 21 utilisant un runtime pur Java et écrit des tests pour lui.

Envisagez Wasm quand vous avez besoin d’exécuter du code non fiable, des plugins multilingues ou un démarrage ultra-rapide. Mais si le problème est juste de la logique métier ordinaire dans un service Java, la solution la plus simple reste généralement la meilleure.

webassemblywasiwasmjavatech-trends