Wasm-плагины на Go

от автора

Привет, Хабр!

Когда обсуждают расширяемость бэкендов, первым делом вспоминают нативные плагины на C или C++. Дальше обычно всплывают вопросы ABI, совместимости компиляторов, загрузчиков и фразы «а у нас Alpine с musl». В Go исторически был пакет plugin, но его применимость ограничена окружениями и сборкой. В 2025 году картина проще: берем WebAssembly как изолированный байткод, исполняем его прямо из Go и получаем плагинную архитектуру без плясок с динамическими библиотеками.

Далее в статье рассмотрим, как создать практичную систему Wasm‑плагинов на Go: с изоляцией, таймаутами, контрактом данных и обновлениями на лету. Для рантайма возьмем wazero, потому что он написан на Go и не требует cgo.

Запускаем плагин с WASI IO

Начнем с самого простого и безопасного протокола обмена: stdin/stdout в терминах WASI. Плагин читает запрос из stdin и пишет ответ в stdout. Это конечно не самый быстрый вариант, зато прямолинейный.

Сторона плагина. Напишем плагин на TinyGo, чтобы собирать в компактный .wasm с WASI. Вход JSON, выход JSON.

// file: plugin/main.go package main  import ( "bufio" "encoding/json" "fmt" "io" "os" )  type Request struct { Op   string            `json:"op"` Data map[string]string `json:"data"` }  type Response struct { Ok    bool              `json:"ok"` Error string            `json:"error,omitempty"` Out   map[string]string `json:"out,omitempty"` }  func handle(req Request) Response { switch req.Op { case "uppercase": out := make(map[string]string, len(req.Data)) for k, v := range req.Data { out[k] = stringsToUpperSafe(v) } return Response{Ok: true, Out: out} default: return Response{Ok: false, Error: "unknown op"} } }  func stringsToUpperSafe(s string) string { // Не аллоцируем лишнее b := []byte(s) for i := range b { if b[i] >= 'a' && b[i] <= 'z' { b[i] = b[i] - 32 } } return string(b) }  func main() { reader := bufio.NewReaderSize(os.Stdin, 64*1024) raw, err := io.ReadAll(reader) if err != nil { fmt.Fprint(os.Stderr, `{"ok":false,"error":"read"}`) return } var req Request if err := json.Unmarshal(raw, &req); err != nil { fmt.Fprint(os.Stderr, `{"ok":false,"error":"bad_json"}`) return } resp := handle(req) buf, _ := json.Marshal(resp) os.Stdout.Write(buf) }

Сборка под WASI для TinyGo:

GOOS=wasip1 GOARCH=wasm tinygo build -o plugin.wasm ./plugin

TinyGo умеет собирать wasip1 и wasip2, но нам важно, чтобы выбранный рантайм это поддерживал.

Теперь хост на Go с wazero. Запускаем модуль, подаем stdin, читаем stdout, вешаем контекст с таймаутом, изолируем файловую систему.

// file: host/main.go package main  import ( "context" _ "embed" "encoding/json" "errors" "fmt" "io/fs" "log" "os" "time"  "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" )  //go:embed plugin.wasm var wasmBytes []byte  type Req struct { Op   string            `json:"op"` Data map[string]string `json:"data"` }  func main() { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel()  r := wazero.NewRuntime(ctx) defer r.Close(ctx)  // Подключаем WASI P1 if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { log.Fatal(err) }  // Ограничим доступ плагина к ФС read-only каталогом work/ // Важно: os.DirFS не является настоящим chroot. Это не jail. // Не кладите туда ничего чувствительного. :contentReference[oaicite:2]{index=2} work := os.DirFS("work") modConfig := wazero.NewModuleConfig(). WithStdout(os.Stdout). WithStderr(os.Stderr). WithStdin(os.Stdin). WithEnv("PLUGIN_MODE", "safe"). WithFS(work)  mod, err := r.InstantiateWithConfig(ctx, wasmBytes, modConfig) if err != nil { log.Fatal(err) } defer mod.Close(ctx)  // Готовим запрос in := Req{ Op: "uppercase", Data: map[string]string{ "a": "hello", "b": "habr", }, } raw, _ := json.Marshal(in)  // Подаем stdin через временный pipe stdinR, stdinW, _ := os.Pipe() defer stdinR.Close() defer stdinW.Close()  go func() { stdinW.Write(raw) stdinW.Close() }()  // Перезапускаем модуль c переопределенным stdin cfg := modConfig.WithStdin(stdinR) _ = mod.Close(ctx) mod, err = r.InstantiateWithConfig(ctx, wasmBytes, cfg) if err != nil { log.Fatal(err) } defer mod.Close(ctx)  // Исполнение начинается с _start для WASI модулей start := mod.ExportedFunction("_start") if start == nil { log.Fatal(errors.New("no _start")) }  _, err = start.Call(ctx) if err != nil { // В таймаут или принудительный exit придет ExitError log.Println("call error:", err) } }

