Миграция Spring-приложения из Compose в Kubernetes

Глава 1 — Отправная точка: многосервисное Java-приложение

Любая история миграции начинается с приложения, которое переросло свою модель развёртывания. Наше приложение называется devopsCentral — опенсорсный Spring MVC-сервис управления профилями пользователей, который выглядит простым снаружи, но скрывает серьёзную операционную сложность внутри.

Само приложение написано на Java 17 / Spring 6, упаковано как WAR-файл и запускается через Tomcat 10. Но оно не работает в одиночку — ему нужны четыре вспомогательных сервиса:

  • MySQL 8 — основное хранилище данных, инициализируемое из SQL-дампа при первом запуске

  • Memcached — кэш сессий и запросов

  • RabbitMQ — асинхронный обмен сообщениями между компонентами

  • Nginx — обратный прокси (reverse proxy) перед Tomcat

Итого пять процессов, которые должны находить друг друга по имени хоста, совместно использовать секреты (пароли от базы данных, учётные данные брокера) и запускаться в правильном порядке. На одной машине разработчика Docker Compose справляется с этим элегантно. Но Compose никогда не создавался для продакшена: в нём нет самовосстановления (self-healing), нет скользящих обновлений (rolling updates), нет ограничений ресурсов, нет маршрутизации входящего трафика (ingress routing). Эта статья — о том, как мы преодолели этот разрыв.

Весь путь зафиксирован в истории коммитов одного репозитория. Каждая отладочная сессия, каждое озарение, каждое тонкое различие между Compose и Kubernetes оставили свой след. Мы пройдём по этим коммитам в хронологическом порядке.

Глава 2 — Докеризация: написание Dockerfile

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

Образ базы данных

Dockerfile для MySQL обманчиво прост: FROM mysql:8.0.33, пароль root и имя базы данных задаются через переменные окружения, а файл db_backup.sql копируется в директорию docker-entrypoint-initdb.d/. Официальный образ MySQL автоматически выполняет все SQL-файлы из этой директории при первом запуске. Это означает, что база данных сразу приходит с готовой схемой и тестовыми данными — никакой ручной настройки, никаких миграционных скриптов.

Это важно для Kubernetes: нам понадобится PersistentVolumeClaim (постоянный запрос на том), чтобы данные сохранялись при перезапуске пода. Скрипт инициализации запускается только когда директория данных пуста. EBS-тома Kubernetes приходят с заранее созданной директорией lost+found, которую MySQL воспринимает как «данные уже существуют» — ловушка, в которую мы обязательно попадём.

Образ приложения (многоэтапная сборка)

Dockerfile приложения использует многоэтапную сборку (multi-stage build): первый этап использует maven:3.9.9-eclipse-temurin-21 для компиляции исходников и получения vprofile-v2.war, второй этап копирует только WAR-файл в чистый образ tomcat:10-jdk21. Инструментарий сборки (Maven, исходный код, кэш .m2) никогда не попадает в финальный образ.

Это не просто оптимизация размера — это граница безопасности. Продакшен-образ содержит только JDK-рантайм и скомпилированный артефакт: никаких компиляторов, никаких пакетных менеджеров, никакого исходного кода, способного утечь.

Образ Nginx

Nginx выполняет единственную задачу: проксировать трафик с порта 80 на контейнер Tomcat по порту 8080. Вся конфигурация занимает шесть строк:

upstream tomcatapp {
  server app01:8080;
}
server {
  listen 80;
  location / {
    proxy_pass http://tomcatapp;
  }
}

Имя хоста app01 разрешается через внутренний DNS Docker. Это важно: когда мы перейдём на Kubernetes, этот контейнер Nginx исчезнет полностью — его заменит ресурс Ingress, который выполняет ту же работу на уровне кластера.

Глава 3 — Docker Compose: локальная оркестрация

После сборки образов Docker Compose связывает всё вместе в единый docker-compose.yml. Пять сервисов, одна сеть, одна команда для запуска.

Обнаружение сервисов через DNS

Ключевое решение: имена сервисов в Compose-файле совпадают с именами хостов в application.properties. Java-приложение настроено на подключение к db01 по порту 3306, cache01 по порту 11211 и mq01 по порту 5672. Сервисы в Compose называются db01, cache01 и mq01. Docker Compose создаёт сеть по умолчанию и регистрирует имя каждого контейнера как DNS-запись. Код приложения не знает и не хочет знать, что работает в контейнере — он просто резолвит имена хостов.

Это согласование имён — осознанное решение, которое щедро окупится позже. Когда мы будем писать манифесты Kubernetes Service, мы дадим им точно такие же имена — db01, cache01, mq01 — и коду приложения не потребуется вносить ни единого изменения на всём протяжении миграции. Имена хостов становятся контрактом между приложением и его инфраструктурой.

Управление зависимостями

