Всем привет! Меня зовут Михаил Голубев, и я работаю в команде Kubernetes Security, MWS Cloud Platform. В этой статье я расскажу об одной попытке оптимизации: как мы залезли под капот CEL и Protobuf, чтобы научить интерпретатор читать данные из бинарного формата на лету без полной десериализации, и почему в итоге от этого кода пришлось отказаться.

Почему мы вообще этим озадачились: в одном из наших микросервисов реализовано сканирование приходящих событий по k8s-манифестам. Событий летит много, а обрабатывать их надо быстро, так что, чтобы минимизировать сетевой трафик и снизить нагрузку на инфраструктуру, в качестве основного формата передачи данных мы первоначально решили использовать Protobuf, он значительно компактнее JSON.
Далее эти данные проходят через различные валидации и проверки. Для описания и выполнения таких политик мы используем CEL — наподобие Kyverno или нативных k8s Validating (и Mutating) Admission Policies. Вот тут-то Protobuf и CEL встретились, и эта встреча оказалась не такой гладкой, как мы рассчитывали, — об этом подробнее ниже.
Что такое CEL и его базовая интеграция
Google CEL (Common Expression Language) — это легковесный, строго типизированный, не полный по Тьюрингу язык выражений (официальная спецификация). На нём удобно писать пользовательские правила и политики.
CEL используется в нашем облаке в качестве основного языка описания политик безопасности через движок Kyverno для Management-кластеров, а ещё в нашем микросервисе Cerebro — движка обработки политик безопасности клиентских k8s-кластеров.
Подробнее про архитектуру Managed Kubernetes в MWS Cloud Platform рассказали мои коллеги в эпизоде реалити «Создавая облако».
Официальная реализация CEL написана на Go (github.com/google/cel-go), так что мы можем использовать её как библиотеку и даже залезать под капот, поскольку в облаке мы пишем все сервисы, отвечающие за основную рабочую нагрузку (Data Plane/DPL-слой) как раз на Go.
Жизненный цикл работы с CEL в Go обычно делится на три фазы:
— инициализацию окружения: проводим один раз при старте;
— компиляцию правила: выполняется один раз;
— выполнение: выполняется на каждый запрос.
Вот как это выглядит:
// 1. Инициализация окружения env, _ := cel.NewEnv(cel.Variable("user", cel.DynType)) // 2. Компиляция ast, _ := env.Compile("user.name == 'Alex'") prg, _ := env.Program(ast) // 3. Выполнение out, _, _ := prg.Eval(map[string]any{ "user": map[string]any{ "id": "123", "name": "Alex", }, }) println(out.Value().(bool)) // true
Проблема десериализации
Узкое место архитектуры находится на этапе подготовки данных для вызова функции выполнения Eval(). По сети нам прилетает компактный массив байтов; чтобы передать его в стандартный CEL, нужно сделать proto.Unmarshal в сгенерированную Go-структуру и преобразовать в map[string]any — карту полей объекта, чтобы CEL при обращении через точку гулял по ключам мапы.
На ум сразу приходит очевидный недостаток такого подхода: мы выделяем память под всё дерево объектов, создаём множество указателей, нагружая Garbage Collector, только ради того, чтобы CEL проверил одно поле. При высоких нагрузках это приводит к избыточным аллокациям памяти. Большая часть распакованных данных интерпретатору просто не нужна.
Для примера дороговизны стандартного анмаршалинга внизу представлен бенчмарк с десериализацией JSON и Protobuf в map[string]any и последующей передачей в среду исполнения CEL ради вычисления выражения, затрагивающего одно поле и аналогичный подход с proto. Для выразительности я специально взял большую структуру, подобную k8s-манифесту, чтобы приблизить цифры к реальным.