Получаем изолированное выполнение, понятные точки входа и контроль жизни через context.

Замечание по WASI версиям. В 2024 стабилизировали Preview 2, вокруг него идет постепенная миграция. Но сегодня многие хосты и тулчейны продолжают держать Preview 1. У самого wazero поддержка p2 еще в процессе и для бэкенда на Go практичнее держаться p1, пока не дозреет экосистема вокруг компонентной модели.

План минимум мы выполнили. Дальше ускоряем обмен данными и добавляем возможности.

Прямой ABI без файлов и JSON

Чтобы уйти от stdin/stdout и лишних парсеров, определим простой бинарный ABI: хост выделяет буфер в памяти гостя, пишет туда байты запроса, вызывает экспорт process(ptr, len) и получает два значения результата respPtr, respLen. Плагин выделяет выходной буфер, заполняет, возвращает указатель и длину, хост считывает и вызывает экспорт free(ptr, len).

Сторона плагина на Rust. Здесь проще безопасно работать с указателями и экспортами.

// file: plugin/src/lib.rs // target: wasm32-wasi #![no_std] extern crate alloc;  use alloc::vec::Vec; use alloc::string::String; use alloc::format; use alloc::boxed::Box; use core::slice;  #[no_mangle] pub extern "C" fn alloc(size: u32) -> *mut u8 {     let mut buf = Vec::<u8>::with_capacity(size as usize);     let ptr = buf.as_mut_ptr();     core::mem::forget(buf);     ptr }  #[no_mangle] pub extern "C" fn free(ptr: *mut u8, len: u32) {     unsafe { let _ = Vec::from_raw_parts(ptr, len as usize, len as usize); } }  #[no_mangle] pub extern "C" fn process(ptr: *mut u8, len: u32) -> u64 {     let input = unsafe { slice::from_raw_parts(ptr as *const u8, len as usize) };     // Протокол: входные данные — UTF-8 строка со строками через '\n'     let s = unsafe { core::str::from_utf8_unchecked(input) };     let out = s.to_uppercase(); // для примера     let bytes = out.into_bytes();     let len_out = bytes.len() as u32;      let buf = bytes.into_boxed_slice();     let ptr_out = Box::into_raw(buf) as *mut u8;      // Возвращаем два 32-битных значения, упакованных в 64-бит     ((len_out as u64) << 32) | (ptr_out as u64 & 0xffffffff) }

Сборка:

rustup target add wasm32-wasi RUSTFLAGS="-C opt-level=z" cargo build --release --target wasm32-wasi

Хост на Go с прямой работой с памятью. В wazero вызовы возвращают результаты как []uint64. Память доступна через mod.Memory(), а запись и чтение методами Write и Read.

