Привет, Хабр! Меня зовут Артем Безруков, я DevOps‑инженер в команде интеграционных сервисов Platform V Synapse в СберТехе.

Наша команда работает над продуктом из линейки Platform V Synapse — Platform V Synapse Messaging. Это брокер сообщений, в основе которого лежит Apache ActiveMQ Artemis. Мы делаем из него более безопасное и функционально обогащённое решение, разрабатывая дополнительные плагины, и заботимся о том, чтобы его можно было просто и быстро развернуть с помощью наших скриптов автоматизации.

В последние годы набирает обороты тренд на использование облачных технологий, технологий контейнеризации и микросервисной архитектуры, и наша команда решила расширить возможности продукта. И если изначально стенды ограничивались только виртуальными машинами (ВМ), то с недавнего времени мы начали выводить Platform V Synapse Messaging в среды оркестрации контейнеров — Kubernetes (K8s/облако).

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

Поехали!

Почему ActiveMQ Artemis?

Мы выбрали Artemis как open‑source замену IBM MQ. Оба решения выполняют функцию брокера сообщений и поддерживают модель работы point‑to‑point с отправкой и вычиткой сообщений из очередей.

Artemis работает с протоколами Core (Artemis native), OpenWire, AMQP, MQTT, STOMP. Его можно использовать как отдельно, так и в кластере. С остальными особенностями можно ознакомиться в официальной документации. Плюс у нашей команды большой опыт разработки на Java, что позволяет нам дорабатывать продукт, добавляя к нему различную функциональность:

  • формирование событий аудита — для упрощения разбора инцидентов;

  • трассировка сообщений — для прослеживания всего их пути внутри кластера;

  • сбор метрик подключения и сессий — для мониторинга и администрирования кластера;

  • ограничение подключений и скорости отправки сообщений — для регулирования нагрузки на брокера;

  • работа с бэкапами — для восстановления работы кластера в случае возникновения инцидентов;

  • проверка DN‑сертификата у подключённого клиента или сервера — для управления доступами клиентов к кластеру;

  • работа с хранилищем секретов (vault) — для использования внешнего хранилища секретов (паролей и сертификатов);

  • шифрование сообщений, пока они находятся в брокере — для безопасного хранения данных;

  • клиентские перехватчики — для контроля целостности данных при записи и вычитке сообщений.

Подготовка к развёртыванию Artemis в Kubernetes

Пока команда разработки трудится над улучшением Synapse Messaging, внедряя новую функциональность, мы, команда DevOps, решаем задачи по его установке, расширяя возможности и повышая удобство развёртывания. Чтобы не ограничиваться только развёртыванием на ВМ, где всё работает в целом стабильно и бесхитростно, мы рассмотрели опции упаковки приложения в контейнер и его запуск в K8s. Это позволило бы исследовать потенциал быстрого масштабирования, отказоустойчивости, альтернативного подхода к конфигурированию и других особенностей, учитывая при этом вероятные просадки в производительности.

С докеризацией приложения проблем не возникло, учитывая, что в самом репозитории Apache ActiveMQ Artemis разработчики несут несколько Dockerfile с пояснениями. Мы, по сути, использовали тот же подход: перенесли файлы приложения, объявили переменные окружения, создали необходимые директории и пользователей, раскидав права. Также мы немного поменяли скрипт запуска приложения, добавив ожидание статусов сайдкаров Istio и Vault — о них расскажем дальше. Скрипт опрашивает конечную точку Istio‑сайдкара и ожидает в файловой системе наличие файлов с секретами, которые генерируются Vault‑сайдкаром.

# Check Istio and Vault sidecars before launching Artemis

if [ "x$ISTIO_ENABLED" = "xtrue" ]; then
  echo "Checking for Istio Sidecar readiness..."
  until curl -fsI http://localhost:15020/healthz/ready; do
    echo "Waiting for Istio Sidecar, sleep for 3 seconds";
    sleep 3;
  done;
  echo "Istio Sidecar is ready."
fi

if [ "x$VAULT_ENABLED" = "xtrue" ]; then
  config_file="$APP_HOME"/etc/waitVault.txt
  if [ ! -f "$config_file" ]; then
    echo "Vault wait file $config_file not found, skipping Vault check."
  else
    echo "Checking for Vault Sidecar readiness..."
    checked_files=$(cat "$config_file")
    files_count=0
    for file in $checked_files; do
        files_count=$(( files_count + 1 ))
    done

    exists_files_count=0

    time_counter=0

    while [ $exists_files_count != $files_count ]; do
        exists_files_count=0
        for file in $checked_files; do
            if [ -f "$file" ]; then
                exists_files_count=$(( exists_files_count + 1 ))
            fi
        done

        sleep 1
        time_counter=$(( time_counter + 1 ))
        echo "Waiting Vault Sidecar $time_counter s."
    done
    echo "Vault Sidecar is ready."
  fi
fi

В шаблон генерации configmap в Helm‑чарты, про которые расскажем ниже, также добавили создание файла waitVault.txt со списком секретов, который используется в скрипте:

