Однажды один мудрый DevOps сказал мне: «DevOps’ы — это люди, способные сделать всё, лишь бы не делать ничего». Привет, Хабр, я Алексей Подольский, лидер направления инструментов безопасной разработки в Cloud.ru. Сегодня я расскажу, как сделать жизнь чуть легче, если кластеров кубера в проектах развелось столько, что за всеми не уследить. Будем объединять кластеры в один, да так, чтобы падали затраты, а не прод. Будет полезно админам, DevOps’ам, прикладным архитекторам, и всем, кто работает с Kubernetes. Поехали!

Это довольно типичная ситуация: когда существует несколько команд, каждая из которых занимается разработкой своего продукта в отдельном кластере K8s. Потом команды объединяются, и грустный DevOps остается один с двумя инфраструктурами, унаследованными от разных команд. У нас в практике тоже такое случалось, и даже какое-то время оставалось на плаву. А потом продукты начали активно развиваться и появилась необходимость поднимать и настраивать приклад, мониторить кластеры, настраивать ролевую модель для команды и менеджерить доступы.

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

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

  • Авторизация. Мы заметили, что на авторизацию уходит очень много времени: у нас шесть кластеров кубера, в каждом нативная авторизация и сессия падает каждый час, а под капотом у нас Argo CD.

Мы попытались решить проблему с авторизацией прикручиванием SSO-авторизации через OpenID: изучили ролевку в ArgoCD, в Grafana, подготовили конфиг-мапы и смаппили группы в Keycloak’е. Получилось по-прежнему шесть кластеров в кубере, но теперь везде SSO и сессия не дропается каждый час. Успех? Как бы не так. Спустя несколько дней и 400 переключений между вкладками стало понятно: что-то надо менять, а именно — объединять кластеры. Итак, что необходимо чтобы всё получилось?

Условия для комфортного переезда

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

  1. Шаблонизированная инфраструктура

Если ваши репозитории, где вы объявляете приложения для Argo, схожи и деплой схож, проблем с переездом не возникнет. Шаблонизация — это всегда плюс к зрелости компании и удобство для DevOps’a, который будет работать с инфраструктурой в дальнейшем.

  1. Infrastructure as Code

Когда вся инфраструктура управляется кодом, а количество того, что работает «на ручнике» минимизировано, это не только полезно для переезда, но и помогает, когда всё упало и нужно срочно всё поднять. Процессы становятся более предсказуемыми и надежными, автоматизация упрощается.

  1. Унифицированный деплой

Унифицированный процесс развертывания приложений обеспечивает единообразие и автоматизацию процессов деплоя на всех этапах разработки и эксплуатации. Унификация деплоя упрощает управление версиями, тестирование и мониторинг, а также помогает избежать ситуаций, когда приложение ведет себя по-разному в разных средах. Если ваш деплой в разных кластерах Kubernetes унифицирован, то есть либо используются одинаковые джобы, либо всё построено на темплейтах — это супер, скорее всего у вас не будет проблем с переездом.

  1. Отсутствие stateful-приложений

Поскольку stateful-приложения хранят данные о состоянии сессии пользователя, при переезде требуется заботиться еще и о сохранении и переносе их состояния. Этот процесс требует другого подхода и дополнительных усилий, поэтому в данной статье его затрагивать не будем.

Подготовка к переезду: сервисы и приклад

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

Предположим, у нас есть приложение А, которое опрашивает API какого-то внешнего сервиса, в ответ оно получает JSON-файл. Этот JSON приложение А кладет в Kafka, а приложение Б, в свою очередь, забирает JSON для работы из Kafka-топиков. В этом случае мы получаем схему взаимодействия трех внутренних компонентов: приложение А, Kafka, приложение Б. Такая схема дает понимание, какие приложения стоит переносить вместе для сохранности взаимосвязей, а какие могут не зависеть от приклада и «соседей». Постройте такую схему для каждого вашего сервиса — будем считать это списком вещей, которые нужно складывать в один чемодан при переезде.

Пример схемы взаимодействия сервисов
Пример схемы взаимодействия сервисов

После того как все взаимосвязи и зависимости выявлены, нам нужно подготовить конфигурации прикладных приложений.