Как мы видим, переход на Protobuf уже даёт немалый прирост производительности и экономит потребляемую память, однако у нас встал вопрос, можем ли мы сократить аллокации ещё сильнее? Ведь по факту мы полностью десериализуем каждое сообщение, хотя большинство правил содержат в своих CEL-инструкциях обращение к 2–3 полям.
Так у нас появилась идея: можно ли передать в CEL сырые байты Protobuf и научить его вытаскивать нужные поля лениво, прямо в момент обращения? Конечно, можно, ведь мы можем залезть под капот CEL и реализовать его внутренние интерфейсы как угодно. Однако для реализации такого ленивого чтения байтов protobuf надо сначала понять, как и в каком порядке эти байты в Protobuf-закодированном сообщении всё-таки лежат.
Вкратце как устроен Protobuf
Для начала нужно понимать разницу между JSON и Protobuf. JSON — самодокументированный формат. Парсер видит ключи вроде "name" или "id" прямо в тексте.
Protobuf устроен иначе (документация по Encoding). В бинарном виде нет никаких имён полей. Данные хранятся в виде последовательности пар [Tag][Value].
Tag — это число (varint), которое содержит номер поля (из .proto файла) и тип данных (Wire Type).
Value — сами данные в бинарном виде.
Wire Type подсказывает парсеру, как прочитать значение: это varint (число), фиксированные 64 бита (double) или данные переменной длины (строка/сообщение).
На уровне байта сообщение выглядит так:
[Tag: field=1, wire=varint] [Value: 150] [Tag: field=2, wire=len-delimited][Value: "Alex"] [Tag: field=3, wire=len-delimited][Value: <nested_msg_bytes>]
Если нам нужно найти скалярное поле № 3, благодаря этой структуре мы можем просто считывать теги и пропускать значения остальных полей, сдвигая указатель по массиву байт и не аллоцируя память.
А как быть со списками и вложенными объектами? Здесь формат раскрывается особенно интересно:
Вложенные объекты (Messages): в Protobuf они передаются с типом Bytes (Length-delimited). Значение содержит длину вложенного сообщения и его сырые байты. Если CEL запрашивает вложенный объект, мы читаем эти байты через
protowire.ConsumeBytesи возвращаем новый экземпляр нашей обёрткиLazyMessage, передав ей этот фрагмент байт и дескриптор дочернего сообщения. Мы снова избегаем десериализации!Списки (Repeated): они могут передаваться двумя путями. Либо как несколько подряд идущих одинаковых тегов (для сложных объектов), либо в упакованном виде (packed) для примитивов, где все элементы лежат одним массивом под одним тегом. При поиске списка мы не прерываем сканирование массива на первом совпадении тега. Мы пробегаем байты до конца, собираем все совпадения или распаковываем массив и возвращаем интерпретатору готовый
types.NewDynamicList.
Вот как выглядит общая функция-обёртка для сканирования байтов:
func scanField(b []byte, target protowire.Number, onMatch func(typ protowire.Type, valBytes []byte) bool) error { for len(b) > 0 { // 1. Читаем тег: num, typ, n := protowire.ConsumeTag(b) b = b[n:] // 2. Пропускаем значение, получая его длину (m) m := protowire.ConsumeFieldValue(num, typ, b) // Если нашли нужное поле — передаём байты значения в коллбек. if num == target { // Коллбек возвращает true, если сканирование нужно остановить, например, // для скаляров. Или false, если нужно продолжать собирать элементы (для списков). if stop := onMatch(typ, b[:m]); stop { return nil } } b = b[m:] // прыгаем к следующему тегу } return nil }
Откуда взять дескрипторы
Появляется проблема: CEL оперирует строками (выражение вида user.name), а Protobuf — числами (поле № 2). Как их связать, ведь формат не самодокументируемый? Для этого нужны Protobuf Descriptors — метаданные, описывающие схему. Получить их в Go можно двумя путями:
Из сгенерированных .pb.go файлов. Если вы компилируете proto-файлы для своего проекта, в каждом *.pb.go файле есть функция
init(). Она автоматически регистрирует схемы в глобальном реестре. Вы можете достать схему нужного сообщения одной строкой:
desc, err := protoregistry.GlobalFiles.FindDescriptorByName("lazyeval.v1.User")
Из файла schemas.pb. Если у вас динамические схемы, вы можете сгенерировать бинарный дескриптор через
protoc --descriptor_set_out=schemas.pb ...встроить его в бинарник через go:embed и загрузить черезprotodesc.NewFiles().
Имея дескриптор, мы можем на лету спросить: «Какой номер у поля name?» — и получить ответ: «2».
Интеграция с интерфейсами CEL
Чтобы CEL принял наш кастомный тип данных, структура должна реализовывать интерфейс — ref.Val. А чтобы работало обращение по ключу через точку, потребуется реализовать traits.Indexer (метод Get).
Пример реализации метода Get интерфейса traits.Indexer:
type LazyMessage struct { RawBytes []byte Desc protoreflect.MessageDescriptor cache map[protowire.Number]ref.Val // мемоизация уже прочитанных полей } // Метод Get вызывается CEL-интерпретатором при обращении к полю: func (l *LazyMessage) Get(index ref.Val) ref.Val { field, errVal := l.getFieldDesc(index) if errVal != nil { return errVal } num := field.Number() if l.cache == nil { l.cache = make(map[protowire.Number]ref.Val) } else if v, ok := l.cache[num]; ok { return v } var v ref.Val switch { case field.IsMap(): v = l.getMap(field) case field.IsList(): v = l.getList(field) default: v = l.getSingular(field) } l.cache[num] = v return v } // getFieldDesc достаёт схему поля из дескриптора по ключу func (l *LazyMessage) getFieldDesc(index ref.Val) (protoreflect.FieldDescriptor, ref.Val) { fieldName, ok := index.Value().(string) if !ok { return nil, types.NewErr("unsupported field key type: %v", index.Type()) } field := l.Desc.Fields().ByName(protoreflect.Name(fieldName)) if field == nil { field = l.Desc.Fields().ByJSONName(fieldName) } if field == nil { return nil, types.NewErr("no such field: %s", fieldName) } return field, nil }
Методы getList, getMap и getSingular, как должно быть понятно из названия, представляют собой декодирование в соответствующий тип. Рассмотрим на примере getSingular:
func (l *LazyMessage) getSingular(field protoreflect.FieldDescriptor) ref.Val { var res ref.Val var merged []byte isMsg := field.Kind() == protoreflect.MessageKind err := scanField(l.RawBytes, field.Number(), func(typ protowire.Type, valBytes []byte) bool { if isMsg { body, n := protowire.ConsumeBytes(valBytes) if n >= 0 { merged = append(merged, body...) } return false // в proto3 сообщения могут быть разбиты на части, продолжаем слияние } res = decodeValue(field, valBytes) return true }) if err != nil { return types.NewErr(err.Error()) } // При слиянии нескольких фрагментов одного сообщения unknownFields сериализованной // структуры теряются — для CEL-использования это некритично. if isMsg && merged != nil { return &LazyMessage{RawBytes: merged, Desc: field.Message()} } if res != nil { return res } return zeroValue(field) }
Внутри decodeValue мы используем функции protowire.ConsumeString, protowire.ConsumeBytes, protowire.ConsumeVarint и оборачиваем результаты в нативные типы библиотеки cel-go (например, types.String()):
func decodeValue(field protoreflect.FieldDescriptor, b []byte) ref.Val { switch field.Kind() { case protoreflect.MessageKind, protoreflect.GroupKind: v, _ := decodeWire(b, protowire.ConsumeBytes, func(body []byte) ref.Val { return &LazyMessage{RawBytes: body, Desc: field.Message()} }) return v case protoreflect.BytesKind: v, _ := decodeWire(b, protowire.ConsumeBytes, asBytes) return v case protoreflect.StringKind: v, _ := decodeWire(b, protowire.ConsumeString, asString) return v default: v, _ := decodeScalarConsume(field, b) return v } }
Функции decode* принимают в себя либо поле, либо слайс байт, вышеописанные функции пакета protowire, которые объясняют, как это поле должно быть прочитано, и функцию, которая просто оборачивает это дело в cel-типы. Например:
func asBytes(v []byte) ref.Val { return types.Bytes(v) } func asString(v string) ref.Val { return types.String(v) }
В то же время decodeScalarConsume представляет из себя большой switch-case по всем возможным скалярным типам вроде int, int32, float, etc.
Для реализации ref.Val необходимо реализовать следующие методы (из их названий и сигнатур довольно очевидно их назначение):
func (l *LazyMessage) Type() ref.Type { return types.NewObjectType(string(l.Desc.FullName())) } func (l *LazyMessage) Value() any { return l.RawBytes } func (l *LazyMessage) ConvertToNative(typeDesc reflect.Type) (any, error) { if typeDesc == reflect.TypeOf([]byte(nil)) { return l.RawBytes, nil } return nil, fmt.Errorf("unsupported native conversion to %v", typeDesc) } func (l *LazyMessage) ConvertToType(t ref.Type) ref.Val { // если передаётся тип как значение — возвращаем обычный Object. if t == types.TypeType { return types.NewObjectType(string(l.Desc.FullName())) } if t.TypeName() == string(l.Desc.FullName()) { return l } return types.NewErr("type conversion not supported: %s", t.TypeName()) } func (l *LazyMessage) Equal(other ref.Val) ref.Val { o, ok := other.(*LazyMessage) if !ok || o.Desc.FullName() != l.Desc.FullName() { return types.False } return types.Bool(bytes.Equal(l.RawBytes, o.RawBytes)) }
Что у нас получилось…
Интеграция ленивого парсера в код выглядит так:
out, _, _ := prg.Eval(map[string]any{ "user": &lazymessage.LazyMessage{ RawBytes: b, // сырые байты из сети Desc: desc, // дескриптор сообщения }, })
То есть мы просто передаём в среду исполнения CEL указатель на структуру, которая реализует интерфейсы CEL-движка, связанные с обращением к полю сообщения и так далее. Самое время показать бенчмарк и увидеть, насколько мы выиграли в аллокациях:

Стоит иметь в виду, что на небольших структурах этот подход будет избыточен, поскольку реализация такой вундервафли ради экономии 10–15 аллокаций неоправданна, в отличие от ситуации, когда мы оперируем огромными манифестами k8s и выигрываем в 15–20 раз.
Итак, на синтетических тестах результаты отличные. За счёт того, что парсинг перенесён в рантайм Eval, само выполнение правила занимает чуть больше времени. Однако общее время обработки входящего сообщения и объём аллокаций памяти сокращаются кратно.
… и почему от этого в итоге пришлось отказаться
Несмотря на красивые бенчмарки, этот код не полностью прижился в нашей системе. Причина кроется в архитектуре Kubernetes.
Дело в том, что CEL-правила для популярных политик безопасности вроде PSS или CIS Benchmark пишутся, опираясь на JSON-представление объектов Kubernetes. Также если мы захотим давать пользователю возможность в будущем писать свои кастомные правила, хочется, чтобы он это делал, опираясь на API спецификации ресурсов от k8s. Но внутренние Protobuf-контракты k8s не всегда соответствуют их JSON-аналогам. Например, в JSON есть инлайн-инструкции, а структура ObjectMeta может сериализоваться иначе.
Наш ленивый парсер жёстко привязан к Protobuf-дескрипторам. Как только структура proto-сообщения перестаёт зеркально повторять JSON API, логика ломается.
Однако подход с ленивым чтением так или иначе оказался полезен: для нашей конкретной задачи мы ограничились ленивым потоковым чтением и передачей по сети JSON, а не Protobuf. Да, мы всё ещё оперируем JSON, который, конечно, не такой эффективный, как Protobuf, однако эту цену мы платим за полное соответствие k8s контрактам: мы решили, что в нашей ситуации это гораздо важнее.
Если у вас есть вопросы, комментарии или хотите обсудить наш подход, приходите в сообщество MWS Cloud Platform в Телеграме. На все сообщения в чате отвечают инженеры облака.