Compose предлагает depends_on для управления порядком запуска. Наше приложение зависит от db01, cache01 и mq01. Но depends_on ждёт лишь запуска контейнера, а не готовности сервиса внутри него. MySQL может инициализироваться 30 секунд, и если Tomcat попытается подключиться раньше — упадёт с ошибкой.

В Compose с этим обычно справляются просто: перезапускают контейнер приложения. В Kubernetes нужно более надёжное решение — init-контейнеры (init containers), которые опрашивают DNS до тех пор, пока каждая зависимость не станет разрешаемой.

Тома и сохранность данных

Том базы данных dbdata монтируется в /var/lib/mysql. Без него каждая команда docker-compose down уничтожала бы все данные. С ним база данных переживает перезапуски контейнеров. Это та же проблема, которую решают PersistentVolumeClaim в Kubernetes, но с важным отличием: тома Compose локальны для Docker-хоста. PVC могут быть подкреплены сетевым хранилищем (EBS, NFS), которое переживает отказ узла.

Глава 4 — Ansible: модель развёртывания до Kubernetes

До Kubernetes приложение разворачивалось на голых Tomcat-серверах с помощью Ansible. Понять этот этап важно, чтобы увидеть, что именно заменяет Kubernetes — и зачем.

Что автоматизирует Ansible

Плейбук tomcat_setup.yml устанавливает JDK, скачивает Tomcat 8, создаёт сервисного пользователя, настраивает systemd-юниты и запускает Tomcat. Он обрабатывает четыре варианта ОС (CentOS 6/7, Ubuntu 14-16+) с условными задачами и шаблонизированными файлами сервисов.

Плейбук vpro-app-setup.yml скачивает последний WAR-файл из Nexus, останавливает Tomcat, архивирует старое развёртывание (резервная копия с временной меткой в /opt/), разворачивает новый WAR и перезапускает Tomcat. Он включает блок rescue — если новое развёртывание падает, автоматически выполняется откат к предыдущей версии.

Чего не хватает этому подходу

Ansible автоматизирует развёртывание, но не управляет им:

  • Нет самовосстановления — если Tomcat упадёт в 3 часа ночи, никто его не перезапустит, пока кто-нибудь не заметит

  • Нет масштабирования — добавить сервер значит поднять новую ВМ и запустить плейбук

  • Нет изоляции ресурсов — все сервисы делят CPU и память хоста

  • Нет скользящих обновлений — развёртывания требуют простоя (остановить, заменить, запустить)

Kubernetes устраняет каждый из этих недостатков. Ansible-плейбуки представляют «push-модель» развёртывания, которую Kubernetes заменяет «декларативной моделью на основе согласования состояний». Вы не говорите Kubernetes как развернуть — вы описываете желаемое состояние, а он сам разбирается, как его достичь.

Глава 5 — Написание манифестов Kubernetes: перевод

Это сердце миграции. 21 марта 2026 года в одном коммите (f7d1436) появились первые Kubernetes-манифесты. Задача: перевести каждый Compose-сервис в Deployment, Service и вспомогательные ресурсы Kubernetes — без единого изменения в коде приложения.

Соответствие объектов

Каждый Compose-сервис отображается минимум в два объекта Kubernetes: Deployment (что запускать) и Service (как найти запущенное).

Compose-сервис K8s Deployment K8s Service Что изменилось

db01 (MySQL)

dbdeploy.yaml

db01 (ClusterIP)

Добавлены PVC, init-контейнер, ограничения ресурсов

cache01 (Memcached)

mcdep.yaml

cache01 (ClusterIP)

Добавлены ограничения ресурсов

mq01 (RabbitMQ)

rmqdeploy.yaml

mq01 (ClusterIP)

Секреты из объекта K8s Secret, ограничения ресурсов

app01 (Java App)

appdeploy.yaml

prodapp-svc (ClusterIP)

Init-контейнеры для управления порядком запуска

web01 (Nginx)

Удалён

appingress.yaml (Ingress)

Контейнер полностью заменён нативным ресурсом K8s

Сохранение имён сервисов

Самое важное решение: имена Kubernetes Service совпадают с именами хостов, которые ожидает приложение. Сервис MySQL называется db01, Memcached — cache01, RabbitMQ — mq01. DNS Kubernetes автоматически создаёт записи вида db01.default.svc.cluster.local — а короткие имена вроде db01 разрешаются внутри одного неймспейса. Приложение подключается к db01:3306 как в Compose, так и в Kubernetes. Без единого изменения в коде.

Управление секретами

В Compose секреты хранятся как открытый текст в переменных окружения прямо в YAML-файле. Kubernetes использует специальный ресурс Secret (secret.yaml) со значениями в base64-кодировке. Deployment-ы ссылаются на эти секреты по имени:

env:
  - name: MYSQL_ROOT_PASSWORD
    valueFrom:
      secretKeyRef:
        name: app-secret
        key: db-pass