// file: host_abi/main.go package main  import ( "context" "encoding/binary" "fmt" "log" "time"  "github.com/tetratelabs/wazero" )  func main() { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel()  r := wazero.NewRuntime(ctx) defer r.Close(ctx)  // Загружаем wasm-модуль как []byte (Rust выше) wasm := mustRead("plugin/target/wasm32-wasi/release/plugin.wasm") mod, err := r.InstantiateModuleFromBinary(ctx, wasm) if err != nil { log.Fatal(err) } defer mod.Close(ctx)  mem := mod.Memory() alloc := mod.ExportedFunction("alloc") free := mod.ExportedFunction("free") proc := mod.ExportedFunction("process")  // Вход input := []byte("foo\nbar\nbaz") // Выделяем в памяти гостя res, err := alloc.Call(ctx, uint64(len(input))) if err != nil { log.Fatal(err) } ptr := uint32(res[0]) if !mem.Write(ptr, input) { log.Fatal("mem write failed") }  // Вызываем out, err := proc.Call(ctx, uint64(ptr), uint64(len(input))) if err != nil { log.Fatal(err) } // Распаковываем ptr/len из одного u64 resp := out[0] respPtr := uint32(resp & 0xffffffff) respLen := uint32(resp >> 32)  buf, ok := mem.Read(respPtr, respLen) if !ok { log.Fatal("mem read failed") } fmt.Println(string(buf))  // Освобождаем буфер в госте if _, err = free.Call(ctx, uint64(respPtr), uint64(respLen)); err != nil { log.Println("free error:", err) } }

ABI позволяет обойтись без JSON и файлов и добиться максимума скорости в пределах одного процесса. Безопасность контролируется изоляцией Wasm и таймаутами. Но есть минус, нужно аккуратно следить за контрактом, чтобы не получить use‑after‑free или переполнение. В вебассембли память линейная, поэтому доступ всегда через явные смещения и длины.

Хост-функции

Модули часто просят базовые сервисы: лог, доступ к конфигу или секретам. В Wasm это делают через импортируемые хост‑функции. В wazero их регистрируют до инстанциирования плагина.

Хост добавляет модуль env с экспортом host_log(level, ptr, len) и get_cfg(keyPtr, keyLen) -> u64.

// file: host_hostfuncs/main.go package main  import ( "context" "log" "unsafe"  "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" )  func main() { ctx := context.Background() r := wazero.NewRuntime(ctx) defer r.Close(ctx)  builder := r.NewHostModuleBuilder("env")  builder.NewFunctionBuilder(). WithParameterTypes(api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32). WithResultTypes(). WithGoFunction(func(ctx context.Context, mod api.Module, stack []uint64) { level := uint32(stack[0]) ptr := uint32(stack[1]) size := uint32(stack[2]) msg, _ := mod.Memory().Read(ptr, size) switch level { case 0: log.Printf("[DEBUG] %s", msg) case 1: log.Printf("[INFO] %s", msg) default: log.Printf("[WARN] %s", msg) } }).Export("host_log")  // Простой K/V конфиг cfg := map[string]string{"featureX": "on"}  builder.NewFunctionBuilder(). WithParameterTypes(api.ValueTypeI32, api.ValueTypeI32). WithResultTypes(api.ValueTypeI64). WithGoFunction(func(ctx context.Context, mod api.Module, stack []uint64) { ptr := uint32(stack[0]) size := uint32(stack[1]) key, _ := mod.Memory().Read(ptr, size) val := cfg[string(key)]  // Выделяем буфер в госте через его alloc alloc := mod.ExportedFunction("alloc") res, _ := alloc.Call(ctx, uint64(len(val))) valPtr := uint32(res[0]) mod.Memory().Write(valPtr, []byte(val))  stack[0] = uint64(valPtr) | (uint64(len(val)) << 32) }).Export("get_cfg")  if _, err := builder.Instantiate(ctx); err != nil { log.Fatal(err) }  // Дальше instantiate самого плагина, как в предыдущем примере }

Импорт в плагине на TinyGo:

// file: plugin/main.go package main  //go:wasmimport env host_log func hostLog(level uint32, ptr uint32, len uint32)  //go:wasmimport env get_cfg func getCfg(keyPtr uint32, keyLen uint32) uint64  // вспомогательные обертки...

Держим интерфейс узким и стабильным, чтобы не приходилось перекомпиливать плагины на каждую мелочь.

Кэш компиляции и пуллинг модулей

Первый запуск модуля тратит время на компиляцию. В wazero есть файл‑кэш для уже скомпилированных модулей, что серьезно снижает задержку холодного старта. В проектах разумно включить и файл‑кэш, и пул инстансов на горячей дорожке.

cacheDir := "/var/cache/wazero" cc, err := wazero.NewCompilationCacheWithDir(cacheDir) if err != nil { panic(err) } defer cc.Close(ctx)  rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCompilationCache(cc))