Рассмотрим на примере Prometheus. Что же нужно сделать, чтобы Prometheus, который изначально собирал метрики сервиса из одного кластера, неожиданно начал собирать метрики из какой-то другой джобы?

Ответ максимально прост: добавить еще одну джобу. Это можно сравнить с добавлением нового адреса в список мест, куда вас должно отвезти такси по пути с работы домой, если вдруг вы решите заскочить в магазин:

additionalScrapeConfigs:
  - job_name: magic-app # джоба из кластера А
metrics_path: /metrics
scrape_interval: 30s
static_configs:
  - targets: [’magic.magic:8101’]
  - job_name: darkmagic-app # джоба из кластера Б
metrics_path: /metrics
scrape_interval: 30s
static_configs:
  - targets: [’darkmagic.darkmagic:8101’]

Такой же подход работает и с другими инструментами, например, с Grafana, которая помогает визуализировать данные, собранные Prometheus. Если вы хотите объединить данные из разных источников в одном месте, вы можете скопировать конфигурацию дашбордов (в виде JSON-файла) с одного инструмента Grafana и добавить их в другой. Если у вас есть система для обмена сообщениями (Kafka) и нет потребности в том, чтобы сохранять состояния данных, вы можете просто перенести настройки одного Kafka-топика в другой. И с системой для сбора и анализа логов (Loki) дело обстоит аналогично: вы настраиваете, какие логи собирать, как собирать и куда их отправлять.

Важно помнить, что все эти настройки и инструменты должны быть «легковесными», то есть не занимать много ресурсов и быть легкими в управлении. Если вы используете шаблоны для автоматического развертывания helm chart, подготовьте патчи к ним, чтобы легко адаптировать под новые условия. Это проще, чем переписывать всё с нуля — заморочиться с этим всегда успеете потом, если патчи не понравятся.

В качестве промежуточного итога у нас должны получиться объединенные конфиги прикладных приложений, а также подборка сервисов и порождаемых ими связей. Но чего-то не хватает…

Подготовка к переезду: роутинг трафика

…А не хватает нам очевидного: Istio. Здесь тоже нужны будут изменения, поскольку раньше трафик шел в два разных кластера, а теперь нужно направлять всё в общее русло. Но нам важно не просто направить трафик в одни условные «ворота», нам важно более тонко распределить его внутри кластера так, чтобы и безопасность, и доступность сервисов от этого выиграла.

В нашем случае функции DNS выполняет внутренний сервис компании, поэтому путь пытливого DevOps’a был следующий:

  1. Убираем LoadBalancer, заменяем его на ClusterAPI или на NodePort по вашему выбору. Логика тут такая: LoadBalancer используется для распределения входящего трафика между подами (контейнерами) в кластере Kubernetes, а мы, напротив, не хотим, чтобы входов было много: меньше точек входа, больше безопасности. Поэтому используем ClusterAPI или NodePort, он позволяет обращаться к сервисам уже внутри кластера, не выходя наружу.

apiVersion: v1     # Убираем LoadBalancer
kind: Service
metadata:
  name: magic-app
spec:
  selector:
	app: magic-app
  type: СlusterIP
  ports:
  - name: prom-export
	port: 8101
	protocol: TCP
	targetPort: 8101
  - name: app
	port: 80
	protocol: TCP
	targetPort: 80
  1. Все сервисы, от которых исходит трафик, или к которым он направлен, должны быть опубликованы на vService, никаких LoadBalancer, всё строго на доменах. Таким решением мы упрощаем доступ к нашим сервисам и получаем больше информации о том, какие запросы к ним идут.

apiVersion: networking.istio.io/v1beta1 # Роутим запросы через vservice
kind: VirtualService
metadata: []
spec:
  gateways:  # Обратите внимание на gateways
  - istio-system/internal-gateway
  - istio-system/external-gateway
  hosts:
  - ”magic-app.company.ru”  # и сюда тоже посмотрите
  - ”magic-app-internal.company.ru”
  http:
  - match:
	- uri:
    	prefix: /
	route:
	- destination:
    	host: magic-app
    	port:
    	number: 80
  1. Готовим Istio Gateway: разграничиваем внешние и внутренние взаимодействия кластера. Внутренний отвечает за «общение» сервисов внутри кластера, а на внешний прилетают все обращения пользователей и внешних сервисов.

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata: {…}
spec:
  selector:
	istio: ingressgateway-internal
  servers:
  - port: {…}
	hosts:
	- “magic-app-internal.company.ru
	tls:
  	httpsRedirect: true
  - port: {…}
	hosts:
	- “magic-app-internal.company.ru”
	tls: {…}


apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata: {…}
spec:
  selector:
	istio: ingressgateway-external
  servers:
  - port: {…}
	hosts:
	- “magic-app.company.ru”
	tls:
  	httpsRedirect: true
  - port: {…}
	hosts:
	- “magic-app.company.ru”
    tls: {…}

Итак. У нас есть:

  • Istio и его конфиги;

  • приклад и объединенные конфиги для него;

  • сервисы, компонентная схема их взаимодействия и примерный план попарного переезда.

Казалось бы, что может пойти не так? Давайте поразмышляем: мы собираемся объединить два кластера, в которые ходили совершенно разные люди и сервисы с разными целями. Оставляя всё как есть, мы заявляем каждому входящему пользователю: «У меня ты мало что можешь взять, но у меня есть сосед — бери у него что хочешь». Пока что мы можем наблюдать за входящим и исходящим трафиком, но не можем контролировать, что происходит внутри. Давайте это исправлять.

Подготовка к переезду: поэтапная фильтрация трафика

Начнем с тривиального: Firewall. В нем мы хотим указать кто, куда и откуда будет ходить в наш кластер и вне кластера. Также нам нужно будет указать основные порты и протоколы взаимодействия с системой. SNAT позволит нам настроить правильный выход трафика за пределы кластера, а DNAT, наоборот, пустит весь входящий трафик строго через Istio:

Для корректной фильтрации трафика в Istio также потребуются небольшие изменения. Помните, мы разделяли внутреннее и внешнее взаимодействие? Давайте дополним то, как будут выглядеть внешние взаимодействия, настройкой политик доступа. Мы видим настройку политики доступа: изначально мы хотим валидировать запрос на наличие JWT-токена и на наличие валидного issuer’а этого JWT-токена. Для этого нам нужно написать политику, где в MatchLabel указываем, к какому IstioGateway хотим применить политику. Указываем, что хотим видеть только валидный токен, прописываем удостоверяющий центр (issuer’а) и указываем ссылку на него.

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata: {…}
spec:
  selector:
	matchLabels:
  	istio: ingressgateway-external
  jwtRules:
  - forwardOriginalToken: true
	issuer: "https://example-OIDC-provider.example.com/auth/realms/"
	jwksUri: "https://example-OIDC-provider.example.com/auth/realms/protocol/openid-connect/certs"

В нашем случае политика запрещает любой трафик отовсюду, в случае если JWT-токен не совпадает или отсутствует. Это позволит нам исключить весь внешний трафик без авторизации доступа:

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata: {…}
spec:
  selector:
	matchLabels:
  	istio: ingressgateway-external
  action: DENY
  rules:
  - from:
  	- source:
      	principals:
  	       - '*’
	to:
 	 - operation:
      	paths:
          	- '*’
	when:
   	- key: 'request.auth.claims[iss]’
     	notValues:
       	- “https://example-OIDC-provider.example.com/auth/realms/”
   	- key: 'request.auth.claims[iss]’
     	values:
        	- 'null'

У опытного DevOps’а наверняка возникнет вопрос: «Внешний трафик мы отфильтровали — хорошо. А как быть с тем, что уже попал в контур?». А вот здесь на помощь к нам придет последний рубеж обороны — Network Policy. Network Policy стоит на защите ваших подов и решает, что в них попадет, а что нет. Сразу предупрежу — это самый душный и долгий метод фильтрации трафика. Он потребует полного фокуса, но разобравшись с ним, вы сможете быть уверены, что ничего лишнего в вашем кластере не окажется, а нужное не утечет.

Чтобы настроить Network Policy, вернемся к схеме взаимодействия наших сервисов, которую мы построили еще на первом этапе. Но теперь учтем и внешние взаимодействия, ведь наверняка наши сервисы ходят в базы данных, отдают логи, передают данные в сервисы аналитики и т. д.

Пример схемы сервисов с учетом внешних взаимодействий
Пример схемы сервисов с учетом внешних взаимодействий