Это шаг вперёд по сравнению с открытым текстом: секреты хранятся как отдельные объекты, могут быть защищены через RBAC и не видны в спецификации Deployment. Это не шифрование (base64 — кодирование, а не шифрование), но это фундамент для более безопасных подходов, таких как sealed-secrets или внешние менеджеры секретов.

Постоянное хранилище

Deployment MySQL получает PersistentVolumeClaim, запрашивающий 3 ГБ хранилища с режимом доступа ReadWriteOnce. В отличие от томов Compose, этот PVC — ресурс уровня кластера, подкреплённый облачным блочным хранилищем (EBS на AWS). Режим доступа ReadWriteOnce критически важен: он означает, что только один узел может монтировать том одновременно, что напрямую влияет на выбор стратегии развёртывания.

Init-контейнеры вместо depends_on

У depends_on Compose нет эквивалента в Kubernetes. Вместо этого в Deployment приложения используются три init-контейнера, которые выполняются до запуска основного контейнера:

initContainers:
  - name: init-mydb
    image: busybox
    command: ['sh', '-c', 'until nslookup db01; do echo waiting for db01; sleep 2; done']

Каждый init-контейнер крутится в цикле, пока не сможет разрезолвить DNS-имя своей зависимости. Только когда все три (db01, cache01, mq01) разрешаются, запускается основной контейнер Tomcat. Это надёжнее depends_on: он ждёт не просто существования контейнера, а распространения DNS-записи Kubernetes — то есть того, что Service и хотя бы один под-бэкенд готовы к работе.

Nginx исчезает, появляется Ingress

Контейнер Nginx выполнял одну задачу: обратный прокси на порту 80. В Kubernetes для этого есть нативная абстракция — Ingress. Манифест appingress.yaml направляет трафик для prod.wzops.de на сервис prodapp-svc по порту 8080. Фактическим проксированием занимается Ingress Controller (в нашем случае — nginx-ingress).

Это концептуальный сдвиг. В Compose обратный прокси — «ваша проблема»: вы сами собираете и обслуживаете Nginx-контейнер. В Kubernetes это инфраструктура: кластер предоставляет маршрутизацию, терминацию TLS и хост-based routing как платформенный сервис.

Запросы и лимиты ресурсов

Каждый Compose-контейнер работает без ограничений ресурсов и может потреблять всё доступное CPU и память хоста. Каждый Kubernetes-под задаёт запросы (requests, гарантированные ресурсы) и лимиты (limits, максимальные ресурсы):

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: 500m
    memory: 512Mi

Это позволяет планировщику Kubernetes принимать взвешенные решения о размещении подов и не даёт вышедшему из-под контроля процессу положить весь узел.

Глава 6 — Отладка на Minikube: уроки, которые стоили дорого

Манифесты выглядели правильно на бумаге. Потом мы применили их к Minikube и потратили четыре дня на исправление того, что Compose молча решал за нас. История коммитов с 21 по 25 марта рассказывает эту историю отладки — коммит за коммитом.

Урок 1: ссылки на образы важны (834b521, c7a5916)

Первая попытка: deployment ссылался на образы, которых не было на Docker Hub. Обновили на walidzaouch/javaapp и walidzaouch/mysqldb. Затем ошибка форматирования YAML в поле образа init-контейнера вызвала загадочный ImagePullBackOff. Один лишний пробел в YAML — и deployment сломан.

Вывод: Kubernetes не собирает образы за вас, как это делает docker-compose build. Образы обязательно должны существовать в реестре. А ошибки пробелов в YAML — тихие убийцы.

Урок 2: имена сервисов — это контракт (1f69d83, fffa2a5)

Init-контейнеры ждали DNS-имена, которые не совпадали с манифестами Service. Приложение ожидало db01, но init-контейнер делал lookup vprodb. Service был назван правильно, но init-контейнер не обновили в соответствие с ним.

Вывод: В Compose имя сервиса задаётся один раз. В Kubernetes одно и то же имя встречается в манифесте Service, в DNS-lookup init-контейнера и в конфигурации приложения — и везде оно должно совпадать. Несоответствие не вызывает ошибки — оно порождает init-контейнер, который ждёт вечно.

Урок 3: ловушка с переводом строки в base64 (db32b6e)

Значения секретов генерировались командой echo "vprodbpass" | base64. Проблема: echo добавляет символ перевода строки. В base64 кодируется строка "vprodbpass\n", а не "vprodbpass". MySQL получает пароль с завершающим символом перевода строки — аутентификация молча падает.

Исправление: echo -n "vprodbpass" | base64. Флаг -n подавляет перевод строки.

Вывод: Это одна из самых распространённых ошибок при работе с Kubernetes Secrets. В закодированное значение попадает всё, что вы передаёте в base64, включая невидимые символы. Всегда используйте echo -n.

Урок 4: права доступа MySQL по хосту и работа с томом (30c572d, 30d574a)

Одновременно всплыли два бага.