Пучок инстансов делаем обычным sync.Pool или каналом, где храним уже инициализированные модули. Не забываем про Close и периодическую ротацию, чтобы избежать накопления состояния в памяти плагина, если он его держит.

Безопасность

Минимальный набор:

  1. Контекст с таймаутом на каждый вызов.

  2. Ограничение файловой системы. WithFS в модуле отображает только заданный fs.FS. Не кладите туда ничего секретного.

  3. Никакой сети по умолчанию. В WASI сети нет, пока вы ее не втащите хост‑функциями. Так и оставляем.

  4. Ограничение памяти. Контролируйте размер входов, иначе модуль сможет потреблять много памяти внутри линейной памяти.

  5. Обновления плагинов — только через проверенный стор. Сигнатуры, контроль версий ABI.

Отдельно про версии WASI. У Go появился поддерживаемый сценарий выполнения тестов и бинов GOOS=wasip1 GOARCH=wasm с выбором хоста через GOWASIRUNTIME.

Когда хочется готовую систему плагинов: Extism

Если не хочется самим тащить память, протоколы и манифесты, есть Extism — фреймворк для плагинов на WebAssembly. Он дает SDK для хоста на Go и PDK для плагинов, упрощает передачу строк и бинарей, имеет манифест с разрешениями и может работать поверх нескольких движков, в том числе wazero и wasmtime.

Минимальный хост на Go:

// file: host_extism/main.go package main  import ( "context" "fmt" "os" "time"  extism "github.com/extism/go-sdk" )  func main() { ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) defer cancel()  wasm := mustRead("plugin.wasm")  manifest := extism.Manifest{ // Один модуль Wasm: []extism.Wasm{extism.WasmData{Data: wasm}}, // Разрешения и конфиги AllowedHosts: []string{}, // Можно прокинуть K/V конфиг Config: map[string]string{"featureX": "on"}, }  host, err := extism.NewHost(&manifest, extism.Config{}) if err != nil { panic(err) } defer host.Close()  plugin, err := host.Plugin() if err != nil { panic(err) } defer plugin.Close()  input := []byte(`{"op":"uppercase","data":{"a":"hello"}}`) out, err := plugin.Call(ctx, "process", input) if err != nil { panic(err) } fmt.Println(string(out)) }

Extism удобен, если планируется давать SDK внешним авторам плагинов, поддерживать несколько языков и держать единый манифест разрешений. Если нужна предельная производительность и полное управление, прямая интеграция wazero тоже остается хорошим вариантом.


Итоги

Рабочий стек для плагинов на Go сегодня выглядит так: wazero встраиваем в процесс, контракт выбираем осознанно, обязательно вешаем таймауты, ограничиваем файловую систему, держим узкий набор хост‑функций, включаем компилирующий рантайм и кэш компиляции, прогреваем пул инстансов и закрываем модули без утечек; для многоязычия и быстрого онбординга плагинеров смотрим на Extism, а Preview 2 и компонентную модель трогаем отдельно.

Делитесь кейсами в комментариях.

Если идея плагинной архитектуры на WebAssembly вам близка — с её изоляцией, чёткими контрактами и контролем исполнения, — приглашаем вас закрепить фундаментальные элементы, на которых такие системы держатся. Присоединяйтесь к открытым урокам курса Golang Developer. Professional — все открытые уроки бесплатные.

  1. Итераторы в Go: разбираем шаг за шагом — 19 августа в 20:00. Разберём, как через итераторы упорядочивать поток данных между компонентами и плагинами без лишних аллокаций и «склейки» протоколов.

  2. Интерфейсы в Golang изнутри — 3 сентября в 20:00. Погрузимся в устройство интерфейсов, динамическую диспетчеризацию и стабильные контракты — то, что помогает аккуратно связывать хост и плагины.

  3. Golang: Когда многопоточность работает против вас — 16 сентября в 20:00. Покажем, где параллелизм усиливает систему, а где ломает предсказуемость выполнения и изоляцию; как ставить границы временем и памятью.

Также приглашаем вас на бесплатное тестирование, которое позволит проверить ваш уровень знаний и навыков.


ссылка на оригинал статьи https://habr.com/ru/articles/938018/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *