Задумался о том что бы прикрутить к своему пет проекту систему плагинов на WebAssembly. Это потенциально позволит переиспользовать существующий код на Go, C++, Rust, если конечно же он есть. А так же избавится от so/dll, что удобно при распространении плагинов, когда проект представляет собой десктопное приложение и собирается под Windows, OSX, GNU/Linux. Поэтому пошел смотреть как это сделано в Envoy.
Предыстория Envoy
На начало 2019 Envoy представляет собой статический бинарник со всеми расширениями скомпилированными во время сборки, поэтому нужно поддерживать несколько бинарных сборок, вместо использования официального и не модифицированного бинарного файла Envoy. Для проектов, которые не контролируют свои деплой, еще более проблематично, потому что обновление расширений требует пересборки и деплоя всего Envoy.
Преимущества
Гибкость. Расширения можно доставлять и перезагружать во время выполнения. Любый изменения или исправления могут быть протестированы во время выполнения, без необходимости обновления и редиплоя нового бинарника.
Надежность и изоляция. Расширения запускаются в песочнице и могут быть ограничены по потреблению CPU и памяти.
Безопасность. Расширения запускаются в песочнице с четко определенным API для связи с прокси(envoy, nginx и т.д.). Имеют ограниченный доступ к свойствам, которые могут менять.
Разнообразие. Большой выбор языков программирования, которые могут скомпилировать в WebAssembly, что позволяет разработчикам с любым опытом(C, Go, Rust, Java, TypeScript, и т.д.) писать расширения.
Переносимость. Поскольку интерфейс между хост-средой и расширениями не зависит от прокси-сервера, расширения, написанные с использованием Proxy-Wasm, могут выполняться в различных прокси-серверах, например Envoy, NGINX, ATS или даже внутри библиотеки gRPC (при условии, что все они реализуют стандарт).
Недостатки
Более высокое потребления памяти из-за необходимости запуска множества виртуальных машин, каждая со своим блоком памяти.
Более низкая производительность для расширений, преобразующий полезные данные, из-за необходимости копировать значительные объемы данных в песочницу и из нее.
Более низкая производительность для CPU-bound задач. Ожидается, что замедление будет менее чем в 2 раза по сравнению с нативным кодом.
Увеличенный размер бинарника из-за необходимости включать среду выполнения Wasm. Это ~20 MB для WAVM и ~10 MB для V8.
Экосистема WebAssembly все еще молода, и в настоящее время разработка сосредоточена на использовании в браузере, где JavaScript считается хост-средой.
Общая схема
В Envoy взяли C++ API, прикрутили Wasm VM и перенаправляют вызовы в wasm модуль.
В одном wasm модуле могут быть несколько фильтров. Экземпляры Wasm VM размножены и размещены в thread-local storage
Коммуникация между экземплярами Wasm VM осуществляется примитивами shared data и message queue. Службы представляют из себя singleton и выполняется в основном потоке Envoy. Они выполняются параллельно фильтрам и осуществляют вспомогательные функции: логи, статистика и т.д.
Рантайм
Wasm VM это один из следующих рантаймов
Спецификация
Спецификация ABI разбита на два больших блока: функции реализованные в модуле и функции реализованные в хост-среде. Выделю две функции: выделение памяти proxy_on_memory_allocate
, точка входа _start
.
Спецификация представляет набор функций в формате proxy_log
аргументы:
i32 (proxy_log_level_t) log_level
i32 (const char*) message_data
i32 (size_t) message_size
возвращаемое значение:
i32 (proxy_result_t) call_result
i32 это числовой тип в wasm, а так она выглядит в разных SDK
extern "C" WasmResult proxy_log(LogLevel level,
const char *logMessage,
size_t messageSize);
package internal
//export proxy_log
func ProxyLog(logLevel LogLevel,
messageData *byte,
messageSize int) Status
// @ts-ignore: decorator
@external("env", "proxy_log")
export declare function proxy_log(level: LogLevel,
logMessage: ptr<char>,
messageSize: size_t): WasmResult;
Владение памятью
Наверное это одна из самых важных тем при построении такой системой, где есть память на стороне хоста и память в wasm модуле. Никакая управляемая память не передается в wasm модуль при вызове обработчиков. Вместо этого wasm модуль сам запрашивает данные. Например proxy_on_http_request_body
передает информацию о количестве доступных байтов в теле запроса, модуль должен запросить эти данные используя proxy_get_buffer
. Когда это происходит, хост выделяет помять у wasm модуля вызовом proxy_on_memory_allocate
, копирует туда данные и отдает память во владение wasm модулю, в надежде, что он освободит ее.
proxy_on_memory_allocate
аргументы:
i32 (size_t) memory_size
возвращаемое значение:
i32 (void*) allocated_ptr
Реализация на AssemblyScript malloc.ts
import {
__pin,
__unpin,
} from "rt/itcms";
/// Allow host to allocate memory.
export function malloc(size: i32): usize {
let buffer = new ArrayBuffer(size);
let ptr = changetype<usize>(buffer);
return __pin(ptr);
}
/// Allow host to free memory.
export function free(ptr: usize): void {
__unpin(ptr);
}
Обратное преобразование
class ArrayBufferReference {
private buffer: usize;
private size: usize;
constructor() {
}
sizePtr(): usize {
return changetype<usize>(this) + offsetof<ArrayBufferReference>("size");
}
bufferPtr(): usize {
return changetype<usize>(this) + offsetof<ArrayBufferReference>("buffer");
}
// Before calling toArrayBuffer below, you must call out to the host to fill in the values.
// toArrayBuffer below **must** be called once and only once.
toArrayBuffer(): ArrayBuffer {
if (this.size == 0) {
return new ArrayBuffer(0);
}
let array = changetype<ArrayBuffer>(this.buffer);
// host code used malloc to allocate this buffer.
// release the allocated ptr. array variable will retain it, so it won't be actually free (as it is ref counted).
free(this.buffer);
// should we return a this sliced up to size?
return array;
}
}
В AssemblyScript заложили поведение при котором объекты можно отдавать во внешнюю среду(в первую очередь в JS). Для этого есть __pin/__unpin, что бы сборщик мусора не собрал объекты на которых уже нет ссылок. В Go
//nolint
//export proxy_on_memory_allocate
func proxyOnMemoryAllocate(size uint) *byte {
buf := make([]byte, size)
return &buf[0]
}
Обратное преобразование
import (
"reflect"
"unsafe"
)
func RawBytePtrToString(raw *byte, size int) string {
//nolint
return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
Data: uintptr(unsafe.Pointer(raw)),
Len: size,
Cap: size,
}))
}
func RawBytePtrToByteSlice(raw *byte, size int) []byte {
//nolint
return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
Data: uintptr(unsafe.Pointer(raw)),
Len: size,
Cap: size,
}))
}
Что тут можно сказать? В спецификации Go ничего не сказано про работу сборщика мусора. Компилятор с https://go.dev/ запрещает передавать указатели на память из Go в Си. Есть пакет go-pointer, который немного напоминает pin/unpin из AssemblyScript.
C.pass_pointer(pointer.Save(&s))
v := *(pointer.Restore(C.get_from_pointer()).(*string))
Внутри довольно простой
package pointer
// #include <stdlib.h>
import "C"
import (
"sync"
"unsafe"
)
var (
mutex sync.RWMutex
store = map[unsafe.Pointer]interface{}{}
)
func Save(v interface{}) unsafe.Pointer {
if v == nil {
return nil
}
// Generate real fake C pointer.
// This pointer will not store any data, but will bi used for indexing purposes.
// Since Go doest allow to cast dangling pointer to unsafe.Pointer, we do rally allocate one byte.
// Why we need indexing, because Go doest allow C code to store pointers to Go data.
var ptr unsafe.Pointer = C.malloc(C.size_t(1))
if ptr == nil {
panic("can't allocate 'cgo-pointer hack index pointer': ptr == nil")
}
mutex.Lock()
store[ptr] = v
mutex.Unlock()
return ptr
}
НО разработчики используют TinyGo, в котором сборщик мусора попроще и запускается когда недостаточно места в куче. Если между вызовом proxy_on_memory_allocate
и моментом возврата владения в Go нет выделения памяти, то это условно безопасно.
А вот в C++ SDK proxy_on_memory_allocate
не увидите. Идет поиск malloc, который экспортирует компилятор
$ em++ --no-entry -s EXPORTED_FUNCTIONS=['_malloc'] ...
На стороне хоста выполняется поиск malloc
, если нет, то ищется proxy_on_memory_allocate
void WasmBase::getFunctions() {
#define _GET(_fn) wasm_vm_->getFunction(#_fn, &_fn##_);
#define _GET_ALIAS(_fn, _alias) wasm_vm_->getFunction(#_alias, &_fn##_);
_GET(_initialize);
if (_initialize_) {
_GET(main);
} else {
_GET(_start);
}
_GET(malloc);
if (!malloc_) {
_GET_ALIAS(malloc, proxy_on_memory_allocate);
}
if (!malloc_) {
fail(FailState::MissingFunction, "Wasm module is missing malloc function.");
}
#undef _GET_ALIAS
#undef _GET
// Try to point the capability to one of the module exports, if the capability has been allowed.
#define _GET_PROXY(_fn) \
if (capabilityAllowed("proxy_" #_fn)) { \
wasm_vm_->getFunction("proxy_" #_fn, &_fn##_); \
} else { \
_fn##_ = nullptr; \
}
#define _GET_PROXY_ABI(_fn, _abi) \
if (capabilityAllowed("proxy_" #_fn)) { \
wasm_vm_->getFunction("proxy_" #_fn, &_fn##_abi##_); \
} else { \
_fn##_abi##_ = nullptr; \
}
FOR_ALL_MODULE_FUNCTIONS(_GET_PROXY);
if (abiVersion() == AbiVersion::ProxyWasm_0_1_0) {
_GET_PROXY_ABI(on_request_headers, _abi_01);
_GET_PROXY_ABI(on_response_headers, _abi_01);
} else if (abiVersion() == AbiVersion::ProxyWasm_0_2_0 ||
abiVersion() == AbiVersion::ProxyWasm_0_2_1) {
_GET_PROXY_ABI(on_request_headers, _abi_02);
_GET_PROXY_ABI(on_response_headers, _abi_02);
_GET_PROXY(on_foreign_function);
}
#undef _GET_PROXY_ABI
#undef _GET_PROXY
}
Точка входа _start
Написав в Go
package main
import (
"math/rand"
"time"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
)
const tickMilliseconds uint32 = 1000
func main() {
proxywasm.SetVMContext(&vmContext{})
}
type vmContext struct {
// Embed the default VM context here,
// so that we don't need to reimplement all the methods.
types.DefaultVMContext
}
AssemblyScript
export * from "@solo-io/proxy-runtime/proxy"; // this exports the required functions for the proxy to interact with us.
import { RootContext, Context, registerRootContext, FilterHeadersStatusValues, stream_context } from "@solo-io/proxy-runtime";
class AddHeaderRoot extends RootContext {
createContext(context_id: u32): Context {
return new AddHeader(context_id, this);
}
}
class AddHeader extends Context {
constructor(context_id: u32, root_context: AddHeaderRoot) {
super(context_id, root_context);
}
onResponseHeaders(a: u32, end_of_stream: bool): FilterHeadersStatusValues {
const root_context = this.root_context;
if (root_context.getConfiguration() == "") {
stream_context.headers.response.add("hello", "world!");
} else {
stream_context.headers.response.add("hello", root_context.getConfiguration());
}
return FilterHeadersStatusValues.Continue;
}
}
registerRootContext((context_id: u32) => { return new AddHeaderRoot(context_id); }, "add_header");
C++
#include <string>
#include <string_view>
#include <stdlib.h>
#include "proxy_wasm_intrinsics.h"
class ExampleContext : public Context {
public:
explicit ExampleContext(uint32_t id, RootContext *root) : Context(id, root) {}
FilterHeadersStatus onRequestHeaders(uint32_t headers, bool end_of_stream) override;
};
static RegisterContextFactory register_ExampleContext(CONTEXT_FACTORY(ExampleContext));
FilterHeadersStatus ExampleContext::onRequestHeaders(uint32_t, bool) {
LOG_DEBUG(std::string("print from wasm, onRequestHeaders, context id: ") + std::to_string(id()));
auto result = getRequestHeaderPairs();
auto pairs = result->pairs();
for (auto &p : pairs) {
LOG_INFO(std::string("print from wasm, ") + std::string(p.first) + std::string(" -> ") + std::string(p.second));
}
return FilterHeadersStatus::Continue;
}
Нужна точка входа, которая инициализирует рантайм C++/Go/AssemblyScript и выполнит что-нибудь вида main. WASI для таких целей предлагает _start и _initialize. Хотя в спеке есть только _start, но на хосте доступны оба варианта
class WasmBase : public std::enable_shared_from_this<WasmBase> {
//s..
protected:
//...
WasmCallVoid<0> _initialize_; /* WASI reactor (Emscripten v1.39.17+, Rust nightly) */
WasmCallVoid<0> _start_; /* WASI command (Emscripten v1.39.0+, TinyGo) */
WasmCallWord<2> main_;
WasmCallWord<1> malloc_;
//...
};
Строки и ассоциативный контейнеры
Один из недостатков это копирование памяти. Может потребоваться не только копирование, но и преобразование. В Go строки это просто последовательность байт, но обычно там UTF-8. В AssemblyScriptэто последовательность UCS-2 и требует преобразования.
export function log(level: LogLevelValues, logMessage: string): void {
// from the docs:
// Like JavaScript, AssemblyScript stores strings in UTF-16 encoding represented by the API as UCS-2,
let buffer = String.UTF8.encode(logMessage);
imports.proxy_log(level as imports.LogLevel, changetype<usize>(buffer), buffer.byteLength);
}
А передача привычного контейнера map потребует дополнительной упаковки/распаковки
func DeserializeMap(bs []byte) [][2]string {
numHeaders := binary.LittleEndian.Uint32(bs[0:4])
var sizeIndex = 4
var dataIndex = 4 + 4*2*int(numHeaders)
ret := make([][2]string, numHeaders)
for i := 0; i < int(numHeaders); i++ {
keySize := int(binary.LittleEndian.Uint32(bs[sizeIndex : sizeIndex+4]))
sizeIndex += 4
keyPtr := bs[dataIndex : dataIndex+keySize]
key := *(*string)(unsafe.Pointer(&keyPtr))
dataIndex += keySize + 1
valueSize := int(binary.LittleEndian.Uint32(bs[sizeIndex : sizeIndex+4]))
sizeIndex += 4
valuePtr := bs[dataIndex : dataIndex+valueSize]
value := *(*string)(unsafe.Pointer(&valuePtr))
dataIndex += valueSize + 1
ret[i] = [2]string{key, value}
}
return ret
}
func SerializeMap(ms [][2]string) []byte {
size := 4
for _, m := range ms {
// key/value's bytes + len * 2 (8 bytes) + nil * 2 (2 bytes)
size += len(m[0]) + len(m[1]) + 10
}
ret := make([]byte, size)
binary.LittleEndian.PutUint32(ret[0:4], uint32(len(ms)))
var base = 4
for _, m := range ms {
binary.LittleEndian.PutUint32(ret[base:base+4], uint32(len(m[0])))
base += 4
binary.LittleEndian.PutUint32(ret[base:base+4], uint32(len(m[1])))
base += 4
}
for _, m := range ms {
for i := 0; i < len(m[0]); i++ {
ret[base] = m[0][i]
base++
}
base++ // nil
for i := 0; i < len(m[1]); i++ {
ret[base] = m[1][i]
base++
}
base++ // nil
}
return ret
}
function serializeHeaders(headers: Headers): ArrayBuffer {
let result = new ArrayBuffer(pairsSize(headers));
let sizes = Uint32Array.wrap(result, 0, 1 + 2 * headers.length);
sizes[0] = headers.length;
// header sizes:
let index = 1;
// for in loop doesn't seem to be supported..
for (let i = 0; i < headers.length; i++) {
let header = headers[i];
sizes[index] = header.key.byteLength;
index++;
sizes[index] = header.value.byteLength;
index++;
}
let data = Uint8Array.wrap(result, sizes.byteLength);
let currentOffset = 0;
// for in loop doesn't seem to be supported..
for (let i = 0; i < headers.length; i++) {
let header = headers[i];
// i'm sure there's a better way to copy, i just don't know what it is :/
let wrappedKey = Uint8Array.wrap(header.key);
let keyData = data.subarray(currentOffset, currentOffset + wrappedKey.byteLength);
for (let i = 0; i < wrappedKey.byteLength; i++) {
keyData[i] = wrappedKey[i];
}
currentOffset += wrappedKey.byteLength + 1; // + 1 for terminating nil
let wrappedValue = Uint8Array.wrap(header.value);
let valueData = data.subarray(currentOffset, currentOffset + wrappedValue.byteLength);
for (let i = 0; i < wrappedValue.byteLength; i++) {
valueData[i] = wrappedValue[i];
}
currentOffset += wrappedValue.byteLength + 1; // + 1 for terminating nil
}
return result;
}
function deserializeHeaders(headers: ArrayBuffer): Headers {
if (headers.byteLength == 0) {
return [];
}
let numheaders = Uint32Array.wrap(headers, 0, 1)[0];
let sizes = Uint32Array.wrap(headers, sizeof<u32>(), 2 * numheaders);
let data = headers.slice(sizeof<u32>() * (1 + 2 * numheaders));
let result: Headers = [];
let sizeIndex = 0;
let dataIndex = 0;
// for in loop doesn't seem to be supported..
for (let i: u32 = 0; i < numheaders; i++) {
let keySize = sizes[sizeIndex];
sizeIndex++;
let header_key_data = data.slice(dataIndex, dataIndex + keySize);
dataIndex += keySize + 1; // +1 for nil termination.
let valueSize = sizes[sizeIndex];
sizeIndex++;
let header_value_data = data.slice(dataIndex, dataIndex + valueSize);
dataIndex += valueSize + 1; // +1 for nil termination.
let pair = new HeaderPair(header_key_data, header_value_data);
result.push(pair);
}
return result;
}
На этом все. Полезные ссылки
Комментарии (7)
maxim_ge
14.06.2022 17:47НО разработчики используют TinyGo, в котором сборщик мусора попроще и запускается когда недостаточно места в куче. Если между вызовом proxy_on_memory_allocate и моментом возврата владения в Go нет выделения памяти, то это условно безопасно.
Можно пример, как конкретно выглядит опасный сценарий при использовании go-pointer?
RPG18 Автор
14.06.2022 18:30+1Опасный сценарий при НЕ использования чего-то подобного go-pointer'у. Количество ссылок на buf равно 0.
func proxyOnMemoryAllocate(size uint) *byte { buf := make([]byte, size) return &buf[0] }
В общем случае GC может переиспользовать эту память, тогда хост при копировании данных что-то перетрет. Например стандартный компилятор вставляет преамбулу(Руководство по ассемблеру Go) в каждую функцию, в которой делает возврат в гошный рантайм, где может переключить горутину или сделать stack-split. Stack-split это когда текущего стека горутине не хватает, тогда идет увеличения стека. По хорошему нужно wasm2wat сделать и посмотреть на сгенеренный код TinyGo.
Тема интересная, что делать с языками с GC. Кто-то захотел SDK для C#.
Tuxman
14.06.2022 20:28Одно время, для расширения функциональности, использовали perl модули, экспортируя разные переменные, можно было выполнять какую-то работу на perl и запускать одну из функций из "бакенда" в конце, или просто возвращать какое-то значение, как фильтр.
Потом perl стал менее популярным, и запускать какой-то внешний python стало стильно/модно/молодёжно. Параллельно с этим некоторые проекты включали V8 для выполнения какой-то кастомной логики.
И получается, что следующее развитие - это WASM платформа, ведь в неё можно накомпилировать из чего угодно? Так можно было бы и просто джава-машину запускать, там бы и Jython работал.
RPG18 Автор
14.06.2022 22:32Ждем когда будет доступен доклад Александра Боргардта Запускаем почти произвольный код через WebAssembly на backend-end.
maxim_ge
Не уловил, каким образом
proxy_on_memory_allocate
"превращается" для AssemblyScriptв:
?
PS: Видимо, так:
RPG18 Автор
Спасибо, упустил этот момент.