Баг А: MySQL отклонял соединения от пода приложения. В Compose все контейнеры находятся в одной Docker-сети, и MySQL принимает соединения от любого контейнера в этой сети. В Kubernetes IP пода приложения берётся из диапазона CIDR подов кластера — дефолтная конфигурация MySQL не распознаёт его как разрешённый хост. Исправление: задать MYSQL_ROOT_HOST: "%" для разрешения соединений с любого IP.

Баг Б: Deployment базы данных использовал стратегию RollingUpdate по умолчанию. С PVC в режиме ReadWriteOnce новый под не может примонтировать том, пока старый его не освободит, — но RollingUpdate запускает новый под до уничтожения старого. Дедлок. Исправление: переключиться на стратегию Recreate, которая сначала уничтожает старый под, а затем запускает новый.

Вывод: Compose даёт плоскую сеть, где все доверяют всем. Kubernetes даёт изолированную сеть подов, где доверие нужно настраивать явно. А stateful-нагрузки с томами, допускающими только одного писателя, не могут использовать rolling update — стратегия развёртывания должна соответствовать режиму доступа к хранилищу.

Вердикт по Minikube

Каждый баг в этой главе был невидим в Docker Compose. Более простая модель Compose — общая сеть, локальные тома, секреты в открытом виде — скрывает сложность, которую Kubernetes обнажает. Minikube поймал всё это до того, как оно стало стоить реальных денег на AWS. Цикл итерации (изменить манифест → kubectl apply → проверить логи → исправить → повторить) быстрый и бесплатный.

Глава 7 — Выход в продакшен: kOps на AWS

После отладки манифестов локально последний шаг — продакшен-кластер Kubernetes. kOps (Kubernetes Operations) разворачивает кластеры на AWS и управляет ими, создавая EC2-инстансы, сетевую инфраструктуру, DNS и компоненты управляющего слоя (control plane).

Дизайн кластера

Файл kops-cluster-compose2k8s.yaml описывает продакшен-кластер:

  • Имя: prod.wzops.de — настоящий домен, поскольку kOps использует DNS для обнаружения кластера

  • Регион: eu-north-1 (Стокгольм), распределён по двум зонам доступности (eu-north-1a, eu-north-1b) для отказоустойчивости

  • Версия Kubernetes: 1.35.2

  • Хранилище состояния: s3://kubernetes-kops-state-aws/prod.wzops.de — kOps хранит состояние кластера в S3, что позволяет восстановить его и предоставить доступ всем членам команды

Сеть: Cilium вместо kube-proxy

Кластер использует Cilium как CNI-плагин с явно отключённым kube-proxy. Это дальновидный выбор: Cilium заменяет iptables-маршрутизацию сервисов kube-proxy на eBPF-программы, выполняемые непосредственно в ядре. Практический эффект: лучшая производительность при масштабировании, применение сетевых политик и встроенная наблюдаемость на уровне сети.

NodePort включён, что позволяет напрямую открывать сервисы на IP-адресах узлов для тестирования или в качестве бэкендов для балансировщика нагрузки.

Безопасность

В спецификацию кластера встроен ряд решений по усилению защиты:

  • Анонимная аутентификация kubelet отключена — никаких неаутентифицированных запросов к API kubelet

  • ETCD-тома зашифрованы — состояние кластера (включая Secrets) шифруется в покое на EBS

  • Доступ к реестру контейнеров через IAM — узлы аутентифицируются в ECR/Docker Hub через IAM-роли, а не через статические учётные данные на диске

  • 90-дневное хранение резервных копий ETCD — автоматические бэкапы для аварийного восстановления

Что kOps даёт сверх Minikube

Minikube — это однонодовый кластер на вашем ноутбуке. kOps создаёт настоящий многонодовый, мульти-AZ кластер с:

  • Высокой доступностью — управляющий слой и рабочие узлы распределены по зонам доступности

  • Автоматическим масштабированием — группы узлов могут масштабироваться в зависимости от нагрузки

  • Постоянным хранилищем — EBS-тома с гарантиями надёжности AWS

  • Интеграцией с DNS — кластер регистрирует себя в Route53

  • Путём к обновлениямkops update cluster управляет обновлениями версии Kubernetes

Те же самые манифесты, которые работали на Minikube, разворачиваются в kOps без изменений. В этом и состоит обещание Kubernetes: портируемость от ноутбука до облака.

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

Кластер создаётся с локальной машины (или с EC2-jumpbox с IAM-ролью):

export KOPS_STATE_STORE=s3://kubernetes-kops-state-aws
kops create cluster \
  --name=prod.wzops.de \
  --state=s3://kubernetes-kops-state-aws \
  --zones=eu-north-1a,eu-north-1b \
  --node-count=2 \
  --node-size=t3.small \
  --control-plane-size=t3.medium \
  --dns-zone=prod.wzops.de \
  --node-volume-size=12 \
  --control-plane-volume-size=12 \
  --ssh-public-key ~/.ssh/id_ed25519.pub \
  --yes

