Глава 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).
-
Получаем имя хоста ELB:
kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' -
В Cloudflare добавляем CNAME-запись:
prod→<имя хоста ELB> -
Устанавливаем статус прокси на 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 нужно добавить вручную:
-
Перейти в Route 53 → Hosted zone
prod.wzops.de→ Create record -
Оставить поле имени записи пустым (для корневого
prod.wzops.de) -
Включить Alias
-
Маршрутизировать трафик на: Alias to Network Load Balancer (ingress controller nginx создаёт NLB, а не Classic ELB)
-
Регион:
eu-north-1(Стокгольм) -
Выбрать балансировщик из выпадающего списка
-
Создать
В результате 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-образы: те же образы, те же теги, те же реестры
-
Конфигурация приложения:
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 коммитов за пять недель — рассказывает настоящую историю: не грандиозный план миграции, а итеративный процесс создания, поломки, понимания и исправления.
Именно так и делается инфраструктурная работа.