Привет, Habr! Меня зовут Валентин, я DevOps-инженер команды Platform V Kintsugi. Мы занимаемся развитием облачного сервиса и на практике регулярно сталкиваемся как с архитектурными задачами построения распределённых систем, так и с вопросами обеспечения их безопасности.

В предыдущей части мы подробно разобрали механизм делегирования TLS-соединения на уровень Service Mesh и показали, как Egress Gateway может выступать полноценным участником PostgreSQL handshake. Однако этот сценарий рассматривался в упрощённой конфигурации — один сервис, один сертификат, одно подключение.

В реальной системе всё устроено иначе: разные сервисы используют разные учётные записи, разные сертификаты и обращаются к одним и тем же внешним системам. Это приводит к необходимости динамически выбирать TLS-политику и корректно маршрутизировать TCP-трафик в зависимости от источника. Именно здесь начинается самое интересное!

Представим сценарий, в котором несколько сервисов обращаются к одному серверу PostgreSQL, но при этом каждый сервис:

  • использует отдельную техническую учётную запись;

  • обладает собственным клиентским сертификатом;

  • работает со своей логической базой данных.

Разберём, как в таких условиях выстраивается взаимодействие и какие ограничения возникают при попытке централизовать управление безопасным подключением.

Смоделируем описанную ситуацию, развернув два сервиса — psql-postgres и psql-kintsugi. Каждый из них будет устанавливать защищённое соединение со своей базой данных, расположенной на сервере СУБД, при этом шифрование трафика будет выполняться на стороне Egress Gateway с использованием собственной технической учётной записи и соответствующего клиентского сертификата.

На этом этапе возникает ключевой вопрос: каким образом Egress Gateway должен определить, какой именно сертификат необходимо использовать для конкретного соединения? Предположим, что сервису psql-postgres необходимо аутентифицироваться на сервере базы данных с использованием учётной записи postgres (CN=postgres), а для сервиса psql-kintsugi создадим отдельную учётную запись kintsugi и выпустим соответствующий сертификат (CN=kintsugi). Попробуем решить эту задачу средствами Service Mesh, разделив трафик на уровне sidecar с использованием sourceLabels и subsets, и сопоставив каждому потоку свою TLS-политику.

Для этого последовательно опишем необходимые ресурсы. Регистрируем внешний PostgreSQL-сервер в реестре Istio:

---
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: postgres-se-tls-origin
spec:
  endpoints:
    - address: 10.40.20.72
  exportTo:
    - .
  hosts:
    - postgres.solution.test
  location: MESH_EXTERNAL
  ports:
    - name: postgres-5432
      number: 5432
      protocol: postgres
  resolution: STATIC

В результате внешний сервис становится доступен внутри mesh и может участвовать в маршрутизации и применении политик.

Определим сервис для проксирования трафика через Egress Gateway:

---
kind: Service
apiVersion: v1
metadata:
  name: postgres-egress-service-tls-origin
spec:
  ports:
    - name: tcp-5001
      protocol: TCP
      port: 5001
      targetPort: 5001
  type: ClusterIP
  selector:
    app: egressgateway

Через порт 5001 будет проходить трафик от всех сервисов к Egress Gateway.

Добавим точку входа на Egress Gateway:

---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: postgres-gw-tls-origin-psql-postgres
spec:
  selector:
    app: egressgateway
  servers:
    - hosts:
        - postgres.solution.test
      port:
        name: postgres-5001
        number: 5001
        protocol: TCP

Gateway принимает TCP-трафик на порту 5001 и передаёт его дальше в mesh. Для каждого канала создаётся отдельный subset в DestinationRule, который используется при выборе политики обработки трафика. Определим наборы политик , которые будем использовать для выбора TLS-конфигурации.

Для внутреннего трафика (от приложения к Egress Gateway):

---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: postgres-internal-dr-tls-origin-postgres
spec:
  exportTo:
    - .
  host: postgres-egress-service-tls-origin
  subsets:
    - name: postgres-internal-tls-origin-postgres

