В этой статье автор рассказывает о том, как самостоятельно построить сервис-меш (service mesh) с помощью современных инструментов и Open Source-решений. Материал будет полезен разработчикам и инженерам, интересующимся внутренним устройством сервис-мешей, их преимуществами, а также возможностями настройки и кастомизации под собственные нужды. Передаём слово автору.

На KubeCon US много говорили о сервис-мешах и mTLS. Я недавно возился с eBPF и потому задумался, насколько сложно создать такую сетку с нуля.

Из чего состоит сервис-меш

Чтобы создать собственную сервисную сетку, нужно реализовать несколько базовых компонентов. В большинстве сервис-мешей их больше, чем рассматривается ниже. Мы же в данном случае говорим об основах и минимуме, необходимом для реализации сервис-меша.

Редиректор трафика

Нужен способ брать трафик из приложения и отправлять его в другое место, обычно на прокси-сервер, где в него можно будет вносить изменения при необходимости. Трафик должен перенаправляться незаметно для приложения. При этом необходимо гарантировать, что трафик достигнет адресата, а ответ будет понятным для приложения. В большинстве случаев это решается с помощью правил iptables, которые изменяют IP-адреса отправителя и получателя пакетов по мере их прохождения через ядро. Как только под установит соединение с другим подом в кластере, его нужно будет перенаправить в нашу программу, которую назовем Proxy (прокси).

Proxy

Proxy должен находиться с сети в ожидании подключений, а для всех создаваемых исходящих соединений IP-адрес отправителя должен меняться на IP-адрес прокси. Кроме того, IP-адрес отправителя также нужно где-то хранить. На этом этапе мы сможем получать данные от отправителя. Прокси нужен для того, чтобы исходный трафик можно было изменить или пропарсить, а затем принять решение о его дальнейшей судьбе на основе полученной информации.

Сайдкар-инжектор

Сайдкар-инжектор — это код, который будет изменять поведение Kubernetes таким образом, чтобы при планировании новых рабочих нагрузок к ним добавлялся дополнительный контейнер (сайдкар) или чтобы перед запуском рабочей нагрузки запускалось что-то, что будет записывать правила iptables/nftables в ядро.

Сертификаты

Чтобы настроить mTLS между подами, нужно выпустить сертификаты, а для сертификатов нужны IP-адреса или имена хостов подов и т. д. Учитывая, что эти данные до запуска пода неизвестны, нужно будет перехватить эту информацию, следя за Kubernetes и генерируя сертификаты, пока под создается.

Начинаем

Первым делом воспользуемся eBPF, чтобы управлять трафиком и следить за тем, чтобы тот направлялся куда нужно. Почему eBPF? Ну потому что!

Впрочем, давайте разберемся...

Существует большое количество методов для манипулирования трафиком: XDP, TC, сокеты и т. д... Какой же из них выбрать?

  • XDP? Нет egress'а, а для того, чтобы перенаправить трафик наружу, нужен egress.

  • ТС? У него есть egress, НО трафик уже прошёл через ядро, iptables, сокеты и т. д. Менять трафик на этом этапе, чтобы следом отправить его обратно в ядро, немного нелогично.

  • Сокеты, кажется, лучший вариант для наших целей.

Магия eBPF

Наш eBPF-код будет манипулировать поведением пакетов на уровнях L3 и L4, пока те проходят через ядро и, в некоторых случаях, через userspace — пространство пользователя (то есть прокси).