Флаг --ssh-public-key встраивает публичный ключ в узлы кластера для экстренного SSH-доступа. Этот ключ не используется kubectl — kubectl общается с API-сервером по HTTPS, используя учётные данные из ~/.kube/config. SSH-ключ — это чёрный ход для отладки на уровне узла: проверки статуса kubelet, инспекции контейнерных рантаймов, верификации монтирования EBS-томов.

Флаг --state нужно передавать при каждой команде kops или экспортировать как KOPS_STATE_STORE. Между вызовами kops не запоминает хранилище состояния.

После создания нужно дождаться готовности всех узлов:

kops validate cluster --name prod.wzops.de --state=s3://kubernetes-kops-state-aws --wait 10m

Ловушка несовместимости платформ

Первая попытка развёртывания завершилась ImagePullBackOff на подах приложения и базы данных:

Failed to pull image "walidzaouch/javaapp": no match for platform in manifest: not found

Образы были собраны на Mac с Apple Silicon (arm64), но EC2-узлы AWS работают на amd64. Docker-образы платформозависимы — образ arm64 не запустится на хосте amd64. Исправление: пересобрать с помощью docker buildx с явным указанием целевой платформы:

docker buildx build --platform linux/amd64 -t walidzaouch/javaapp:latest --push ./Docker-files/app/
docker buildx build --platform linux/amd64 -t walidzaouch/mysqldb:latest --push ./Docker-files/db/

Minikube на Mac с M-процессором скрывает эту проблему: он работает на той же архитектуре, что и хост, поэтому локально собранные образы работают нормально. Несоответствие проявляется только при развёртывании на облачной инфраструктуре.

Установка Nginx Ingress Controller

Манифест appingress.yaml ссылается на ingressClassName: nginx, но ресурс Ingress — это только конфигурация, которой нужен контроллер для исполнения. Без контроллера Ingress не получает ADDRESS и трафик не достигает приложения.

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.4/deploy/static/provider/aws/deploy.yaml

Это создаёт nginx ingress controller в собственном неймспейсе (ingress-nginx). Сервис контроллера имеет тип LoadBalancer, что автоматически инициирует создание Elastic Load Balancer (ELB) на стороне AWS. ELB — это точка входа из интернета в кластер:

Интернет → ELB (:80/:443) → под nginx-ingress-controller → читает правила Ingress → маршрутизирует на prodapp-svc:8080

ELB находится за пределами Kubernetes-кластера — это ресурс AWS. Ingress controller живёт внутри кластера как под. Неймспейсы — это логическая граница (организация и контроль доступа), а не физическая: под ingress controller работает на тех же рабочих узлах, что и поды приложения.

Развёртывание манифестов

После запуска ingress controller:

kubectl apply -f kubedefs/

Все одиннадцать манифестов разворачиваются одной командой. Планировщик распределяет поды между двумя рабочими узлами на основе доступности ресурсов — в манифестах нет явного node affinity, поэтому размещение недетерминировано.

Межузловая коммуникация работает прозрачно через CNI overlay-сеть (Cilium). Когда под приложения на Узле 1 подключается к cache01:11211, CoreDNS резолвит это в ClusterIP, а kube-proxy/Cilium маршрутизирует трафик на под memcached, работающий на Узле 2. Поды не знают, что находятся на разных узлах.

Подключение Cloudflare к ELB

Для маршрутизации публичного трафика к кластеру существует два варианта. Мы реализовали оба — последовательно.

Вариант А — CNAME из Cloudflare на ELB (проще)

Этот подход оставляет Cloudflare DNS-провайдером для wzops.de. Route 53 обрабатывает только внутренний DNS кластера (api.internal.prod.wzops.de).

  1. Получаем имя хоста ELB: kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'

  2. В Cloudflare добавляем CNAME-запись: prod<имя хоста ELB>

  3. Устанавливаем статус прокси на Proxied (оранжевое облако) и режим SSL на Flexible

При включённом Proxied цепочка запроса выглядит так:

Браузер → HTTPS → Cloudflare (терминирует SSL с валидным сертификатом) → HTTP → ELB → nginx ingress → приложение

Cloudflare берёт на себя терминацию SSL — nginx ingress controller по умолчанию выдаёт самоподписанный сертификат, который браузеры отклоняют. Без прокси Cloudflare при переходе на https://prod.wzops.de вы получите NET::ERR_CERT_AUTHORITY_INVALID. Переключение прокси в режим DNS only (серое облако) означает, что браузер подключается напрямую к ELB и видит самоподписанный сертификат.

Режим SSL важен: при включённом Proxied Cloudflare обращается к вашему origin (ELB) на бэкенде. «Flexible» означает, что Cloudflare→ELB работает по HTTP. «Full» означает Cloudflare→ELB по HTTPS (что упадёт, так как у ingress нет валидного сертификата). Используйте Flexible, если на ingress controller не установлен настоящий сертификат.

Вариант Б — Делегирование NS через Route 53

