Привет, Хабр!
Когда обсуждают расширяемость бэкендов, первым делом вспоминают нативные плагины на 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. Покажем, где параллелизм усиливает систему, а где ломает предсказуемость выполнения и изоляцию; как ставить границы временем и памятью.
Также приглашаем вас на бесплатное тестирование, которое позволит проверить ваш уровень знаний и навыков.
soulilya
Круто, спасибо большое за статью!