Типовой пакет живет так (в нашем случае IP-адрес pod-01 — 10.0.0.10, IP-адрес pod-02 — 10.10.0.20):

  1. Запускается eBPF-программа. Ей (через eBPF map) передаётся диапазон сетевых адресов (CIDR) подов в кластере Kubernetes и PID прокси-сервера.

  2. Приложение в pod-01 хочет создать исходящее подключение connect(), в данном случае к pod-02. Это обычно происходит на высоком порте (например, 32305).

  3. eBPF-программа изменяет IP-адрес получателя с 10.10.0.20 на прокси, который слушает на localhost — 10.10.0.20:<порт> превращается в 127.0.0.1:18000.

  4. Оригинальный адрес и порт назначения также записываются в map. В качестве ключа используется сокет-cookie.

  5. Прокси на 127.0.0.1:18000 получает весь TCP-трафик от приложения, которое инициировало соединение. После установки токена можно задействовать eBPF-программу.

  6. В другую map добавляются порт отправителя 32305 и уникальный сокет-cookie.

  7. Прокси установил соединение с приложением, однако ему необходимо знать IP-адрес получателя. Для этого вызывается getsockopt со специальной опцией SO_ORIGINAL_DST. eBPF перехватывает это и выполняет поиск по src-порту 32305, чтобы найти cookie, затем по cookie в другой map ищется исходный адресат 10.10.0.20:<порт>.

  8. Теперь прокси может установить исходящее подключение к целевому поду или другому прокси (об этом поговорим позже).

  9. Трафик считывается (read()) с прокси, перенаправляется на внутреннее соединение, и приложение в pod-01 обрабатывает его так, как если бы прокси не было.

Почему мы передаем PID прокси в eBPF-программу?

Все просто: иначе мы попали бы в замкнутый круг, когда подключения, исходящие из прокси, опять попадали бы в прокси. А так, если мы видим подключение от прокси, мы его не перенаправляем.

Сокращённый лог:

$ kubectl logs pod-01 -c smesh-proxy
[2024/12/02T10:17:58.618] [application] [INFO] [main.go:66,main] Starting the SMESH 
[2024/12/02T10:17:58.618] [application] [INFO] [main.go:94,main] detected Kernel 6.8.x
[2024/12/02T10:17:58.682] [application] [INFO] [connection.go:23,startInternalListener] internal proxy [pid: 7] 127.0.0.1:18000
[2024/12/02T10:17:58.682] [application] [INFO] [connection.go:33,startExternalListener] external proxy [pid: 7] 0.0.0.0:18001
[2024/12/02T10:17:58.682] [application] [INFO] [connection.go:62,startExternalTLSListener] external TLS proxy [pid: 7] 0.0.0.0:18443

< Запускаем прокси-сервер >
< Получаем перенаправленное соединение от eBPF >

[2024/12/02T10:18:14.080] [application] [INFO] [connection.go:75,start] internal proxy connection from 127.0.0.1:33804 -> 127.0.0.1:18000

< Нашли соединение через eBPF, чтобы определить исходного адресата >
< Прокси подключается к pod-02 (на локальный порт прокси, где дальше происходит переадресация на приложение в том же поде); теперь можно отправлять трафик из pod-01 через прокси >

[2024/12/02T10:18:14.087] [application] [INFO] [connection.go:156,internalProxy] Connected to remote endpoint 10.10.0.20:18443, original dest 10.10.0.20:9000

< Приложение в pod-02 установило новое соединение в обратном направлении >

[2024/12/02T10:18:16.081] [application] [INFO] [connection.go:95,startTLS] external TLS proxy connection from 10.10.0.20:47292 -> 10.0.0.10:18443

В этом разделе мы рассмотрели элементы, необходимые для создания сервис-меша, а также показали, как с помощью eBPF перенаправить трафик на другой процесс в том же поде. Теперь нужно объединить все эти компоненты, чтобы направить трафик на другой под.

Прокси

В нашей реализации сервис-меша прокси будеть жить рядом с приложением. Да, вы правильно поняли — в сайдкар-контейнере.

То, как сайдкар туда попадёт, — интересная тема для обсуждения, поэтому давайте рассмотрим возможные варианты.

  1. У вас есть некоторый YAML-код. И по сути, в Deployment надо добавить описание сайдкара. Сделать это нужно до деплоя, поскольку добавить сайдкар-контейнер в существующий под не получится.

  2. «А как же эфемерные контейнеры?» — спросите вы. Да, они довольно хороши, и да, их можно добавить в существующий под. Но если потребуется примонтировать файлы (например, ? сертификаты), то в pod.spec нужно будет добавить описание тома, а этого сделать не получится. Слышу ваши мысли: «Так используй секреты и переменные окружения! Тогда не придётся менять тело pod.spec, только pod.spec.ephemeralcontainer[x]!» Отличная идея, но она не работает: связанное с этим Issue открыто уже (так-с, посмотрим…) почти два года! (Прим. пер.: оно было открыто на момент написания статьи. PR от 24 января разобрался, наконец, с проблемой.)

  3. Старый добрый сайдкар-инжектор! Де-факто общепринятый и устоявшийся метод изменения pod.spec до того, как она будет скормлена API Kubernetes. (Тут не стану вдаваться в подробности — тема уже хорошо освещена в многочисленных статьях.)