Этот подход делегирует весь поддомен prod.wzops.de из Cloudflare в Route 53. Route 53 управляет всем DNS — как внутренними записями кластера, так и публичной записью ELB.

Шаг 1 — Добавление NS-записей в Cloudflare:

В DNS-настройках Cloudflare для wzops.de удаляем CNAME-запись для prod и добавляем NS-записи, указывающие на неймсерверы Route 53 для hosted zone prod.wzops.de:

NS  prod  ns-1895.awsdns-44.co.uk
NS  prod  ns-587.awsdns-09.net
NS  prod  ns-306.awsdns-38.com
NS  prod  ns-1047.awsdns-02.org

Значения этих неймсерверов берутся из NS-записи в hosted zone Route 53.

Шаг 2 — Добавление записи ELB в Route 53:

kops создаёт DNS-записи для внутренней коммуникации кластера (api.internal.prod.wzops.de, api.prod.wzops.de), но не для публичного сайта. Запись ELB нужно добавить вручную:

  1. Перейти в Route 53 → Hosted zone prod.wzops.de → Create record

  2. Оставить поле имени записи пустым (для корневого prod.wzops.de)

  3. Включить Alias

  4. Маршрутизировать трафик на: Alias to Network Load Balancer (ingress controller nginx создаёт NLB, а не Classic ELB)

  5. Регион: eu-north-1 (Стокгольм)

  6. Выбрать балансировщик из выпадающего списка

  7. Создать

В результате hosted zone Route 53 содержит следующие записи:

Запись Тип Указывает на Назначение

prod.wzops.de

NS

Неймсерверы Route 53

Авторитет — «я управляю этой зоной»

prod.wzops.de

SOA

Основной неймсервер

Метаданные зоны

prod.wzops.de

A (Alias)

NLB

Трафик публичного сайта

api.prod.wzops.de

A

Публичный IP управляющего слоя

Доступ kubectl

api.internal.prod.wzops.de

A

Приватный IP управляющего слоя

Рабочий узел → API-сервер

kops-controller.internal.prod.wzops.de

A

Приватный IP управляющего слоя

Внутреннее управление kops

Компромисс: Вариант Б даёт kops полный контроль над DNS и сосредотачивает всё в AWS, но лишает вас возможностей прокси Cloudflare (защита от DDoS, кэширование, бесплатная терминация SSL). При Варианте Б для работы HTTPS потребуется установить настоящий TLS-сертификат на ingress controller (например, через cert-manager + Let’s Encrypt).

Отладка DNS: три подводных камня

Камень 1 — переопределение через /etc/hosts

После настройки Cloudflare (Вариант А) prod.wzops.de был по-прежнему недоступен. Резолвинг DNS показывал адрес 192.168.49.2 — IP Minikube. Виновник: запись в /etc/hosts, оставшаяся с этапа тестирования на Minikube. Файл /etc/hosts проверяется до любого DNS-сервера — он переопределяет Cloudflare, Route 53 и всё остальное.

Исправление: sudo sed -i '' '/prod.wzops.de/d' /etc/hosts

Приоритет резолвинга DNS на macOS:

1. /etc/hosts          ← проверяется первым, переопределяет всё
2. Локальный DNS-резолвер   ← ваш роутер (например, 192.168.100.1)
3. Вышестоящий DNS         ← Cloudflare/Route 53

Камень 2 — кэш DNS роутера

После переключения с Варианта А на Вариант Б домен перестал резолвиться, хотя Route 53 был настроен правильно. dig (который запрашивает DNS-серверы напрямую) возвращал верные IP, а curl и nslookup (использующие системный резолвер) — ничего.

Причина: локальный роутер (192.168.100.1) закэшировал старый ответ «нет данных» из времени до создания alias-записи в Route 53. Google DNS (8.8.8.8) резолвил корректно, так как уже подхватил новую запись.

Исправление: перезагрузить роутер, дождаться истечения TTL или переключить DNS на Mac на Google DNS (8.8.8.8, 8.8.4.4) в Системных настройках → Wi-Fi → DNS.

Вывод: Когда DNS-изменения не применяются, сначала проверьте через публичный DNS-сервер (nslookup prod.wzops.de 8.8.8.8) — это поможет определить, проблема в конфигурации DNS или в локальном кэшировании.

Камень 3 — ошибка SSL-сертификата

Переход на https://prod.wzops.de в режиме DNS only (серое облако) в Cloudflare выдавал NET::ERR_CERT_AUTHORITY_INVALID. Nginx ingress controller по умолчанию выдаёт самоподписанный «Kubernetes Ingress Controller Fake Certificate». Браузеры самоподписанным сертификатам не доверяют.

Исправление: либо использовать режим Proxied в Cloudflare (Cloudflare терминирует SSL своим валидным сертификатом), либо установить на ingress controller настоящий сертификат через cert-manager.

Что осталось за рамками

