Задумался о том что бы прикрутить к своему пет проекту систему плагинов на 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)


  1. maxim_ge
    14.06.2022 16:30
    +1

    Не уловил, каким образом proxy_on_memory_allocate "превращается" для AssemblyScript
    в:


    /// Allow host to allocate memory.
    export function malloc(size: i32): usize {
      let buffer = new ArrayBuffer(size);
      let ptr = changetype<usize>(buffer);
      return __pin(ptr);
    }

    ?
    PS: Видимо, так:


    На стороне хоста выполняется поиск malloc, если нет, то ищется proxy_on_memory_allocate


    1. RPG18 Автор
      14.06.2022 17:30

      Спасибо, упустил этот момент.


  1. maxim_ge
    14.06.2022 17:47

    НО разработчики используют TinyGo, в котором сборщик мусора попроще и запускается когда недостаточно места в куче. Если между вызовом proxy_on_memory_allocate и моментом возврата владения в Go нет выделения памяти, то это условно безопасно.

    Можно пример, как конкретно выглядит опасный сценарий при использовании go-pointer?


    1. 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#.


  1. Tuxman
    14.06.2022 20:28

    Одно время, для расширения функциональности, использовали perl модули, экспортируя разные переменные, можно было выполнять какую-то работу на perl и запускать одну из функций из "бакенда" в конце, или просто возвращать какое-то значение, как фильтр.

    Потом perl стал менее популярным, и запускать какой-то внешний python стало стильно/модно/молодёжно. Параллельно с этим некоторые проекты включали V8 для выполнения какой-то кастомной логики.

    И получается, что следующее развитие - это WASM платформа, ведь в неё можно накомпилировать из чего угодно? Так можно было бы и просто джава-машину запускать, там бы и Jython работал.


    1. maxim_ge
      14.06.2022 22:29

      WASM-модули относительно легко контролировать по использованию CPU и памяти, чего не скажешь про Java. Варианты есть, но они, будем так говорить, не для всех и experimental.


    1. RPG18 Автор
      14.06.2022 22:32

      Ждем когда будет доступен доклад Александра Боргардта Запускаем почти произвольный код через WebAssembly на backend-end.