Для внешнего трафика (от Egress Gateway к серверу СУБД PostgreSQL):

---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: postgres-external-dr-tls-origin
spec:
  exportTo:
    - .
  host: postgres.solution.test
  subsets:
    - name: postgres-external-tls-origin-postgres
      trafficPolicy:
        tls:
          caCertificates: /secrets/istio/egressgateway-certs/ca.crt
          clientCertificate: /secrets/istio/egressgateway-certs/postgres.crt
          privateKey: /secrets/istio/egressgateway-certs/postgres.key
          mode: MUTUAL
    - name: postgres-external-tls-origin-kintsugi
      trafficPolicy:
        tls:
          caCertificates: /secrets/istio/egressgateway-certs/ca.crt
          clientCertificate: /secrets/istio/egressgateway-certs/kintsugi.crt
          privateKey: /secrets/istio/egressgateway-certs/kintsugi.key
          mode: MUTUAL
  workloadSelector:
    matchLabels:
      app: egressgateway

Каждый subset соответствует отдельной TLS-политике и конкретному клиентскому сертификату.

Теперь настроим маршрутизацию. Попробуем разделить трафик на уровне sidecar с помощью sourceLabels и направить его в разные subsets:

---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: postgres-vs-tls-origin-psql-postgres
spec:
  exportTo:
    - .
  gateways:
    - postgres-gw-tls-origin-psql-postgres
    - mesh
  hosts:
    - postgres.solution.test
  tcp:
    - match:
        - gateways:
            - mesh
          port: 5432
          sourceLabels:
            app: psql-postgres
      route:
        - destination:
            host: postgres-egress-service-tls-origin
            port:
              number: 5001
            subset: postgres-internal-tls-origin-postgres
    - match:
        - gateways:
            - postgres-gw-tls-origin-psql-postgres
          port: 5001
      route:
        - destination:
            host: postgres.solution.test
            port:
              number: 5432
            subset: postgres-external-tls-origin-postgres

Таким образом, на уровне sidecar мы разделяем трафик по sourceLabels и направляем его в разные subsets, каждому из которых соответствует своя TLS-конфигурация.

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

Выполним подключение из сервиса psql-postgres:

10001@psql-postgres-8f7584f7d-j97dt:/$ psql "host=postgres.solution.test user=postgres port=5432 dbname=postgres sslmode=disable"

postgres=# SELECT ssl, version, cipher
FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
 ssl | version |           cipher
-----+---------+-----------------------------
 t   | TLSv1.2 | ECDHE-RSA-AES256-GCM-SHA384
(1 row)

Теперь выполним аналогичный запрос из сервиса psql-kintsugi:

10001@psql-kintsugi-79db99ff5d-dghpz:/$ psql "host=postgres.solution.test user=kintsugi dbname=kintsugi port=5432 sslmode=disable"
psql: error: connection to server at "postgres.solution.test" (10.40.20.72), port 5432 failed: FATAL:  certificate authentication failed for user "kintsugi"

Результат оказался не совсем тем, который мы ожидали.

Для сервиса psql-postgres подключение прошло успешно — соединение установлено, TLS используется, что подтверждается выводом pg_stat_ssl.

Однако при попытке подключения из сервиса psql-kintsugi получаем ошибку:

FATAL: certificate authentication failed for user "kintsugi"

Обратим внимание на логи Egress Gateway:

[2026-04-26T21:28:47.690Z] "- - -" 0 - - - "-" 89 510 564 - "-" "-" "-" "-" "10.40.20.72:5432" outbound|5432|postgres-external-tls-origin-postgres|postgres.solution.test 172.21.15.211:38966 172.21.15.211:5001 172.21.14.161:53470 - -
[2026-04-26T21:29:56.079Z] "- - -" 0 - - - "-" 84 108 14 - "-" "-" "-" "-" "10.40.20.72:5432" outbound|5432|postgres-external-tls-origin-postgres|postgres.solution.test 172.21.15.211:53216 172.21.15.211:5001 172.21.21.113:35306 - -