waitVault.txt: |-
    {{- range $key, $value := .Values.annotations }}
    {{- if hasPrefix "vault.hashicorp.com/secret-volume-path" $key }}
    {{ $value }}/{{ $key | trimPrefix "vault.hashicorp.com/secret-volume-path-" }}
    {{- end }}
    {{- end }}

Проверяем работу локально, убеждаемся, что всё запускается и, главное, не падает. Довольные пушим в репозиторий и идём пить кофе.

На обычном образе мы не остановились — мы следим за рекомендациями и best practices в нашей сфере, поэтому следующей итерацией была разработка distroless‑образа. Distroless — это тип образов, которые не содержат в себе дистрибутив (Alpine, Debian …), а имеют только всё необходимое для запуска приложения, в нашем случае Java. Это делает их более легковесными и менее уязвимыми ввиду уменьшения области атак.

Здесь подход тоже достаточно тривиальный — из builder‑образа Debian взяли необходимые утилиты, локали, библиотеки и перенесли в Distroless‑образ с Java 11. И такой получившийся образ использовали в качестве базового при сборке самого образа приложения.

# Start from a Debian-based image to install packages
FROM debian:bullseye-slim as builder

# Install the required packages

RUN apt-get update && apt-get install -y \
    bash \
    coreutils \
    curl \
    locales \
    locales-all

# Start from the distroless java 11 image

FROM gcr.io/distroless/java:11

# Copy the required libraries

COPY --from=builder /lib/x86_64-linux-gnu/libtinfo.so.6 \
                    /lib/x86_64-linux-gnu/libselinux.so.1 \
                    /lib/x86_64-linux-gnu/libpthread.so.0 \
                    /lib/x86_64-linux-gnu/libdl.so.2 \
                    /lib/x86_64-linux-gnu/libc.so.6 \
                    /lib/x86_64-linux-gnu/libaudit.so.1 \
                    /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \
                    /lib/x86_64-linux-gnu/libcap-ng.so.0 \
                    /lib/x86_64-linux-gnu/libdl.so.2 \
                    /lib/x86_64-linux-gnu/libsepol.so.1 \
                    /lib/x86_64-linux-gnu/libbz2.so.1.0 \
                    /lib/x86_64-linux-gnu/

COPY --from=builder /usr/lib/x86_64-linux-gnu/libpcre2-8.so.0 \
                    /usr/lib/x86_64-linux-gnu/libacl.so.1 \
                    /usr/lib/x86_64-linux-gnu/libattr.so.1 \
                    /usr/lib/x86_64-linux-gnu/libsemanage.so.1 \
                    /usr/lib/x86_64-linux-gnu/

COPY --from=builder /usr/lib/locale/ /usr/lib/locale/

COPY --from=builder /usr/share/locale/ /usr/share/locale/

# Copy the shell and utilities

COPY --from=builder /bin/bash \
                    /bin/cat \
                    /bin/chown \
                    /bin/chmod \
                    /bin/mkdir \
                    /bin/sleep \
                    /bin/ln \
                    /bin/uname \
                    /bin/ls \
                    /bin/

COPY --from=builder /usr/bin/curl \
                    /usr/bin/env \
                    /usr/bin/basename \
                    /usr/bin/dirname \
                    /usr/bin/locale \
                    /usr/bin/

COPY --from=builder /usr/sbin/groupadd \
                    /usr/sbin/useradd \
                    /usr/sbin/

# Change shell to Bash
SHELL ["/bin/bash", "-c"]
# Create link sh -> bash
RUN ln -s /bin/bash /bin/sh

Переходим к развёртыванию. Далеко ходить не пришлось — ArtemisCloud.io предоставляет K8s‑оператор для развёртывания приложения в облаке. В комплекте идёт оператор, CRD с описанием сущностей брокера, манифесты с ролями, вспомогательные скрипты и инструкция (как это часто бывает, отвечающая не на все вопросы).

Перед установкой оператора в наш namespace надо занести CRD, создать ServiceAccount, Role, RoleBinding, ElectionRole, ElectionRoleBinding. Затем уже можно развёртывать и сам оператор. Набор из custom resource definition покрывает основные сущности Artemis:

  • Broker CRD — создание и конфигурирование развёртывания брокера;

  • Address CRD — создание адресов и очередей;

  • Scaledown CRD — создание контроллера миграции сообщений при уменьшении размера кластера;

  • Security CRD — настройка безопасности и методов аутентификации для брокера.

Солидный комплект! Но тут начинают возникать вопросы:

  • А как нам управлять нашими плагинами и интеграциями?

  • Как теперь конфигурировать кластер? Не через изменение XML‑файлов через Ansible, как привыкли? Переписывать все в YAML под CRD?

  • Как разделять доступ к управлению кластером и управлению очередями?

  • Как дописать необходимую функциональность без большого опыта в Go‑разработке?

  • А что на это скажет безопасность, с которой приложение на ВМ полностью согласовано, а про оператор она ничего не знает?

  • и так далее.

С одной стороны, у нас есть готовый оператор, который надо подробно изучить, понять, как его можно подкрутить под наши нужды, и использовать. С другой — наши Ansible‑плейбуки для работы с ВМ, которые не так долго адаптировать под развёртывание в облаке, и привычные XML‑конфиги.

Недолго думая, мы решили, что не будем использовать оператор, но станем разрабатывать Helm‑манифесты и доделывать наши плейбуки. И тут начинается самое интересное.

