[Перевод] Контейнеры, микросервисы и сервис-меши
В интернете кучастатей о сервис-мешах (service mesh), и вот ещё одна. Ура! Но зачем? Затем, что я хочу изложить своё мнение, что лучше бы сервис-меши появились 10 лет назад, до появления контейнерных платформ, таких как Docker и Kubernetes. Я не утверждаю, что моя точка зрения лучше или хуже других, но поскольку сервис-меши — довольно сложные животные, множественность точек зрения поможет лучше их понять.
Я расскажу о платформе dotCloud, которая была построена на более чем сотне микросервисах и поддерживала тысячи приложений в контейнерах. Я объясню проблемы, с которыми мы столкнулись при её разработке и запуске, и как сервис-меши могли бы помочь (или не могли).
Я уже писал об истории dotCloud и выборе архитектуры для этой платформы, но мало рассказывал о сетевом уровне. Если не хотите погружаться в чтение прошлой статьи о dotCloud, то вкратце вот суть: это платформа-как-сервис PaaS, позволяющая клиентам запускать широкий спектр приложений (Java, PHP, Python…), c поддержкой широкого спектра служб данных (MongoDB, MySQL, Redis…) и рабочим процессом как у Heroku: вы загружаете свой код на платформу, она строит образы контейнеров и разворачивает их.
Я расскажу, как направлялся трафик на платформу dotCloud. Не потому, что это было особенно здорово (хотя для своего времени система работала неплохо!), но прежде всего потому, что с помощью современных инструментов такой дизайн легко может реализовать за короткое время скромная команда, если им нужен способ маршрутизации трафика между кучей микросервисов или кучей приложений. Таким образом, можно сравнить варианты: что получается, если разработать всё самим или использовать существующий сервис-меш. Стандартный выбор: сделать самим или купить.
Приложения на dotCloud могут предоставлять конечные точки HTTP и TCP.
Конечные точки HTTP динамически добавляются в конфигурацию кластера балансировщиков нагрузки Hipache. Это похоже на то, что сегодня делают ресурсы Ingress в Kubernetes и балансировщик нагрузки вроде Traefik.
Клиенты подключаются к конечным точкам HTTP через соответствующие домены при условии, что доменное имя указывает на балансировщики нагрузки dotCloud. Ничего особенного.
Конечные точки TCP связаны с номером порта, который затем передаётся всем контейнерам этого стека через переменные среды.
Клиенты могут подключаться к конечным точкам TCP, используя соответствующее имя хоста (что-то вроде gateway-X.dotcloud.com) и номер порта.
Это имя хоста резолвится на кластер серверов «nats» (не имеет отношения к NATS), которые будут маршрутизировать входящие TCP-соединения в правильный контейнер (или, в случае служб с балансировкой нагрузки, в правильные контейнеры).
Если вы знакомы с Kubernetes, вероятно, это напомнит вам службы NodePort.
На платформе dotCloud не было эквивалента служб ClusterIP: для простоты доступ к службам происходил одинаково как изнутри, так и снаружи платформы.
Всё было организовано достаточно просто: первоначальные реализации сетей маршрутизации HTTP и TCP, вероятно, всего по несколько сотен строк Python. Простые (я бы сказал, наивные) алгоритмы, которые дорабатывались с ростом платформы и появлением дополнительных требований.
Обширный рефакторинг существующего кода не требовался. В частности, 12-факторные приложения могут напрямую использовать адрес, полученный через переменные окружения.
Ограниченная обзорность. У нас вообще не было никаких метрик для сетки маршрутизации TCP. Что касается маршрутизации HTTP, то в более поздних версиях появились подробные HTTP-метрики с кодами ошибок и временем отклика, но современные сервис-меши идут ещё дальше, обеспечивая интеграцию с системами сбора метрик, как Prometheus, например.
Обзорность важна не только с оперативной точки зрения (чтобы помогать в устранении проблем), но и при выпуске новых функций. Речь о безопасном сине-зелёном деплое и деплое канареек.
Эффективность маршрутизации тоже ограничена. В сетке маршрутизации dotCloud весь трафик должен был проходить через кластер выделенных узлов маршрутизации. Это означало потенциальное пересечение нескольких границ AZ (зоны доступности) и значительное увеличение задержки. Помню, как устранял проблемы с кодом, который делал более сотни SQL-запросов на страницу и для каждого запроса открывал новое соединение с SQL-сервером. При локальном запуске страница загружается мгновенно, но в dotCloud загрузка занимает несколько секунд, потому что для каждого TCP-соединения (и последующего SQL-запроса) требуется десятки миллисекунд. В этом конкретном случае проблему решили постоянные соединения.
Современные сервис-меши лучше справляются с такими проблемами. Прежде всего, они проверяют, что соединения маршрутизируются в источнике. Логический поток тот же: клиент → меш → сервис
, но теперь меш работает локально, а не на удалённых узлах, поэтому соединение клиент → меш
является локальным и очень быстрым (микросекунды вместо миллисекунд).
Современные сервис-меши также реализуют более умные алгоритмы балансировки нагрузки. Контролируя работоспособность бэкендов, они могут отправлять больше трафика на более быстрые бэкенды, что приводит к повышению общей производительности.
Безопасность тоже лучше. Сетка маршрутизации dotCloud работала полностью на EC2 Classic и не шифровала трафик (исходя из предположения, что если кому-то удалось поставить сниффер на сетевой трафик EC2, у вас уже большие проблемы). Современные сервис-меши прозрачно защищают весь наш трафик, например, с взаимной TLS-аутентификацией и последующим шифрованием.
Хорошо, мы обсудили трафик между приложениями, но как насчёт самой платформы dotCloud?
Сама платформа состояла примерно из сотни микросервисов, отвечающих за различные функции. Одни принимали запросы от других, а некоторые были фоновыми воркерами, которые подключались к другим службам, но сами не принимали соединений. В любом случае, каждая служба должна знать конечные точки адресов, к которым необходимо подключиться.
Многие службы высокого уровня могут использовать сетку маршрутизации, описанную выше. На самом деле, многие из более чем сотни микросервисов dotCloud были развёрнуты как обычные приложения на самой платформе dotCloud. Но небольшое количество низкоуровневых сервисов (в частности, которые реализуют эту сетку маршрутизации) нуждались в чём-то более простом, с меньшими зависимостями (поскольку для работы они не могли зависеть от самих себя — старая добрая проблема курицы и яйца).
Эти низкоуровневые, важные службы были развёрнуты путём запуска контейнеров непосредственно на нескольких ключевых узлах. При этом не задействовались стандартные службы платформы: компоновщик, планировщик и runner. Если хотите сравнить с современными контейнерными платформами, это похоже на запуск плоскости управления с docker run
непосредственно на узлах, вместо делегирования задачи Kubernetes. Это довольно похоже на концепцию статических модулей (подов), которые использует kubeadm или bootkube при загрузке автономного кластера.
Эти службы экспонировались простым и грубым способом: в файле YAML были перечислены их имена и адреса;, а каждый клиент должен был для деплоя взять копию этого YAML-файла.
С одной стороны, это чрезвычайно надёжно, потому что не требует поддержки внешнего хранилища ключей/значений, такого как Zookeeper (не забывайте, в то время ещё не существовало etcd или Consul). С другой стороны, это затрудняло перемещение служб. Каждый раз при перемещении все клиенты должны были получить обновлённый файл YAML (и потенциально перезагрузиться). Не очень удобно!
Впоследствии мы начали внедрять новую схему, где каждый клиент подключался к локальному прокси-серверу. Вместо адреса и порта ему достаточно знать только номер порта службы, и подключаться через localhost
. Локальный прокси-сервер обрабатывает это соединение и направляет его на фактический сервер. Теперь при перемещении бэкенда на другую машину или масштабировании вместо обновления всех клиентов нужно обновить только все эти локальные прокси; и перезагрузка больше не требуется.
(Также планировалось инкапсулировать трафик в TLS-соединениях и поставить ещё один прокси-сервер на принимающей стороне, а также проверять сертификаты TLS без участия принимающей службы, которая настроена на приём соединений только на localhost
. Об этом позже).
Это очень похоже на SmartStack от Airbnb, но существенная разница в том, что SmartStack реализован и развёрнут в продакшн, в то время как внутреннюю систему маршрутизации dotCloud убрали в ящик, когда dotCloud превратился в Docker.
Я лично считаю SmartStack одним из предшественников таких систем, как Istio, Linkerd и Consul Connect, потому что все они следуют одному шаблону:
- Запуск прокси на каждом узле.
- Клиенты подключаются к прокси.
- Плоскость управления обновляет конфигурацию прокси-сервера при изменении бэкендов.
- … Профит!
Если нам нужно реализовать подобную сетку сегодня, мы можем использовать аналогичные принципы. Например, настроить внутреннюю зону DNS, сопоставляя имена служб адресам в пространстве 127.0.0.0/8
. Затем запустить HAProxy на каждом узле кластера, принимая соединения по каждому адресу службы (в этой подсети 127.0.0.0/8
) и перенаправляя/балансируя нагрузку на соответствующие бэкенды. Конфигурация HAProxy может управляться confd, позволяя хранить информацию о бэкенде в etcd или Consul и автоматически пушить обновлённую конфигурацию на HAProxy, когда это необходимо.
Примерно так работает Istio! Но с некоторыми отличиями:
- Использует Envoy Proxy вместо HAProxy.
- Сохраняет конфигурацию бэкенда через Kubernetes API вместо etcd или Consul.
- Службам выделяются адреса во внутренней подсети (адреса Kubernetes ClusterIP) вместо 127.0.0.0/8.
- Имеет дополнительный компонент (Citadel) для добавления взаимной проверки подлинности TLS между клиентом и серверами.
- Поддерживает новые функции, таких как разрыв цепи (circuit breaking), распределённая трассировка, деплой канареек и др.
Давайте вкратце рассмотрим некоторые различия.
Envoy Proxy
Envoy Proxy написала компания Lyft [конкурент Uber на рынке такси — прим. пер.]. Он во многом похож на другие прокси (например, HAProxy, Nginx, Traefik…), но Lyft написала свой, потому что им были нужны функции, отсутствующие в других прокси, и разумнее показалось сделать новый, чем расширять существующий.
Envoy можно использовать сам по себе. Если у меня есть конкретная служба, которая должна подключаться к другим службам, я могу настроить её на подключения к Envoy, а затем динамически настраивать и перенастраивать Envoy с расположением других служб, получая при этом много отличных дополнительных функций, например, по обзорности. Вместо кастомной клиентской библиотеки или внедрения в код трассировки вызовов мы направляем трафик в Envoy, а он собирает для нас метрики.
Но Envoy также способен работать как плоскость данных (data plane) для сервис-меша. Это означает, что теперь для данного сервис-меша Envoy настраивается плоскостью управления (control plane).
Плоскость управления
В плоскости управления Istio полагается на Kubernetes API. Это не очень отличается от использования confd, который полагается на etcd или Consul для просмотра набора ключей в хранилище данных. Istio через Kubernetes API просматривает набор ресурсов Kubernetes.
Между делом: лично мне показалось полезным это описание Kubernetes API, которое гласит:
Сервер Kubernetes API — это «глупый сервер», который предлагает хранение, управление версиями, проверку, обновление и семантику ресурсов API.
Istio разработан для работы с Kubernetes; и если вы хотите использовать его за пределами Kubernetes, то вам нужно запустить экземпляр сервера Kubernetes API (и вспомогательной службы etcd).
Адреса служб
Istio полагается на адреса ClusterIP, которые выделяет Kubernetes, поэтому службы Istio получают внутренний адрес (не в диапазоне 127.0.0.0/8
).
Трафик на адрес ClusterIP для конкретной службы в кластере Kubernetes без Istio перехватывается kube-proxy и отправляется на серверную часть этого прокси. Если вас интересуют технические детали, то kube-proxy устанавливает правила iptables (или балансировщики нагрузки IPVS, в зависимости от того, как его настроили), чтобы переписать IP-адреса назначения соединений, идущих по адресу ClusterIP.
После установки Istio в кластере Kubernetes ничего не меняется, пока он не будет явно включён для данного потребителя или даже всего пространства имён, путём введения контейнера sidecar
в кастомные поды. Этьот контейнер запустит экземпляр Envoy и установит ряд правил iptables для перехвата трафика, идущего в другие службы, и перенаправления этого трафика на Envoy.
При интеграции с Kubernetes DNS это означает, что наш код может подключаться по имени службы, и всё «просто работает». Другими словами, наш код выдаёт запросы типа http://api/v1/users/4242
, тогда api
резолвит запрос на 10.97.105.48
, правила iptables перехватывают соединения с 10.97.105.48 и перенаправляют их на локальный прокси Envoy, а этот локальный прокси направит запрос на фактический бэкенд API. Фух!
Дополнительные рюшечки
Istio также обеспечивает сквозное шифрование и аутентификацию через mTLS (mutual TLS). За это отвечает компонент под названием Citadel.
Также есть компонент Mixer, который Envoy может запросить для каждого запроса, чтобы принять специальное решение об этом запросе в зависимости от различных факторов, таких как заголовки, загрузка бэкенда и т. д… (не волнуйтесь: есть много средств обеспечить работоспособность Mixer, и даже если он слетит, Envoy продолжит нормально работать как прокси).
И, конечно, мы упомянули обзорность: Envoy собирает огромное количество метрик, обеспечивая при этом распределённую трассировку. В архитектуре микросервисов, если один запрос API должен пройти через микросервисы A, B, C и D, то при входе в систему распределённая трассировка добавит к запросу уникальный идентификатор и сохранит данный идентификатор через подзапросы ко всем этим микросервисам, позволяя фиксировать все связанные вызовы, их задержки и т. д.
У Istio репутация сложной системы. Напротив, построение сетки маршрутизации, которую я описал в начале этого поста, относительно просто с помощью существующих инструментов. Итак, есть ли смысл вместо этого создавать собственный сервис-меш?
Если у нас скромные потребности (не нужна обзорность, прерыватель цепи и другие тонкости), то приходят мысли о разработке собственного инструмента. Но если мы используем Kubernetes, он может даже не понадобиться, потому что Kubernetes уже предоставляет базовые инструменты для обнаружения служб и балансировки нагрузки.
Но если у нас продвинутые требования, то «покупка» сервис-меша представляется намного лучшим вариантом. (Это не всегда именно «покупка», поскольку Istio поставляется с открытым исходным кодом, но нам всё равно нужно инвестировать инженерное время, чтобы разобраться в его работе, задеплоить и управлять им).
Пока мы говорили только об Istio, но это не единственный сервис-меш. Популярная альтернатива — Linkerd, а есть ещё Consul Connect.
Что выбрать?
Честно говоря, не знаю. В данный момент я не считаю себя достаточно компетентным для ответа на этот вопрос. Есть несколько интересных статей со сравнением этих инструментов и даже бенчмарки.
Один из многообещающих подходов — использовать инструмент вроде SuperGloo. Он реализует слой абстракции для упрощения и унификации API, предоставляемых сервис-мешами. Вместо того, чтобы изучать конкретные (и, на мой взгляд, относительно сложные) API различных сервис-мешей, мы можем использовать более простые конструкции SuperGloo — и легко переключаться с одного на другой, словно у нас промежуточный формат конфигурации, описывающий HTTP-интерфейсы и бэкенды, способный генерировать фактическую конфигурацию для Nginx, HAProxy, Traefik, Apache…
Я немного побаловался с Istio и SuperGloo, а в следующей статье хочу показать, как добавить Istio или Linkerd в существующий кластер с помощью SuperGloo, и насколько последний справится со своей работой, то есть позволяет переключаться с одного сервис-меша на другой без перезаписи конфигураций.