После того как кластер заработал и стал доступен через домен, следующими шагами будут:

  • Настройка мониторинга и логирования (Prometheus, Grafana или CloudWatch)

  • Реализация GitOps-процесса (ArgoCD или Flux) для автоматического применения манифестов при пуше

От Ingress к Gateway API: следующая эволюция

Kubernetes Ingress хорошо нам послужил, но у него есть преемник. Gateway API — более выразительная, ориентированная на роли модель маршрутизации, которую сообщество Kubernetes стандартизирует как будущее управления трафиком. Мы сделали переход на живом кластере — и сразу обнаружили, что экосистема не настолько готова, насколько готова сама спецификация.

Почему Gateway API заменяет Ingress

Ingress имеет фундаментальное ограничение дизайна: всё живёт в одном ресурсе. Правила маршрутизации, выбор контроллера, конфигурация протокола — всё это в одном YAML-файле. Gateway API разделяет эти обязанности на три ресурса с чёткими границами владения:

Ресурс Кто владеет Что делает

GatewayClass

Провайдер инфраструктуры

«Какой контроллер обрабатывает трафик?»

Gateway

Оператор кластера

«Какие порты, протоколы и имена хостов слушаем?»

HTTPRoute

Разработчик приложения

«Куда идёт трафик моего приложения?»

В реальных организациях это разделение принципиально важно. Платформенная команда настраивает GatewayClass и Gateway. Команда разработчиков пишет HTTPRoute. Никому не нужно трогать ресурсы другой команды. С Ingress все редактируют один и тот же объект.

Ещё одно преимущество — портируемость. HTTPRoute выглядит одинаково вне зависимости от используемого контроллера — nginx, Istio, Cilium, Envoy. Смена реализации означает изменение одного поля (gatewayClassName), а не переписывание аннотаций.

Миграция: шаг за шагом

Шаг 1 — Удаление Nginx Ingress Controller

Старый контроллер устанавливался через URL манифеста. Удаление — точная обратная операция:

kubectl delete -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.4/deploy/static/provider/aws/deploy.yaml

Это удаляет неймспейс ingress-nginx, под контроллера, сервис LoadBalancer (что депровизирует NLB), ClusterRole, IngressClass и конфигурации вебхуков. Это точная отмена — удаляется каждый объект из манифеста, как в рамках неймспейса, так и на уровне кластера.

Альтернатива — kubectl delete namespace ingress-nginx — каскадно удаляет всё внутри неймспейса, но оставляет «осиротевшими» ресурсы уровня кластера (IngressClass, ClusterRole, вебхуки). Подход через -f — чистый путь.

Шаг 2 — Установка CRD Gateway API и NGINX Gateway Fabric

Ресурсы Gateway API (GatewayClass, Gateway, HTTPRoute) не встроены в Kubernetes — это Custom Resource Definition (пользовательские определения ресурсов), которые нужно устанавливать отдельно:

kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml

Затем устанавливаем контроллер. NGINX Gateway Fabric v2.x перешёл на установку только через Helm из OCI-реестра — отдельного YAML-манифеста больше нет:

helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
  --version 2.4.2 \
  --create-namespace \
  -n nginx-gateway

Helm автоматически создаёт неймспейс nginx-gateway, разворачивает под контроллера, генерирует TLS-сертификаты для внутреннего mTLS и регистрирует GatewayClass с именем nginx.

Ключевое отличие от старого ingress controller: сервис LoadBalancer пока не создаётся. NLB провизируется только при создании ресурса Gateway — контроллер ждёт, пока вы не объявите, чего хотите, прежде чем выделять облачные ресурсы.

Шаг 3 — Создание ресурсов Gateway API

Два новых файла заменяют appingress.yaml:

gateway.yaml — конфигурация слушателя:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: prod-gateway
spec:
  gatewayClassName: nginx
  listeners:
  - name: http
    protocol: HTTP
    port: 80
    hostname: prod.wzops.de

httproute.yaml — правило маршрутизации (та же роль, что у старого Ingress):

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: prod-route
spec:
  parentRefs:
  - name: prod-gateway
  hostnames:
  - prod.wzops.de
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /
    backendRefs:
    - name: prodapp-svc
      port: 8080

Применяем оба:

kubectl apply -f kubedefs/gateway.yaml -f kubedefs/httproute.yaml

Gateway переходит в состояние Programmed: True, и автоматически провизируется новый NLB. HTTPRoute привязывается к Gateway и маршрутизирует трафик на тот же бэкенд prodapp-svc:8080.

Шаг 4 — Обновление DNS

Новый NLB имеет другое имя хоста, чем старый. Обновляем Alias A-запись в Route 53 (или CNAME в Cloudflare), указывая на новый адрес ELB:

kubectl get gateway prod-gateway -o jsonpath='{.status.addresses[0].value}'

Проблема с документацией

Честно скажем: самой сложной частью этой миграции был поиск рабочих инструкций по установке.