Подготовка Helm-чартов

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

В Kubernetes namespace разворачивается приложение с несколькими репликами. Кластер находится за единым сервисом. Помимо кластера Artemis в namespace ещё разворачиваются два шлюза (Istio envoy) — ingress и egress, через которые проводится трафик для журналирования. Поды приложения и шлюзов настраиваются на работу с сайдкарами Vault‑agent и Istio‑proxy. Внутри Kubernetes namespace настраивается маршрутизация трафика и mTLS посредством DestinationRule (DR), VirtualService (VS), PeerAuthentication (PA), ServiceEntry (SE) манифестов Istio. Начнём с самого приложения, а затем перейдём к «обвязке».

Мы используем Helm‑чарты для развёртывания наших приложений в Kubernetes и управления ими. Helm‑чарт состоит из шаблонов‑манифестов и значений‑переменных (values.yaml), которые подставляются в шаблоны. В отличие от отдельных манифестов различных объектов, которые разворачиваются по одному готовому файлу через kubectl, чарты устанавливаются «набором» или «релизом». Релиз можно обновлять или откатывать, а при удалении ресурсы из Kubernetes также удаляются все сразу.

Для приложения написали манифест, поднимающий statefulset. Statefulset подходит нам потому, что его поды имеют предсказуемые названия, сохраняют идентичность при перезапуске и при изменении топологии кластера поднимаются, удаляются или перезапускаются одна за одной, позволяя сообщениям перетекать из брокера в брокер. Также необходимы манифесты для сервисов — service для доступа к подам, headless service для обнаружения подов в кластере брокеров.

apiVersion: v1
kind: Service
metadata:
  name: artemis-svc
  namespace: my_namespace
spec:
  ports:
  - name: console
    port: 8161
    protocol: TCP
    targetPort: 8161
  - name: data
    port: 61616
    protocol: TCP
    targetPort: 61616
  - name: jgroups-7800
    port: 7800
    protocol: TCP
    targetPort: 7800
  - name: jgroups-7900
    port: 7900
    protocol: TCP
    targetPort: 7900
  publishNotReadyAddresses: true
  selector:
    app: artemis-app
  type: ClusterIP

В сервисах объявляем порты:

  • console — для доступа к UI‑интерфейсу;

  • data — для TCP‑подключения к акцепторам приложения;

  • prometheus — для сбора метрик;

  • jgroups — для межкластерного общения.

Так как у нас уже были роли и плейбуки Ansible для развёртывания Artemis на ВМ, то для большинства конфигурационных файлов требовалось сделать перевод из Jinja2-формата в Helm template, и дописать шаблоны для недостающих файлов. В итоге у нас получился следующий список файлов с конфигурациями, которые мы монтируем через configmap в /app/broker/etc:

