Задумался о том что бы прикрутить к своему пет проекту систему плагинов на 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; }
На этом все. Полезные ссылки
ссылка на оригинал статьи https://habr.com/ru/post/671048/
Добавить комментарий