NGINX Gateway Fabric прошёл серьёзную переработку с версии v1.x до v2.x. В ходе этого перехода:

  • GitHub-организация переехала с nginxinc на nginx — сломав каждую ссылку в каждом туториале

  • Отдельный YAML-манифест был заменён установкой только через Helm

  • Helm-чарт переехал из традиционного репозитория в OCI-реестр (oci://ghcr.io/nginx/charts/…​)

  • Архитектура изменилась: появился agent-sidecar с mTLS, добавив cert-manager как зависимость для установки через манифест

Каждый пост в блоге, каждый ответ на Stack Overflow, каждый туториал, написанный полгода назад, содержит нерабочие ссылки. Официальная документация существует, но переехала. Мы получили три ошибки 404 подряд, прежде чем нашли правильный путь к OCI-чарту. Это не критика команды NGINX — это отражение проекта, находящегося в середине перехода. Но это означает, что миграция на Gateway API сегодня требует больше детективной работы, чем можно было бы ожидать от элегантной спецификации.

Взгляд вперёд: альтернативные реализации

NGINX Gateway Fabric — одна из нескольких реализаций Gateway API. Ирония нашей конфигурации в том, что кластер уже использует Cilium как CNI, который имеет встроенную поддержку Gateway API. Мы могли бы вообще не устанавливать дополнительный контроллер.

Следующий шаг — переключение на Istio в качестве реализации Gateway API, что наглядно продемонстрирует обещание портируемости: HTTPRoute остаётся идентичным, меняется только GatewayClass.

Что мы узнали: Compose и Kubernetes рядом

Что меняется

Аспект Docker Compose Kubernetes

Обнаружение сервисов

Имена контейнеров в Docker-сети

Объекты Service + DNS кластера

Обратный прокси

Nginx-контейнер, который вы поддерживаете

Ресурс Ingress, управляемый кластером

Секреты

Открытый текст в YAML или .env

Объекты Secret (base64), защищённые RBAC

Хранилище

Локальные тома Docker

PVC, подкреплённые облачным хранилищем (EBS)

Порядок запуска

depends_on (запуск контейнера)

Init-контейнеры (резолвинг DNS)

Ограничения ресурсов

Отсутствуют по умолчанию

Requests и limits на каждый под

Стратегия развёртывания

Остановить и запустить (вручную)

Rolling update или Recreate (декларативно)

Самовосстановление

Отсутствует — авария = ручной перезапуск

Kubelet автоматически перезапускает упавшие контейнеры

Масштабирование

Вручную (docker-compose up --scale)

kubectl scale или HPA (автомасштабирование)

Что остаётся неизменным

  • Код приложения: не изменилась ни одна строка

  • Docker-образы: те же образы, те же теги, те же реестры

  • Конфигурация приложения: application.properties не тронут, потому что имена Service совпадают с именами сервисов Compose

  • Конвейер сборки: Jenkins производит те же артефакты вне зависимости от цели развёртывания

Вот ключевое понимание: миграция — это не переписывание. Это перевод инфраструктурных задач с одного уровня абстракции на другой. Приложение не знает — и не должно знать — работает ли оно под Compose или под Kubernetes.

Реальная стоимость миграции

Самой сложной частью было не написать 11 YAML-манифестов. Это были четыре дня отладки тонких различий между двумя средами:

  • Символ перевода строки в base64-закодированном секрете

  • Разрешения MySQL по хосту, которые Compose обрабатывал неявно

  • Режим доступа к тому, конфликтующий со стратегией развёртывания по умолчанию

  • Имена сервисов, которые должны совпадать в трёх разных местах вместо одного

Каждый из этих багов занимает 15 минут на исправление, когда понимаешь суть проблемы, и 3 часа отладки — когда не понимаешь. Minikube превращает это обучение в безопасную, бесплатную среду. Манифесты, прошедшие через Minikube, развернулись в kOps с первой попытки.

Заключительные мысли

Путь от docker-compose up до продакшен-кластера Kubernetes — протоптанный, но никогда не тривиальный. Это путешествие к всё более высоким уровням абстракции: от «запустите эти контейнеры вместе» до «опишите желаемое состояние распределённой системы и позвольте платформе его согласовать».

Этот путь становится управляемым благодаря дисциплине в именовании, конфигурации и итерациях:

  • Называйте сервисы в соответствии с конфигурацией приложения — и вам никогда не придётся менять код приложения

  • Тестируйте на Minikube до того, как потратите хоть доллар на облачную инфраструктуру

  • Читайте логи ошибок — каждый баг в Kubernetes — это допущение Compose, которое всплыло на поверхность

Приложение devopsCentral осталось тем же самым приложением, каким было в начале. Инфраструктура вокруг него эволюционировала от ноутбука разработчика до облачно-нативной платформы. История коммитов — 14 коммитов за пять недель — рассказывает настоящую историю: не грандиозный план миграции, а итеративный процесс создания, поломки, понимания и исправления.

Именно так и делается инфраструктурная работа.

© 2026 meganuke