etc/
|-- _address-settings.tpl
|-- _addresses.tpl
|-- _artemis_profile.tpl
|-- _audit_metamodel.tpl
|-- _audit_properties.tpl
|-- _bootstrap.tpl
|-- _broker.tpl
|-- _cert_roles.tpl
|-- _cert_users.tpl
|-- _jgroups-ping.tpl
|-- _jolokia-access.tpl
|-- _keycloak.tpl
|-- _logback.tpl
|-- _login.tpl
|-- _management.tpl
|-- _plugins_configs.tpl
|-- _resource-limit-settings.tpl
|-- _security-settings.tpl
`-- _vault.tpl

Кластеризация через Jgroups

Важной частью конфигураций является настройка кластеризации. На ВМ ноды брокера мы объединяли в кластер, объявляя статичные коннекторы в разделе <cluster-connections> в broker.xml:

<connectors>
      <!-- Connector used to be announced through cluster connections and notifications -->
      <connector name="artemis">tcp://10.20.30.40:61616?sslEnabled=true;enabledProtocols=TLSv1.2,TLSv1.3</connector>
      <connector name="node0">tcp://10.20.30.41:61616?sslEnabled=true;enabledProtocols=TLSv1.2,TLSv1.3</connector>
    </connectors>
<cluster-connections>
      <cluster-connection name="my-cluster">
        <reconnect-attempts>-1</reconnect-attempts>
        <connector-ref>artemis</connector-ref>
        <message-load-balancing>ON_DEMAND</message-load-balancing>
        <max-hops>1</max-hops>
        <static-connectors allow-direct-connections-only="false">
          <connector-ref>node0</connector-ref>
        </static-connectors>
      </cluster-connection>
    </cluster-connections>
    <ha-policy>
      <live-only>
        <scale-down>
          <connectors>
            <connector-ref>node0</connector-ref>
          </connectors>
        </scale-down>
      </live-only>
    </ha-policy>

В облаке же объявлять кластер таким образом было бы неудобно. Поэтому мы настроили механизм Jgroups, доступный в Artemis «из коробки». Jgroups — стек протоколов, позволяющий реализовывать кластеризацию для Java‑приложений. Настройки брокера стали выглядеть так:

<connectors>
      <!-- Connector used to be announced through cluster connections and notifications -->
      <connector name="cluster">tcp://${POD_IP}:61617?sslEnabled=false;enabledProtocols=TLSv1.2,TLSv1.3</connector>
      <connector name="artemis">tcp://${POD_IP}:61616?sslEnabled=true;enabledProtocols=TLSv1.2,TLSv1.3</connector>
    </connectors>
<acceptors>
      <acceptor name="cluster">tcp://0.0.0.0:61617?protocols=CORE,AMQP,MQTT,STOMP;amqpCredits=1000;amqpDuplicateDetection=true;amqpLowCredits=300;amqpMinLargeMessageSize=102400;supportAdvisory=false;suppressInternalManagementObjects=false;tcpReceiveBufferSize=1048576;tcpSendBufferSize=1048576;useEpoll=true;sslEnabled=false</acceptor>
      <acceptor name="artemis">tcp://0.0.0.0:61616?protocols=CORE,AMQP,MQTT,STOMP;amqpCredits=1000;amqpDuplicateDetection=true;amqpLowCredits=300;amqpMinLargeMessageSize=102400;supportAdvisory=false;suppressInternalManagementObjects=false;tcpReceiveBufferSize=1048576;tcpSendBufferSize=1048576;useEpoll=true;sslEnabled=true;enabledProtocols=TLSv1.2,TLSv1.3;keyStorePath=/app/artemis/broker/vault/crt.pem;keyStoreType=PEM;trustStorePath=/app/artemis/broker/vault/ca.pem;trustStoreType=PEM;verifyHost=false;needClientAuth=true</acceptor>
    </acceptors>
<broadcast-groups>
      <broadcast-group name="my-broadcast-group">
        <jgroups-file>jgroups-ping.xml</jgroups-file>
        <jgroups-channel>activemq_broadcast_channel</jgroups-channel>
        <connector-ref>cluster</connector-ref>
      </broadcast-group>
    </broadcast-groups>
    <discovery-groups>
      <discovery-group name="my-discovery-group">
        <jgroups-file>jgroups-ping.xml</jgroups-file>
        <jgroups-channel>activemq_broadcast_channel</jgroups-channel>
        <refresh-timeout>10000</refresh-timeout>
      </discovery-group>
    </discovery-groups>
    <cluster-connections>
      <cluster-connection name="my-cluster">
        <discovery-group-ref discovery-group-name="my-discovery-group"/>
        <connector-ref>cluster</connector-ref>
        <max-hops>1</max-hops>
        <message-load-balancing>ON_DEMAND</message-load-balancing>
        <reconnect-attempts>-1</reconnect-attempts>
      </cluster-connection>
    </cluster-connections>
    <ha-policy>
      <live-only>
        <scale-down>
          <discovery-group-ref discovery-group-name="my-discovery-group"/>
        </scale-down>
      </live-only>
    </ha-policy>

Каждый брокер теперь имел отдельный акцептор и коннектор, предназначенный для общения между нодами кластера. Объявили broadcast- и discovery-группы для работы Jgroups, которые указаны в cluster-connections и ha-policy. Сам же Jgroups-стек описали в файле jgroups-ping.xml:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="urn:org:jgroups"
        xsi:schemaLocation="urn:org:jgroups http://www.jgroups.org/schema/jgroups.xsd"
        >
    <TCP bind_addr="127.0.0.1"
         bind_port="7800"
         external_addr="${POD_IP}"
         external_port="7800"
         port_range="0"
         thread_pool.min_threads="0"
         thread_pool.max_threads="200"
         thread_pool.keep_alive_time="30000"/>
    <dns.DNS_PING dns_query="${DNS_QUERY}"
                  dns_record_type="${DNS_RECORD_TYPE:A}" />
    <MERGE3 min_interval="10000"
            max_interval="30000"/>
    <FD_SOCK2 port_range="0" />
    <FD_ALL3 timeout="40000" interval="5000" />
    <VERIFY_SUSPECT2 timeout="1500"  />
    <pbcast.NAKACK2 use_mcast_xmit="false" />
    <pbcast.STABLE desired_avg_gossip="50000"
                   max_bytes="4M"/>
    <pbcast.GMS print_local_addr="true" join_timeout="2000" max_join_attempts="2" print_physical_addrs="true" print_view_details="true"/>
    <UFC max_credits="2M"
         min_threshold="0.4"/>
    <MFC max_credits="2M"
         min_threshold="0.4"/>
    <FRAG2 frag_size="60K"  />
</config>

Мы не будем подробно описывать каждый протокол с его особенностями, это можно найти в документации Jgroups. Остановимся на основных моментах, которые используются в этом проекте.

Нас интересует блок TCP — в нём мы объявляем адреса и порты, на которых будет работать Jgroups. Сам приклад работает на 127.0.0.1 внутри пода и стандартном Jgroups‑порте 7800. Также необходимо указать «внешний» адрес — адрес пода, в котором размещено наше приложение, порт при этом остаётся неизменным.

Ранее в манифесте сервиса мы объявляли, что для Jgroups необходимы два порта: 7800 и 7900, но в конфигурации об этом не написано. Дело в том, что порт 7900 используется для протокола FD_SOCK2, указанного в стеке. Значение порта получаем из bind_port + offset, и обычно это 7800 + 100.

Второй интересующий нас блок — dns.DNS_PING. Он отвечает за обнаружение узлов кластера. Здесь мы указываем dns_query, совпадающий с headless‑service. Помимо DNS_PING существуют и другие методы обнаружения. Например, JDBC_PING и S3_PING, которые позволяют обращаться к внешнему источнику информации для обнаружения, к базе данных или бакету; или AWS_PING и AZURE_PING, которые обращаются к ресурсам публичного облака, где располагается приложение.

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

В итоге механизм обнаружения состоит из следующих шагов:

  1. При запуске узел брокера обращается по DNS_PING к DNS‑серверу. Брокер запрашивает dns_query, в котором прописан headless‑service statefulset-а.

  2. DNS‑сервер смотрит поды, подходящие под dns_query.

  3. DNS‑сервер возвращает список адресов узлу брокера.

  4. Узел брокера рассылает приглашения для вступления в кластер другим узлам из полученного списка. Идёт обмен кластерным паролем. Тут включаются в работу нижестоящие протоколы из стека:

    1. MERGE3 — протокол для обнаружения подгрупп, возникающих при разделении и восстановлении сети.

    2. FD_SOCK2 и FD_ALL3 — используются для обнаружения сбоев. FD_SOCK2 отслеживает работоспособность членов кластера через TCP‑соединения, а FD_ALL3 использует heartbeat.

    3. VERIFY_SUSPECT2 — проверяет и подтверждает неактивность участника кластера.

    4. pbcast.NAKACK2 — обеспечивает надёжную доставку сообщений с использованием механизма отрицательного подтверждения (NAK). Он обрабатывает повторные передачи отсутствующих сообщений, чтобы гарантировать получение сообщений всеми участниками.

    5. pbcast.STABLE — вычисляет, какие широковещательные сообщения были доставлены всем участникам кластера, и отправляет события STABLE в стек. Это позволяет NAKACK2 удалять сообщения, которые видели все участники.

    6. Узел брокера получает ответ, GMS‑протокол (Group Membership Service) его обрабатывает. Между узлами кластера вычисляется новая топология, и узлы объединяются.

Протоколы UFC и MFC используют кредитную систему для контроля потока сообщений и предотвращения перегрузок.

FRAG‑протокол фрагментирует сообщения размером больше указанного размера и собирает их на принимающей стороне.

Сайдкары Vault-Agent и Istio

В нашей архитектуре Artemis сконфигурирован на работу с mTLS. Помимо использования сертификатов для установления защищённого соединения они также используются для аутентификации клиентов. Брокер поддерживает работу с JKS keystore/truststore и, с недавнего времени, с PEM keystore/truststore.

Приложению необходимы пароли для JKS и для кластерного соединения. Чтобы не хранить секреты в конфигурационных файлах (даже в зашифрованном виде) и не использовать объекты типа Secret в K8s для паролей и keystore/truststore, мы используем Vault‑agent.

Через annotation в statefulset включается сайдкар и объявляются секреты, которые необходимо взять из хранилища и записать в файловую систему. Ниже приведены примеры запроса к PKI engine для выпуска PEM‑сертификата и обращению к KV‑хранилищу за cluster_password‑секретом (мы также немного доработали Artemis, чтобы он умел читать cluster_password из файла).

    vault.hashicorp.com/agent-init-first: 'true'
    vault.hashicorp.com/agent-set-security-context: 'true'
    vault.hashicorp.com/agent-pre-populate: 'false'
    vault.hashicorp.com/agent-inject-secret-cluster.pass: 'true'
    vault.hashicorp.com/secret-volume-path-cluster.pass: /app/artemis/broker/vault
    vault.hashicorp.com/namespace: MY_VAULT_NAMESPACE
    vault.hashicorp.com/role: MY_ROLE
    vault.hashicorp.com/agent-inject: 'true'
    vault.hashicorp.com/agent-limits-cpu: 100m
    vault.hashicorp.com/agent-requests-cpu: 100m
    vault.hashicorp.com/secret-volume-path-crt.pem: /app/artemis/broker/vault
    vault.hashicorp.com/agent-inject-secret-crt.pem: 'true'
    vault.hashicorp.com/agent-inject-template-crt.pem: |
      {%- raw %}
      {{- with secret "PKI/issue/MY_ROLE"
      "common_name=my_artemis_app.my_domain" "format=pem" "ttl=20h"
      "private_key_format=pkcs8" -}}
      {{ .Data.private_key }}
      {{ .Data.certificate }}
      {{- end }}
      {%- endraw %}
    vault.hashicorp.com/agent-inject-template-cluster.pass: |
      {%- raw %}
      {{- with secret "PATH/TO/MY/KV/cloud_artemis" -}}
      {{ index .Data "cluster_password" }}
      {{- end }}
      {%- endraw %}

Когда под стартует, контейнер с прикладом ждёт, пока сайдкар Vault‑agent не создаст в файловой системе необходимые секреты по указанному пути. Таким образом мы получаем для приложения необходимые пароли, keystore и truststore для настройки TLS на сервере. Vault‑agent установлен не только на поде с приложением, но и на граничных шлюзах, что позволяет использовать Vault для получения сертификатов при интеграции с внешними системами.

Маршрутизация трафика внутри namespace и настройки mTLS

Стараясь не забывать и про сетевую безопасность, про которую нам заботливо напоминают коллеги всевозможными стандартами и проверками, мы обращаемся к любимому Istio. Рассказывать, как работает Istio, в этой статье мы не будем, пройдёмся лишь по моментам, актуальным для нашего проекта.

Нулевой шаг — включить Peer Authentication в режим mtls: strict, чтобы внутри namespace ходил только TLS‑трафик.

Далее пойдём по пути от «пользователя/приложения». Для приложения развёрнут Ingress, в который ходят пользователи для подключения к UI по console‑порту или приложения для отправки сообщений по data‑порту:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/ssl-passthrough: "true"
  name: artemis-istio-ingress
  namespace: my_namespace
spec:
  rules:
  - host: ui-artemis-istio-ingress.my_cluster
    http:
      paths:
      - backend:
          service:
            name: artemis-ingressgateway-svc
            port:
              number: 8161
        path: /
        pathType: Prefix
  - host: data-artemis-istio-ingress.my_cluster
    http:
      paths:
      - backend:
          service:
            name: artemis-ingressgateway-svc
            port:
              number: 61616
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - ui-artemis-istio-ingress.my_cluster
    - data-artemis-istio-ingress.my_cluster

Попадая на Ingress‑controller, трафик переводится на бэкенд, которым является сервис нашего поднятого Ingress‑шлюза. И, так как аутентификация пользователей осуществляется в приложении, мы пропускаем SSL‑трафик дальше, не прерывая его.

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: artemis-ingressgateway
  namespace: my_namespace
spec:
  selector:
    app: artemis-ingressgateway
    istio: artemis-ingressgateway
  servers:
  - hosts:
    - ui-artemis-istio-ingress.my_cluster
    port:
      name: tls-console
      number: 8161
      protocol: tls
    tls:
      mode: PASSTHROUGH
  - hosts:
    - data-artemis-istio-ingress.my_cluster
    port:
      name: tls-data
      number: 61616
      protocol: tls
    tls:
      mode: PASSTHROUGH

---
apiVersion: v1
kind: Service
metadata:
  name: artemis-ingressgateway-svc
  namespace: my_namespace
  ports:
  - name: tls-console
    port: 8161
    protocol: TCP
    targetPort: 8161
  - name: tls-data
    port: 61616
    protocol: TCP
    targetPort: 61616
  selector:
    app: artemis-ingressgateway
    istio: artemis-ingressgateway
  sessionAffinity: None
  type: ClusterIP

Дальше трафик регулируется через VirtualService и перенаправляется со шлюза на сервис приложения:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: artemis-ingress-vs
  namespace: my_namespace
spec:
  exportTo:
  - .  
  gateways:
  - artemis-ingressgateway
  hosts:
  - ui-artemis-istio-ingress.my_cluser
  - data-artemis-istio-ingress.my_cluster
  tls:
  - match:
    - gateways:
      - artemis-ingressgateway
      port: 8161
      sniHosts:
      - ui-artemis-istio-ingress.my_cluster
    route:
    - destination:
        host: artemis-svc
        port:
          number: 8161
  - match:
    - gateways:
      - artemis-ingressgateway
      port: 61616
      sniHosts:
      - data-artemis-istio-ingress.my_cluster
    route:
    - destination:
        host: artemis-svc
        port:
          number: 61616

По пути «в сторону приложения» на трафик не накладывается никаких DestinationRule, так как ранее мы указали ssl-passthrough.

Как ходит трафик из приложения? Разберём на примере обращения в Vault, который находится вне нашего K8s. Чтобы сервис Istio знал, куда слать трафик, направление которого уходит за пределы кластера, необходимо определить ServiceEntry:

apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: vault-8443-service-entry
spec:
  exportTo:
  - .
  hosts:
  - my.vault.host
  location: MESH_EXTERNAL
  ports:
  - name: http-vault
    number: 8443
    protocol: https
  resolution: DNS

Объявляем Egress-шлюз и сервис шлюза:

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: scripts-egressgateway
spec:
  selector:
    app: artemis-egressgateway
    istio: artemis-egressgateway
  servers:
  - hosts:
    - my.vault.host
    port:
      name: tls-vault-9444
      number: 9444
      protocol: TLS
    tls:
      mode: PASSTHROUGH

---
apiVersion: v1
kind: Service
metadata:
  name: artemis-egressgateway-svc
spec:
  ports:
  - name: status-port
    port: 15021
    protocol: TCP
    targetPort: 15021
  - name: tls-vault-9444
    port: 9444
    protocol: TCP
    targetPort: 9444
  selector:
    app: artemis-egressgateway
    istio: artemis-egressgateway
  sessionAffinity: None
  type: ClusterIP

Направляем трафик через VirtualService:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: artemis-vault-vs
spec:
  exportTo:
  - .
  gateways:
  - artemis-egressgateway
  - mesh
  hosts:
  - my.vault.host
  tcp:
  - match:
    - gateways:
      - mesh
      port: 8443
    route:
    - destination:
        host: artemis-egressgateway-svc
        port:
          number: 9444
  - match:
    - gateways:
      - artemis-egressgateway
      port: 9444
	  sniHosts:
      - my.vault.host
    route:
    - destination:
        host: my.vault.host
        port:
          number: 8443

Так как трафик идёт из приложения в Vault уже с использованием TLS, настроенным в Vault-agent, никаких дополнительных DestinationRule ставить не надо.

С трафиком, который ходит в приложение и из приложения, разобрались, перейдём к самому кластеру приложения. Если вы подумали, что после кластеризации через Jgroups всё самое неприятное позади, спешим вас переубедить: трафик внутри кластера тоже необходимо перевести в TLS.

У нас было два варианта: настраивать SSL непосредственно через Jgroups или пустить всё через Istio. Раз всё остальное ходит через Istio, то и тут мы решили не мудрить, включили режим отладки и пошли разбираться.

Первая проблема, с которой мы столкнулись, открыв журналы, — все запросы на обнаружение узлов уходили в BlackHoleCluster. Мы задали ServiceEntry для нашего headless‑service, и трафик стал доходить до DNS‑сервиса и возвращать список узлов кластера.

apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: artemis-headless
spec:
  exportTo:
  - .
  hosts:
  - artemis-hdls-svc
  location: MESH_INTERNAL
  ports:
  - name: jgroups-7800
    number: 7800
    protocol: TCP
  - name: jgroups-7900
    number: 7900
    protocol: TCP
  resolution: NONE
  workloadSelector:
    labels:
      app: artemis-app

Но аналогичная проблема появлялась при общении узлов между собой. Когда брокер, получив список хостов, начинал рассылать приглашения о вступлении в кластер, нас снова засасывало в чёрные дыры. Объявляем ещё один ServiceEntry, на этот раз для хостов кластера. Так как мы заранее не знаем, какой адрес достанется поду при развёртывании или масштабировании, то в манифесте указываем любой адрес (0.0.0.0/0), но с Jgroups‑портами и с data‑портом для работы приклада и пересылки сообщений между узлами кластера.

apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: artemis-cluster
spec:
  addresses:
  - 0.0.0.0/0
  exportTo:
  - .
  hosts:
  - artemis.hosts
  location: MESH_INTERNAL
  ports:
  - name: cluster
    number: 61617
    protocol: TCP
  - name: jgroups-7800
    number: 7800
    protocol: TCP
  - name: jgroups-7900
    number: 7900
    protocol: TCP
  resolution: NONE
  workloadSelector:
    labels:
      app: artemis-app

Следующая ошибка — NR filter_chain_not_found. Она возникала из‑за того, что у нас стоит peerAutherntication mtls:strict, и трафик, который ходит в рамках процессов по кластеризации Jgroups, не покрыт TLS. Настраиваем DestinationRule на mTLS с сертификатами Istio для портов кластера:

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: artemis-clustering-dr
spec:
  exportTo:
  - .
  host: artemis.hosts
  trafficPolicy:
    portLevelSettings:
    - port:
        number: 61617
      tls:
        mode: ISTIO_MUTUAL
    - port:
        number: 7800
      tls:
        mode: ISTIO_MUTUAL
    - port:
        number: 7900
      tls:
        mode: ISTIO_MUTUAL
  workloadSelector:
    matchLabels:
      app: artemis-app

Открываем журналы Istio и видим, что трафик начал ходить по необходимым портам:

info    Envoy proxy is ready
"- - -" 0 - - - "-" 192 0 7747 - "-" "-" "-" "-" "172.21.10.42:7800" outbound|7800|| artemis-hdls-svc 172.21.1.146:59028 172.21.10.42:7800 172.21.1.146:35715 - -
"- - -" 0 - - - "-" 1854 1527 40980 - "-" "-" "-" "-" "127.0.0.1:7800" inbound|7800|| 127.0.0.1:42266 172.21.1.146:7800 172.21.10.179:46534 outbound_.7800_._. artemis-hdls-svc -

В журналах приложения есть запись об образовании бриджа:

artemis-app [Thread-2 (ActiveMQ-server-org.apache.activemq.artemis.core.server.impl.ActiveMQServerImpl$6@6b69761b)] INFO  org.apache.activemq.artemis.core.server  - AMQ221027: Bridge ClusterConnectionBridge@6d8264f3 [name=$.artemis.internal.sf.my-cluster.b49f0fb1-53d4-11ef-8504-568fdad344ae, queue=QueueImpl[name=$.artemis.internal.sf.my-cluster.b49f0fb1-53d4-11ef-8504-568fdad344ae, postOffice=PostOfficeImpl [server=ActiveMQServerImpl::name=artemis-statefulset-0], temp=false]@13e456bc targetConnector=ServerLocatorImpl (identity=(Cluster-connection-bridge::ClusterConnectionBridge@6d8264f3 [name=$.artemis.internal.sf.my-cluster.b49f0fb1-53d4-11ef-8504-568fdad344ae, queue=QueueImpl[name=$.artemis.internal.sf.my-cluster.b49f0fb1-53d4-11ef-8504-568fdad344ae, postOffice=PostOfficeImpl [server=ActiveMQServerImpl::name=artemis-statefulset-0], temp=false]@13e456bc targetConnector=ServerLocatorImpl [initialConnectors=[TransportConfiguration(name=cluster, factory=org-apache-activemq-artemis-core-remoting-impl-netty-NettyConnectorFactory)?enabledProtocols=TLSv1-2,TLSv1-3&port=61617&sslEnabled=false&host=172-21-10-42&verifyHost=false], discoveryGroupConfiguration=null]]::ClusterConnectionImpl@1887326180[nodeUUID=97d07d4c-53d4-11ef-8aab-5e95e30bd562, connector=TransportConfiguration(name=cluster, factory=org-apache-activemq-artemis-core-remoting-impl-netty-NettyConnectorFactory)?enabledProtocols=TLSv1-2,TLSv1-3&port=61617&sslEnabled=false&host=172-21-1-146&verifyHost=false, address=, server=ActiveMQServerImpl::name=artemis-statefulset-0])) [initialConnectors=[TransportConfiguration(name=cluster, factory=org-apache-activemq-artemis-core-remoting-impl-netty-NettyConnectorFactory)?enabledProtocols=TLSv1-2,TLSv1-3&port=61617&sslEnabled=false&host=172-21-10-42&verifyHost=false], discoveryGroupConfiguration=null]] is connected

В UI Artemis топология кластера обновилась, и связь образовалась между aкцепторами двух узлов.

После перезапуска одного из подов кластера (.42) в журналах опять можно заметить трафик по Jgroups‑портам в процессе изменения топологии и коммуникацию с новой подой (.179) по data‑порту aкцептора.

"- - -" 0 - - - "-" 192 0 7747 - "-" "-" "-" "-" "172.21.10.42:7800" outbound|7800|| artemis-hdls-svc 172.21.1.146:59028 172.21.10.42:7800 172.21.1.146:35715 - -
"- - -" 0 - - - "-" 1854 1527 40980 - "-" "-" "-" "-" "127.0.0.1:7800" inbound|7800|| 127.0.0.1:42266 172.21.1.146:7800 172.21.10.179:46534 outbound_.7800_._. artemis-hdls-svc -
"- - -" 0 - - - "-" 1281 1292 3226 - "-" "-" "-" "-" "127.0.0.1:7900" inbound|7900|| 127.0.0.1:46662 172.21.1.146:7900 172.21.10.179:52084 outbound_.7900_._. artemis-hdls-svc -
"- - -" 0 - - - "-" 3347 4241 38730 - "-" "-" "-" "-" "127.0.0.1:7800" inbound|7800|| 127.0.0.1:46792 172.21.1.146:7800 172.21.10.179:48670 outbound_.7800_._. artemis-hdls-svc -
"- - -" 0 - - - "-" 1213 1211 1527 - "-" "-" "-" "-" "127.0.0.1:61617" inbound|61617|| 127.0.0.1:59074 172.21.1.146:61617 172.21.10.179:40322 outbound_.61617_._.artemis.hosts -
"- - -" 0 - - - "-" 1213 1211 787 - "-" "-" "-" "-" "127.0.0.1:61617" inbound|61617|| 127.0.0.1:59088 172.21.1.146:61617 172.21.10.179:40350 outbound_.61617_._.artemis.hosts -
"- - -" 0 - - - "-" 1213 1211 1527 - "-" "-" "-" "-" "127.0.0.1:61617" inbound|61617|| 127.0.0.1:59086 172.21.1.146:61617 172.21.10.179:40336 outbound_.61617_._.artemis.hosts -

Чтобы при перезапуске под узлы Artemis успевали обмениваться сообщениями в очередях, обязательно надо добавить аннотацию для сайдкара Istio, которая задаёт ожидание завершения сетевых соединений перед завершением работы.

proxy.istio.io/config:|
  proxyMetadata:
    EXIT_ON_ZERO_ACTIVE_CONNECTIONS: 'true'

Итоги и векторы развития

Мы получили:

  • полностью функциональный кластер брокеров Artemis в Kubernetes без использования оператора;

  • бесценный опыт настройки и отладки Istio;

  • немного седых волос.

Текущая реализация кластера ограничивается работой в неперсистентном режиме — сообщения хранятся только в памяти брокера и не записываются на диск. Поэтому нашим следующим шагом будет настройка персистентного кластера с хранилищами в PersistentVolume на диске или S3. Такая доработка позволит перенести шифрование сообщений по модели «encryption at rest», которая уже реализована на ВМ.

Ещё одной зоной исследования и улучшений является производительность. На момент написания статьи конечные результаты нагрузочного тестирования, которые были бы релевантны по отношению к ВМ, ещё не получены. Но и так очевидно, что производительность кластера в облаке будет меньше, чем на ВМ, из‑за дополнительных процессов с трафиком и работы в контейнерах.

Также для увеличения отказоустойчивости мы планируем развёртывать мультикластер, растянутый между несколькими ЦОДами. И с помощью того же Istio будем обрабатывать падения узлов и переключаться на рабочие ноды.

Все эти разработки мы проводим в рамках продукта Platform V Synapse Messaging, который входит в состав Platform V Synapse — комплекса облачных продуктов для интеграции и оркестрации микросервисов. Он позволяет импортозаместить любые корпоративные сервисные шины, обеспечивает обработку данных для бизнес‑решений в реальном времени и интегрирует технологии в единый производственный процесс.

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