Несмотря на то, что запросы приходят от разных сервисов, в обоих случаях используется один и тот же subsetpostgres-external-tls-origin-postgres. Следовательно, Egress Gateway всегда применяет одинаковую TLS-политику и один и тот же клиентский сертификат для всех соединений, независимо от источника. Именно поэтому подключение для пользователя kintsugi завершается ошибкой: сервер PostgreSQL получает сертификат с CN=postgres и отклоняет попытку аутентификации.

Почему так произошло?

На стороне sidecar-прокси действительно доступен контекст источника (включая sourceLabels), и именно поэтому мы можем успешно разделить трафик на этом этапе. Однако при проксировании через Egress Gateway формируется новый сегмент TCP-соединения, в котором исходный контекст маршрутизации уже недоступен, то есть метки сервиса, использованные при маршрутизации в sidecar, не попадают в следующий сегмент соединения. В результате на уровне Egress Gateway все входящие подключения выглядят как однородный поток TCP-трафика, и выбор TLS-политики становится невозможен без дополнительных механизмов. Именно поэтому, несмотря на корректную маршрутизацию на уровне sidecar, на Egress Gateway применяется одна и та же TLS-политика для всех подключений.

Возникает закономерный вопрос: если контекст не передаётся, можно ли зафиксировать его в самом соединении?

Вместо попытки передать контекст напрямую, мы можем явно закодировать его в L4-характеристиках трафика — например, направляя соединения от разных сервисов на разные внутренние порты Egress Gateway. Поскольку порт остаётся неизменным на всём пути следования пакета, Egress Gateway получает возможность различать потоки и применять к ним разные политики. В результате, мы переносим логику выбора с уровня абстракций Service Mesh на уровень транспортных параметров, доступных прокси.

Чтобы разделить трафик на уровне Egress Gateway, введём дополнительный транспортный признак — выделенный порт.

Сначала добавим новый порт в сервис, через который будет обрабатываться трафик от сервиса psql-kintsugi:

spec:
  ports:
    - name: tcp-5001
      protocol: TCP
      port: 5001
      targetPort: 5001
    - name: tcp-5002
      protocol: TCP
      port: 5002
      targetPort: 5002

Далее определим соответствующий Gateway, который будет принимать подключения на этом порту:

---
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: postgres-gw-tls-origin-psql-kintsugi
spec:
  selector:
    app: egressgateway
  servers:
    - hosts:
        - postgres.solution.test
      port:
        name: postgres-tls-origin
        number: 5002
        protocol: TCP

После этого внесём изменения в ранее созданный VirtualService postgres-vs-tls-origin-psql-kintsugi: вместо общего порта 5001(TCP) будем использовать выделенный порт 5002(TCP), тем самым направляя трафик в отдельный транспортный канал.

tcp:
  - match:
      - gateways:
          - mesh
        port: 5432
        sourceLabels:
          app: psql-kintsugi
    route:
      - destination:
          host: postgres-egress-service-tls-origin
          port:
            number: 5002
          subset: postgres-internal-tls-origin-kintsugi
  - match:
      - gateways:
          - postgres-gw-tls-origin-psql-kintsugi
        port: 5002
    route:
      - destination:
          host: postgres.solution.test
          port:
            number: 5432
          subset: postgres-external-tls-origin-kintsugi

Иными словами, мы явно разделяем трафик на уровне L4: каждому сервису соответствует собственный порт Egress Gateway, а значит — и собственная TLS-политика. В результате формируются независимые транспортные потоки, которые больше не смешиваются на уровне шлюза.

Как и в предыдущем сценарии, проверим подключение к базе данных для каждого сервиса. Для этого выполним соответствующую команду внутри пода каждого приложения.

10001@psql-postgres-8f7584f7d-j97dt:/$ psql "host=postgres.solution.test user=postgres port=5432 dbname=postgres sslmode=disable"
postgres=# SELECT ssl, version, cipher
FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
 ssl | version |           cipher