Мы хотим фильтровать как входящий, так и исходящий трафик, поэтому пропишем по блоку для каждого из них. Сначала прописываем, кто будет посещать наше приложение. Обратите внимание, что обязательно нужно указать неймспейс Istio в политике входящего трафика, иначе он попросту не найдет сервис, на который направлять запрос и все предыдущие этапы работы будут напрасны:

ingress:
- from:
  - namespaceSelector:
  	matchLabels:
    	kubernetes.io/metadata.name: istio-system
- from:
  - namespaceSelector:
  	matchLabels:
     	kubernetes.io/metadata.name: monitoring
  ports:
  - port: 8101
- from:
  - namespaceSelector:
  	matchLabels:
    	kubernetes.io/metadata.name: logging

С исходящим всё более интересно, и менее очевидно, потому что то, куда ваш сервис хочет обращаться — это задачка на исследование. Как правило, это ваши внутренние сервисы, базы данных, дружелюбные «соседи» по виртуальным машинам. При указании адреса разрешающей политики для виртуальных машин прописывайте порт и протокол взаимодействия: это усилит вашу сетевую безопасность и сократит точки входа.

egress:
- to:
  - namespaceSelector:
  	matchLabels:
     	kubernetes.io/metadata.name: istio-system
- to:
  - namespaceSelector:
  	podSelector:
    	matchLabels:
      	k8s-app: kube-dns
  - ports:
	- port: 53
  	protocol: UDP
- to:
  - ipBlock:
  	cidr: 192.168.0.5/32
  - ports:
	- port: 6432
      protocol: TCP

Поздравляю, мы выжили после настройки Network Policy. А если вы просто проскроллили этот раздел, потому что «и таааак сойдет» — выйдите и зайдите как положено не поленитесь вернуться и настроить! Если пренебречь качеством написания политик, вы в лучшем случае потеряете часть функциональности основного приложения, а в худшем — вообще не сможете им пользоваться. Обязательно учитывайте в Network Policy весь приклад, который использует ваше приложение. И вообще все места, куда оно обращается или куда выгружает данные. Если вы кого-то не пропустите в кластер из-за неверной настройки политик, исправить это можно очень просто, а вот выгнать того, кто уже неправомерно попал в контур, задачка не из легких.

Почему я так акцентирую внимание на том, что работа над политиками должна быть особенно аккуратной и вдумчивой? Был у нас однажды случай, когда мы в Network Policy входящего трафика указали не неймспейс Istio, а лейбл одного единственного пода, вот так:

ingress:
- from:
  - namespaceSelector:
  	matchLabels:
    	app: magic-app
   ports:
   - protocol: TCP
 	port: 80
- from:
  - namespaceSelector:
  	matchLabels:
    	namespace: monitoring
- from:
  - PodSelector:
   	matchLabels:
      	namespace: istio-system

Вот так легким движением руки мы обрезали весь трафик от Istio. На продовом кластере. Не надо так.

Переезд

Проверим, всё ли на месте:

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

  • деплоим в тестовый контур;

  • наблюдаем за тем, как подружились конфиги сервисов и приклада…

… Ахахахаха, поверили! С первого раза ничего не поднимается. Но инцидентов бояться — в прод не деплоить. Поэтому:

  • вносим бесконечные правки. БОЛЬШЕ. ЕЩЕ БОЛЬШЕ.

Радуемся, что всё поднялось. Любуемся.

После этого можно аккуратненько деплоить в прод:

  • сначала деплоим конфиги;

  • проводим тесты;

  • проверяем работу политик, вы же не хотите правок в проде;

  • меняем точку входа со «старого» кластера на объединенный в нашем DNS;

  • счастье: трафик покидает старый кластер и начинает посещать новый безо всякого даунтайма.

Profit!

Итак, что мы получили в ходе таких манипуляций?

  1. Один безопасный кластер, трафик которого защищен намного сильнее, чем Error 429.

  2. Экономию на инфраструктуре в 2 раза.

  3. Сокращение объектов поддержки и обслуживания вдвое.

  4. DevOps наконец-то может сходить на обед.

Дерзайте, повторяйте, объединяйте и администрируйте! Делитесь в комментариях своими способами организации переездов и рассказывайте, что бы предпочли у вас: нанять больше DevOps’ов или нырнуть в Марианскую впадину тонких настроек безопасности?


Интересное в блоге:

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