Привет, Хабр!
Когда обсуждают расширяемость бэкендов, первым делом вспоминают нативные плагины на 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 и периодическую ротацию, чтобы избежать накопления состояния в памяти плагина, если он его держит.
Безопасность
Минимальный набор:
-
Контекст с таймаутом на каждый вызов.
-
Ограничение файловой системы.
WithFSв модуле отображает только заданныйfs.FS. Не кладите туда ничего секретного. -
Никакой сети по умолчанию. В WASI сети нет, пока вы ее не втащите хост‑функциями. Так и оставляем.
-
Ограничение памяти. Контролируйте размер входов, иначе модуль сможет потреблять много памяти внутри линейной памяти.
-
Обновления плагинов — только через проверенный стор. Сигнатуры, контроль версий 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 — все открытые уроки бесплатные.
Итераторы в Go: разбираем шаг за шагом — 19 августа в 20:00. Разберём, как через итераторы упорядочивать поток данных между компонентами и плагинами без лишних аллокаций и «склейки» протоколов.
Интерфейсы в Golang изнутри — 3 сентября в 20:00. Погрузимся в устройство интерфейсов, динамическую диспетчеризацию и стабильные контракты — то, что помогает аккуратно связывать хост и плагины.
Golang: Когда многопоточность работает против вас — 16 сентября в 20:00. Покажем, где параллелизм усиливает систему, а где ломает предсказуемость выполнения и изоляцию; как ставить границы временем и памятью.
Также приглашаем вас на бесплатное тестирование, которое позволит проверить ваш уровень знаний и навыков.
ссылка на оригинал статьи https://habr.com/ru/articles/938018/
Добавить комментарий