-----+---------+-----------------------------
 t   | TLSv1.2 | ECDHE-RSA-AES256-GCM-SHA384
(1 row)

Для сервиса psql-postgres подключение выполнено успешно, аналогично проверим для сервиса psql-kintsugi:

10001@psql-kintsugi-79db99ff5d-dghpz:/$ psql "host=postgres.solution.test user=kintsugi port=5432 dbname=kintsugi sslmode=disable"

kintsugi=> SELECT ssl, version, cipher
FROM pg_stat_ssl
WHERE pid = pg_backend_pid();
 ssl | version |           cipher
-----+---------+-----------------------------
 t   | TLSv1.2 | ECDHE-RSA-AES256-GCM-SHA384
(1 row)

Результат ожидаемый: оба подключения устанавливаются успешно.

В логах Egress Gateway видно, как проксируется трафик и какие subset используются для маршрутизации:

[2026-04-26T21:41:45.957Z] "- - -" 0 - - - "-" 169 664 36540 - "-" "-" "-" "-" "10.40.20.72:5432" outbound|5432|postgres-external-tls-origin-postgres|postgres.solution.test 172.21.15.211:36170 172.21.15.211:5001 172.21.14.161:45982 - -
[2026-04-26T21:45:06.743Z] "- - -" 0 - - - "-" 89 511 8024 - "-" "-" "-" "-" "10.40.20.72:5432" outbound|5432|postgres-external-tls-origin-kintsugi|postgres.solution.test 172.21.15.211:37830 172.21.15.211:5002 172.21.21.113:34314 - -

Таким образом, нам удалось решить задачу многопользовательского подключения: каждый сервис устанавливает соединение с одной и той же СУБД, при этом на уровне Egress Gateway применяется корректная TLS-политика и используется соответствующий клиентский сертификат. Ключевая идея решения заключается в том, что контекст соединения кодируется в транспортных параметрах — в данном случае через порт. Это позволяет обойти ограничение TCP-маршрутизации в Service Mesh и обеспечить выбор политики на стороне инфраструктуры. Однако у такого подхода есть важная архитектурная особенность, которую необходимо учитывать при проектировании.

С ростом количества сервисов увеличивается число независимых транспортных каналов:

  • для каждого сервиса требуется выделенный порт на Egress Gateway;

  • появляются дополнительные ресурсы, описывающие политики маршрутизации;

  • растёт общий объём конфигурации в Service Mesh.

Это приводит не только к усложнению сопровождения, но и к потенциальному увеличению нагрузки на прокси (Envoy): возрастает количество listeners, clusters и правил маршрутизации, которые необходимо обрабатывать. В результате при масштабировании системы такой подход может стать фактором, влияющим на утилизацию ресурсов и управляемость конфигурации.

Тем не менее, данная схема остаётся рабочим и практичным решением для сценариев, где:

  • требуется централизованное управление TLS;

  • важно вынести работу с сертификатами из приложений;

  • количество сервисов и соединений остаётся контролируемым.

На практике такие сценарии редко остаются в изолированном виде. По мере роста системы могут потребоваться комбинированные подходы или поиск альтернативных механизмов маршрутизации и аутентификации — особенно когда количество сервисов и вариантов подключения начинает расти.

Тем не менее даже в рассмотренном варианте решение уже даёт ощутимый выигрыш: управление TLS централизуется, работа с сертификатами выносится на уровень инфраструктуры, а сами приложения избавляются от лишней сложности. В результате архитектура становится проще, а её сопровождение — предсказуемее.

Мы прошли путь от базового подключения через Service Mesh до более сложного многопользовательского сценария, разобрали ограничения TCP-маршрутизации и увидели, как они влияют на дизайн решения. Это хороший пример того, как инфраструктурные абстракции упрощают жизнь — но при этом требуют понимания своих границ.

По традиции прикладываю ссылку на репозиторий с примерами конфигурации, рассмотренными в статье: https://gitverse.ru/spbvalentine/istio-demo/tag/v1.2.0

Спасибо за внимание!

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