Так что прокси должен попасть в сайдкар-контейнер независимо от способа. Он будет у каждого приложения, с которым мы будем работать. Когда один под захочет «поговорить» с другим, «разговор» будет вестись через наши прокси.

Что должно быть в прокси:

  • код eBPF;

  • код, который будет устанавливать соединения и создавать TCP-слушателей для отправки и получения трафика;

  • необходимые сертификаты для шифрования трафика.

Запуск прокси

При запуске прокси определит свой PID и вместе с диапазоном сетевых адресов (CIDR) подов добавит его в eBPF map, после чего прикрепит eBPF-программы к ядру. Затем запустится слушатель (компонент прокси, прослушивающий сетевое соединение), через который прокси будет получать весь трафик, пересылаемый eBPF-программой. Он прочитает сертификаты (из файловой системы или переменных окружения) и, как только те будут загружены, запустит другой слушатель для входящих TLS-соединений (TLS-слушатель)! Вот и всё.

Жизненный цикл работы прокси

  1. Прокси слушает внутренний прокси-порт.

  2. На этом порте создаётся новое соединение. Его перехватывает eBPF и перенаправляет к прокси. Системный вызов getsockopt с опцией SO_ORIGINAL_DST помогает восстановить оригинальные IP-адрес и порт.

  3. Создаётся новое исходящее соединение с оригинальным IP-адресом получателя, однако порт заменяется на порт TLS-слушателя прокси. В результате устанавливается новое TLS-соединение между двумя прокси-серверами.

  4. Прокси-отправитель отправляет прокси-получателю тот порт, к которому мы изначально обращались.

  5. Прокси-получатель подключается к этому порту и начинает принимать трафик от источника.

  6. На этом этапе получаем сквозное соединение (end-to-end) между приложениями, при этом приложения не подозревают, что оно проходит через прокси.

Создание сертификатов

Чтобы работал TLS, сертификаты нужно создать с IP-адресами подов для корректной идентификации. В идеале эти данные нам нужны как можно скорее. Проблема в том, что IP-адрес выделяется поду только после того, как он будет создан API Kubernetes. Раздел Volume невозможно изменить после создания пода, но мы можем ссылаться на секреты как переменные окружения до того, как те будут созданы.

Для этого напишем код с использованием информеров Kubernetes (подробности здесь). Эти информеры сообщат нам, когда под был создан и обновлён (updated). Нас больше волнует обновление, поскольку именно при этой операции в pod.status.podIP будет записан адрес, который нас интересует. Получив IP-адрес, можно создать необходимые сертификаты и загрузить их в секрет для дальнейшего использования прокси-контейнером.

Последняя деталь — сайдкар-инжектор

Тут всё сравнительно просто. При запуске эта часть кода регистрируется в AdmissionController и следит за тем, чтобы определённые ресурсы (в нашем случае поды) при создании проходили через наш код. Тот включит в секцию initContainers спецификации пода описание прокси-контейнера и вернёт её в API Kubernetes для дальнейшего планирования на узел.

Итог

Мы получили зачатки сервис-меша (по крайней мере, в моём понимании). Трафик прозрачно передаётся от приложения к приложению через прокси и его можно менять по своему усмотрению. Кроме того, выпускаются сертификаты для сквозного mTLS — трафик между отправителем и получателем шифруется. Но это не значит, что на этом нужно останавливаться ?.

Что дальше?

Весь исходный код для этого эксперимента доступен по адресу https://github.com/thebsdbox/smesh. Заходите и смотрите. Он не самый опрятный, но зато работает ?.

P. S.

Читайте также в нашем блоге:

Комментарии (0)