18.3. От Compose к Kubernetes
Цели
После этого материала вы сможете:
- сопоставить каждую конструкцию Compose с объектами Kubernetes;
- назвать три вещи, которые не переносятся, и объяснить почему;
- объяснить, почему автоматическая конвертация даёт плохой результат;
- перенести стек из раздела 09 вручную и обосновать каждое решение;
- определить, что в вашем
compose.yamlтребует переосмысления до переноса; - оценить объём работы по переносу до его начала.
Предварительные знания
- 9.7. Практический стек — исходный стек;
- 18.2. Объекты Kubernetes;
- 11.3. Healthchecks и readiness.
Кластер не требуется. Манифесты разбираются как документы.
Ключевые термины
| Термин | Объяснение |
|---|---|
конвертация | Автоматическое преобразование Compose в манифесты |
Deployment | Объект для приложений без собственного состояния |
StatefulSet | Объект для приложений с состоянием и устойчивыми именами |
PVC | Запрос на постоянное хранилище |
Ingress | Маршрутизация внешнего HTTP-трафика |
Теория
Таблица соответствий
| Конструкция Compose | Объекты Kubernetes | Перенос |
|---|---|---|
services.<имя> | Deployment + Service | Прямой |
image | spec.containers[].image | Прямой |
command, entrypoint | command, args | Прямой |
environment | env или ConfigMap | Прямой |
env_file | ConfigMap из файла | Прямой |
secrets | Secret | Прямой |
ports | Service + containerPort | Переосмысление |
volumes (именованный) | PVC + volumeMounts | Переосмысление |
volumes (bind mount) | ConfigMap, Secret или hostPath | Переосмысление |
tmpfs | emptyDir с medium: Memory | Прямой |
healthcheck | livenessProbe + readinessProbe | Переосмысление |
depends_on | initContainers, пробы, повторы | Не переносится |
deploy.replicas | spec.replicas | Прямой |
deploy.resources | requests + limits | Переосмысление |
restart | Всегда действует; политика на уровне pod'а | Прямой |
networks | Плоская сеть кластера | Не переносится |
stop_grace_period | terminationGracePeriodSeconds | Прямой |
user | securityContext.runAsUser | Прямой |
read_only | readOnlyRootFilesystem | Прямой |
cap_drop | securityContext.capabilities.drop | Прямой |
profiles | Отдельные файлы или инструмент шаблонов | Не переносится |
| Имя проекта | Namespace | Частично |
Двенадцать конструкций из двадцати двух переносятся прямо. Разберём остальные.
Что не переносится: depends_on
depends_on:
db:
condition: service_healthy
В Kubernetes такого механизма нет. Причина в модели: pod'ы запускаются параллельно, и порядок не гарантируется в принципе.
Три способа заменить:
| Способ | Когда подходит |
|---|---|
initContainers | Обязательная подготовка: миграция, ожидание базы |
readinessProbe | Приложение может стартовать без зависимости |
| Повторы в приложении | Правильный ответ в большинстве случаев |
Третья строка — главный вывод. Kubernetes исходит из того, что зависимость может отказать в любой момент, а не только при старте.
Приложение, умеющее переподключаться, не нуждается ни в depends_on, ни в initContainers. Приложение, падающее при недоступности базы, будет падать и в эксплуатации — просто отложенно.
Отсюда практическое следствие: перенос в Kubernetes выявляет приложения, не готовые к отказам зависимостей.
Что не переносится: сети
networks:
frontend:
backend:
internal: true
В Kubernetes сеть плоская: любой pod может обратиться к любому. Изоляция достигается не сетями, а NetworkPolicy — правилами, которые фильтруют трафик.
| Compose | Kubernetes |
|---|---|
| Несколько сетей | Одна плоская сеть |
internal: true | NetworkPolicy с запретом по умолчанию |
| Подключение к сети | Метки pod'а и селекторы в политике |
Существенное различие: NetworkPolicy применяет плагин сети, а не API-сервер. Объект будет принят в любом случае; подействует ли он — зависит от плагина.
Проверено на кластере kind с плагином kindnet: политика подействовала, связь закрылась (verify/FACTS.md). То есть «молча не работает» — не общее правило, а возможный режим отказа.
Отсюда единственно надёжный подход: проверять фактическую доступность, а не наличие объекта.
Проверять нужно фактическую доступность, а не наличие политики.
Что переосмысливается: ports
ports:
- "8080:8000"
В Compose это одна строка. В Kubernetes — три отдельных решения:
| Решение | Объект |
|---|---|
| Какой порт слушает приложение | containerPort |
| Как обращаться внутри кластера | Service |
| Как обращаться снаружи | Ingress, LoadBalancer или NodePort |
Публикация «наружу» в Kubernetes — отдельное решение с последствиями по стоимости и безопасности, а не побочный эффект строки в файле.
Полезное наблюдение: большинство сервисов, публиковавших порты в Compose «чтобы проверить», наружу не нужны вовсе.
Что переосмысливается: тома и состояние
volumes:
pgdata:
Именованный том становится PVC. Но вместе с ним меняется и объект, управляющий приложением:
| Свойство | Deployment | StatefulSet |
|---|---|---|
| Имена pod'ов | Случайные | Устойчивые: db-0, db-1 |
| Хранилище | Общее или без него | Своё у каждого pod'а |
| Порядок запуска | Произвольный | По очереди |
| Для чего | Приложения без состояния | Базы данных, очереди |
База данных в Deployment с одним PVC работает, пока реплика одна. При попытке масштабирования два pod'а обратятся к одному тому — и это либо не запустится (ReadWriteOnce), либо повредит данные.
Правило: приложение с состоянием — StatefulSet, не Deployment.
Отдельный вопрос, который перенос заставляет задать: нужно ли вообще держать базу в кластере. Управляемая база вне кластера снимает целый класс задач (урок 18.4).
Что переосмысливается: healthcheck
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
Одна проверка превращается в две или три (урок 18.2). Механический перенос — скопировать один и тот же путь в liveness и readiness — даёт ошибку, разобранную в предыдущем уроке.
Требуется разделить эндпоинты:
| Проверка | Что проверяет |
|---|---|
/healthz | Только процесс: отвечает ли |
/readyz | Готовность: зависимости, прогрев, очередь |
/startupz | Завершена ли инициализация |
Если приложение таких эндпоинтов не имеет — их придётся добавить. Это работа в коде, а не в манифесте.
Почему автоматическая конвертация даёт плохой результат
Инструменты вроде kompose преобразуют compose.yaml в манифесты. Результат запускается — и почти всегда плох.
| Что делает конвертер | Почему это плохо |
|---|---|
healthcheck → одна проба или ни одной | Теряется разделение liveness и readiness |
| Все сервисы → Deployment | База данных должна быть StatefulSet |
ports → NodePort или LoadBalancer | Публикует наружу то, что не должно быть снаружи |
depends_on → игнорируется | Проблема остаётся неосознанной |
| Ресурсы → как есть или пусто | Нет requests → класс BestEffort |
networks → игнорируются | Изоляция теряется молча |
restart → политика pod'а | Обычно верно |
Главная беда не в отдельных ошибках, а в том, что результат выглядит работающим. Он запускается, отвечает на запросы — и содержит решения, которые никто не принимал.
Разумное применение конвертера: получить черновик, чтобы увидеть объём работы, и переписать вручную.
Порядок ручного переноса
1. Образы закрепить по digest, проверить пригодность ([урок 18.1])
2. Конфигурация environment → ConfigMap, secrets → Secret
3. Состояние решить: StatefulSet, PVC или вынести из кластера
4. Пробы разделить healthcheck на три эндпоинта
5. Ресурсы добавить requests, не только limits
6. Сеть Service для каждого; Ingress — только где нужен
7. Зависимости убрать depends_on, добавить повторы в приложении
8. Изоляция NetworkPolicy вместо отдельных сетей
Порядок не случаен: каждый следующий шаг опирается на решения предыдущего.
Внутренний механизм
Почему depends_on не имеет аналога
Compose запускает сервисы на одной машине и знает порядок. Kubernetes распределяет pod'ы по узлам, и порядок зависит от доступности ресурсов, планировщика, состояния узлов.
Гарантировать порядок означало бы отказаться от параллельного запуска — то есть от того, ради чего система устроена так.
Вместо порядка предлагается устойчивость к отсутствию зависимости: приложение переподключается, readiness-проба не пропускает трафик до готовности, Service не направляет запросы на неготовые pod'ы.
Почему сеть плоская
Каждый pod получает адрес из общего пространства, и адреса маршрутизируются между узлами без трансляции. Это требование модели сети Kubernetes.
Отдельные сети, как в Compose, потребовали бы либо трансляции адресов, либо привязки pod'ов к сегментам — и то и другое усложнило бы планирование.
Изоляция вынесена на уровень политик: NetworkPolicy описывает, кто с кем может говорить, и применяется плагином сети.
Команды и примеры
Исходный стек
mkdir -p /tmp/migrate && cd /tmp/migrate
cat > compose.yaml <<'EOF'
name: practical-stack
services:
api:
build: .
image: ghcr.io/org/api:1.4.2
command: ["python", "-m", "app.main"]
ports:
- "8080:8000"
environment:
LOG_LEVEL: info
DATABASE_URL: postgresql://app@db:5432/appdb
REDIS_URL: redis://cache:6379/0
secrets:
- db_password
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
deploy:
replicas: 2
resources:
limits:
cpus: "0.5"
memory: 256M
stop_grace_period: 45s
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
networks: [frontend, backend]
restart: unless-stopped
worker:
image: ghcr.io/org/api:1.4.2
command: ["python", "-m", "app.worker"]
environment:
REDIS_URL: redis://cache:6379/0
depends_on:
cache:
condition: service_started
deploy:
replicas: 3
stop_grace_period: 60s
networks: [backend]
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
retries: 10
networks: [backend]
restart: unless-stopped
cache:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- cachedata:/data
networks: [backend]
restart: unless-stopped
volumes:
pgdata:
cachedata:
secrets:
db_password:
file: ./secrets/db_password.txt
networks:
frontend:
backend:
internal: true
EOF
echo "═══ состав стека ═══"
python3 - <<'PY'
import yaml
from pathlib import Path
c = yaml.safe_load(Path("compose.yaml").read_text())
services = c["services"]
print(f" сервисов: {len(services)}")
print(f" томов: {len(c.get('volumes', {}))}")
print(f" секретов: {len(c.get('secrets', {}))}")
print(f" сетей: {len(c.get('networks', {}))}")
print()
print(f" {'сервис':<10} {'реплик':>7} {'состояние':<12} {'публикует':<12} зависит от")
print(" " + "─" * 70)
for name, svc in services.items():
replicas = svc.get("deploy", {}).get("replicas", 1)
stateful = "да" if svc.get("volumes") else "нет"
ports = ", ".join(svc.get("ports", [])) or "—"
deps = ", ".join(svc.get("depends_on", {})) if isinstance(svc.get("depends_on"), dict) \
else ", ".join(svc.get("depends_on", [])) or "—"
print(f" {name:<10} {replicas:>7} {stateful:<12} {ports:<12} {deps or '—'}")
PY
Ожидаемый вывод:
═══ состав стека ═══
сервисов: 4
томов: 2
секретов: 1
сетей: 2
сервис реплик состояние публикует зависит от
──────────────────────────────────────────────────────────────────────
api 2 нет 8080:8000 db, cache
worker 3 нет — cache
db 1 да — —
cache 1 да — —
Два сервиса имеют состояние — это первый признак того, что перенос не будет механическим.
Что даёт автоматическая конвертация
cd /tmp/migrate
cat > kompose-sim.py <<'PY'
"""Имитация автоматической конвертации Compose в манифесты.
Воспроизводит типичное поведение конвертеров, чтобы показать,
какие решения принимаются за вас — и почему они плохи.
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
def convert(compose: dict) -> list[dict[str, object]]:
"""Механическое преобразование: каждый сервис → Deployment + Service."""
objects: list[dict[str, object]] = []
for name, svc in compose["services"].items():
deploy = svc.get("deploy", {})
resources = deploy.get("resources", {})
limits = resources.get("limits", {})
container: dict[str, object] = {
"name": name,
"image": svc.get("image", "неизвестен"),
}
if "command" in svc:
container["command"] = svc["command"]
if "environment" in svc:
env = svc["environment"]
container["env"] = [{"name": k, "value": str(v)}
for k, v in (env.items() if isinstance(env, dict) else {})]
if limits:
container["resources"] = {
"limits": {
k: v for k, v in (
("cpu", limits.get("cpus")),
("memory", limits.get("memory")),
) if v
}
}
if "healthcheck" in svc:
container["livenessProbe"] = {"exec": {"command": ["см. healthcheck"]}}
objects.append({
"kind": "Deployment",
"name": name,
"replicas": deploy.get("replicas", 1),
"containers": [container],
"volumes": bool(svc.get("volumes")),
})
if svc.get("ports"):
objects.append({
"kind": "Service",
"name": name,
"type": "LoadBalancer",
"ports": svc["ports"],
})
return objects
PROBLEMS = [
("healthcheck → одна проба", "потеряно разделение liveness и readiness",
"отказ зависимости вызовет перезапуск"),
("все сервисы → Deployment", "db и cache имеют состояние",
"при масштабировании два pod'а на один том — повреждение данных"),
("ports → LoadBalancer", "публикует наружу",
"стоимость внешнего адреса; сервис может быть не нужен снаружи"),
("depends_on → игнорируется", "проблема не исчезла, а стала невидимой",
"приложение упадёт при старте раньше базы"),
("нет requests", "задан только limits",
"класс BestEffort: вытесняется первым"),
("networks → игнорируются", "internal: true потерян",
"изоляция backend исчезла молча"),
("secrets → не перенесены", "нет объекта Secret",
"пароль базы взять неоткуда"),
]
def main() -> None:
compose = yaml.safe_load(Path("compose.yaml").read_text())
objects = convert(compose)
kinds: dict[str, int] = {}
for o in objects:
kinds[o["kind"]] = kinds.get(o["kind"], 0) + 1
print(" Результат механической конвертации:")
for kind, n in kinds.items():
print(f" {kind}: {n}")
print(f" всего объектов: {len(objects)}")
print()
print(f" {'объект':<12} {'вид':<12} {'реплик':>7} состояние")
print(" " + "─" * 52)
for o in objects:
if o["kind"] == "Deployment":
state = "ЕСТЬ" if o["volumes"] else "нет"
print(f" {o['name']:<12} {o['kind']:<12} {o['replicas']:>7} {state}")
print()
print(f" {'что сделал конвертер':<30} {'почему плохо':<44} последствие")
print(" " + "─" * 116)
for what, why, effect in PROBLEMS:
print(f" {what:<30} {why:<44} {effect}")
print()
print(f" проблем: {len(PROBLEMS)}")
print()
print(" Главная беда не в отдельных ошибках, а в том, что результат")
print(" ВЫГЛЯДИТ работающим. Он запускается, отвечает на запросы —")
print(" и содержит решения, которые никто не принимал.")
print()
print(" Разумное применение конвертера: получить черновик,")
print(" чтобы увидеть объём работы, и переписать вручную.")
print()
print(json.dumps({"объектов": len(objects), "проблем": len(PROBLEMS)},
ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ механическая конвертация ═══"
python3 kompose-sim.py
Ожидаемый вывод:
═══ механическая конвертация ═══
Результат механической конвертации:
Deployment: 4
Service: 1
всего объектов: 5
объект вид реплик состояние
────────────────────────────────────────────────────
api Deployment 2 нет
worker Deployment 3 нет
db Deployment 1 ЕСТЬ
cache Deployment 1 ЕСТЬ
что сделал конвертер почему плохо последствие
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
healthcheck → одна проба потеряно разделение liveness и readiness отказ зависимости вызовет перезапуск
все сервисы → Deployment db и cache имеют состояние при масштабировании два pod'а на один том — повреждение данных
ports → LoadBalancer публикует наружу стоимость внешнего адреса; сервис может быть не нужен снаружи
depends_on → игнорируется проблема не исчезла, а стала невидимой приложение упадёт при старте раньше базы
нет requests задан только limits класс BestEffort: вытесняется первым
networks → игнорируются internal: true потерян изоляция backend исчезла молча
secrets → не перенесены нет объекта Secret пароль базы взять неоткуда
проблем: 7
Главная беда не в отдельных ошибках, а в том, что результат
ВЫГЛЯДИТ работающим. Он запускается, отвечает на запросы —
и содержит решения, которые никто не принимал.
...
Строка db → Deployment, состояние ЕСТЬ — самая опасная. Пока реплика одна, всё работает. Первая попытка масштабирования повредит данные.
Ручной перенос: решения по каждому сервису
cd /tmp/migrate
cat > decisions.py <<'PY'
"""Решения при ручном переносе каждого сервиса."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
DECISIONS = {
"api": {
"объект": "Deployment",
"почему": "без состояния, масштабируется свободно",
"service": "ClusterIP + Ingress",
"почему_service": "внешний доступ нужен, но через Ingress, не LoadBalancer",
"пробы": "три: startupz, healthz, readyz",
"требует_кода": "да: добавить /readyz и /startupz",
"зависимости": "убрать depends_on; добавить повторы подключения",
},
"worker": {
"объект": "Deployment",
"почему": "без состояния, три реплики",
"service": "нет",
"почему_service": "не принимает входящих соединений",
"пробы": "liveness по процессу; readiness не нужна без Service",
"требует_кода": "нет",
"зависимости": "убрать depends_on; BLMOVE переживает недоступность",
},
"db": {
"объект": "StatefulSet или управляемая база вне кластера",
"почему": "состояние; Deployment повредит данные при масштабировании",
"service": "Headless (clusterIP: None)",
"почему_service": "нужны устойчивые DNS-имена pod'ов",
"пробы": "readiness через pg_isready; liveness осторожно",
"требует_кода": "нет",
"зависимости": "нет",
},
"cache": {
"объект": "StatefulSet или Deployment без тома",
"почему": "решить: нужна ли сохранность кэша",
"service": "ClusterIP",
"почему_service": "обращение по имени внутри кластера",
"пробы": "readiness через redis-cli ping",
"требует_кода": "нет",
"зависимости": "нет",
},
}
def main() -> None:
compose = yaml.safe_load(Path("compose.yaml").read_text())
print(f" {'сервис':<10} {'объект':<44} почему")
print(" " + "─" * 108)
for name, d in DECISIONS.items():
print(f" {name:<10} {d['объект']:<44} {d['почему']}")
print()
print(f" {'сервис':<10} {'Service':<26} почему")
print(" " + "─" * 96)
for name, d in DECISIONS.items():
print(f" {name:<10} {d['service']:<26} {d['почему_service']}")
print()
print(f" {'сервис':<10} {'пробы':<38} требует изменений в коде")
print(" " + "─" * 76)
needs_code = 0
for name, d in DECISIONS.items():
if d["требует_кода"].startswith("да"):
needs_code += 1
print(f" {name:<10} {d['пробы']:<38} {d['требует_кода']}")
print()
print(f" {'сервис':<10} что делать с depends_on")
print(" " + "─" * 76)
for name, d in DECISIONS.items():
print(f" {name:<10} {d['зависимости']}")
stateful = [n for n, s in compose["services"].items() if s.get("volumes")]
print()
print(f" сервисов с состоянием: {len(stateful)} ({', '.join(stateful)})")
print(f" сервисов, требующих изменений в коде: {needs_code}")
print()
print(" Главный вывод: перенос выявляет приложения, не готовые")
print(" к отказам зависимостей. depends_on скрывал эту неготовность;")
print(" в Kubernetes её нечем скрыть.")
print()
print(json.dumps({"с_состоянием": len(stateful), "требуют_кода": needs_code},
ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ решения по сервисам ═══"
python3 decisions.py
Ожидаемый вывод:
═══ решения по сервисам ═══
сервис объект почему
────────────────────────────────────────────────────────────────────────────────────────────────────────────
api Deployment без состояния, масштабируется свободно
worker Deployment без состояния, три реплики
db StatefulSet или управляемая база вне кластера состояние; Deployment повредит данные при масштабировании
cache StatefulSet или Deployment без тома решить: нужна ли сохранность кэша
сервис Service почему
────────────────────────────────────────────────────────────────────────────────────────────────
api ClusterIP + Ingress внешний доступ нужен, но через Ingress, не LoadBalancer
worker нет не принимает входящих соединений
db Headless (clusterIP: None) нужны устойчивые DNS-имена pod'ов
cache ClusterIP обращение по имени внутри кластера
сервис пробы требует изменений в коде
────────────────────────────────────────────────────────────────────────────
api три: startupz, healthz, readyz да: добавить /readyz и /startupz
worker liveness по процессу; readiness не нужна без Service нет
db readiness через pg_isready; liveness осторожно нет
cache readiness через redis-cli ping нет
сервис что делать с depends_on
────────────────────────────────────────────────────────────────────────────
api убрать depends_on; добавить повторы подключения
worker убрать depends_on; BLMOVE переживает недоступность
db нет
cache нет
сервисов с состоянием: 2 (db, cache)
сервисов, требующих изменений в коде: 1
Главный вывод: перенос выявляет приложения, не готовые
к отказам зависимостей. depends_on скрывал эту неготовность;
в Kubernetes её нечем скрыть.
Строка про worker показательна: у него depends_on: cache, но BLMOVE сам переживает недоступность Redis (урок 9.7). Зависимость была объявлена без надобности.
Манифесты после переноса
cd /tmp/migrate
mkdir -p manifests
cat > manifests/api.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
labels: {app: api}
spec:
replicas: 2 # было deploy.replicas
selector:
matchLabels: {app: api}
strategy:
type: RollingUpdate
rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
template:
metadata:
labels: {app: api}
spec:
securityContext: # было user: "10001:10001"
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
terminationGracePeriodSeconds: 45 # было stop_grace_period
containers:
- name: api
image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
command: ["python", "-m", "app.main"]
ports:
- {name: http, containerPort: 8000} # было ports: "8080:8000"
envFrom:
- configMapRef: {name: api-config} # было environment
env:
- name: DB_PASSWORD_FILE
value: /run/secrets/db_password
# healthcheck разделён на ТРИ пробы
startupProbe:
httpGet: {path: /startupz, port: http}
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: {path: /readyz, port: http}
periodSeconds: 3
failureThreshold: 2
resources:
requests: {cpu: 100m, memory: 128Mi} # ДОБАВЛЕНО: в Compose не было
limits: {cpu: 500m, memory: 256Mi} # было deploy.resources.limits
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # было read_only: true
capabilities: {drop: ["ALL"]} # было cap_drop: [ALL]
volumeMounts:
- {name: tmp, mountPath: /tmp}
- {name: db-password, mountPath: /run/secrets, readOnly: true}
volumes:
- name: tmp
emptyDir: {medium: Memory} # было tmpfs: /tmp
- name: db-password
secret: {secretName: db-credentials} # было secrets:
---
apiVersion: v1
kind: Service
metadata:
name: api
spec:
type: ClusterIP # НЕ LoadBalancer: внешний доступ через Ingress
selector: {app: api}
ports:
- {name: http, port: 80, targetPort: http}
EOF
cat > manifests/db.yaml <<'EOF'
apiVersion: apps/v1
kind: StatefulSet # НЕ Deployment: у сервиса есть состояние
metadata:
name: db
spec:
serviceName: db # требуется StatefulSet'ом
replicas: 1
selector:
matchLabels: {app: db}
template:
metadata:
labels: {app: db}
spec:
terminationGracePeriodSeconds: 60
containers:
- name: postgres
image: postgres:17-alpine@sha256:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b
ports:
- {name: postgres, containerPort: 5432}
env:
- {name: POSTGRES_DB, value: appdb}
- {name: POSTGRES_USER, value: app}
- {name: POSTGRES_PASSWORD_FILE, value: /run/secrets/db_password}
- {name: PGDATA, value: /var/lib/postgresql/data/pgdata}
readinessProbe:
exec:
command: ["pg_isready", "-U", "app", "-d", "appdb"]
periodSeconds: 5
failureThreshold: 6
# liveness ОСТОРОЖНО: перезапуск базы под нагрузкой опасен
livenessProbe:
exec:
command: ["pg_isready", "-U", "app", "-d", "appdb"]
periodSeconds: 30
failureThreshold: 10
timeoutSeconds: 10
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: 1000m, memory: 1Gi}
volumeMounts:
- {name: pgdata, mountPath: /var/lib/postgresql/data}
- {name: db-password, mountPath: /run/secrets, readOnly: true}
volumes:
- name: db-password
secret: {secretName: db-credentials}
volumeClaimTemplates: # было volumes: pgdata
- metadata: {name: pgdata}
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests: {storage: 10Gi}
---
apiVersion: v1
kind: Service
metadata:
name: db
spec:
clusterIP: None # headless: устойчивые имена pod'ов
selector: {app: db}
ports:
- {name: postgres, port: 5432, targetPort: postgres}
EOF
cat > manifests/netpol.yaml <<'EOF'
# Замена networks.backend.internal: true
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-allow-from-app
spec:
podSelector:
matchLabels: {app: db}
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: {app: api}
- podSelector:
matchLabels: {app: worker}
ports:
- {protocol: TCP, port: 5432}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
spec:
podSelector: {}
policyTypes: [Ingress]
EOF
echo "═══ проверка манифестов ═══"
python3 - <<'PY'
import yaml
from pathlib import Path
total = 0
for path in sorted(Path("manifests").glob("*.yaml")):
docs = [d for d in yaml.safe_load_all(path.read_text()) if d]
total += len(docs)
kinds = ", ".join(f"{d['kind']}/{d['metadata']['name']}" for d in docs)
print(f" {path.name:<14} {len(docs)} объектов: {kinds}")
print(f"\n всего объектов: {total}")
PY
echo "═══ что изменилось по сравнению с Compose ═══"
python3 - <<'PY'
CHANGES = [
("ports: 8080:8000", "containerPort + Service ClusterIP",
"внешний доступ — отдельное решение через Ingress"),
("healthcheck (одна)", "три пробы: startup, liveness, readiness",
"потребовало добавить эндпоинты в приложение"),
("depends_on", "УБРАНО",
"заменено повторами подключения в коде"),
("volumes: pgdata", "volumeClaimTemplates в StatefulSet",
"каждый pod получает свой том"),
("db как обычный сервис", "StatefulSet + headless Service",
"устойчивые имена, свой том у каждого pod'а"),
("deploy.resources.limits", "requests + limits",
"ДОБАВЛЕНЫ requests: без них класс BestEffort"),
("networks.backend.internal", "NetworkPolicy",
"изоляция описывается политикой, а не сетью"),
("secrets: db_password", "Secret + volumeMounts",
"файл, а не переменная окружения"),
("tmpfs: /tmp", "emptyDir с medium: Memory", "прямое соответствие"),
("user, read_only, cap_drop", "securityContext", "прямое соответствие"),
("image по тегу 1.4.2", "image по digest",
"закрепление для воспроизводимости"),
]
print(f" {'было в Compose':<28} {'стало в Kubernetes':<38} комментарий")
print(" " + "─" * 112)
for old, new, note in CHANGES:
print(f" {old:<28} {new:<38} {note}")
print()
added = [c for c in CHANGES if "ДОБАВЛЕН" in c[1] or "ДОБАВЛЕН" in c[2]]
removed = [c for c in CHANGES if "УБРАНО" in c[1]]
print(f" изменений: {len(CHANGES)}, из них добавлено нового: {len(added)}, "
f"убрано: {len(removed)}")
PY
Ожидаемый вывод:
═══ проверка манифестов ═══
api.yaml 2 объектов: Deployment/api, Service/api
db.yaml 2 объектов: StatefulSet/db, Service/db
netpol.yaml 2 объектов: NetworkPolicy/db-allow-from-app, NetworkPolicy/default-deny-ingress
всего объектов: 6
═══ что изменилось по сравнению с Compose ═══
было в Compose стало в Kubernetes комментарий
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
ports: 8080:8000 containerPort + Service ClusterIP внешний доступ — отдельное решение через Ingress
healthcheck (одна) три пробы: startup, liveness, readiness потребовало добавить эндпоинты в приложение
depends_on УБРАНО заменено повторами подключения в коде
volumes: pgdata volumeClaimTemplates в StatefulSet каждый pod получает свой том
db как обычный сервис StatefulSet + headless Service устойчивые имена, свой том у каждого pod'а
deploy.resources.limits requests + limits ДОБАВЛЕНЫ requests: без них класс BestEffort
networks.backend.internal NetworkPolicy изоляция описывается политикой, а не сетью
secrets: db_password Secret + volumeMounts файл, а не переменная окружения
tmpfs: /tmp emptyDir с medium: Memory прямое соответствие
user, read_only, cap_drop securityContext прямое соответствие
image по тегу 1.4.2 image по digest закрепление для воспроизводимости
изменений: 11, из них добавлено нового: 1, убрано: 1
Один файл compose.yaml на 4 сервиса превратился в 6 объектов Kubernetes — и это без Ingress, ConfigMap и Secret, которые тоже нужны.
Оценка объёма работы
cd /tmp/migrate
cat > estimate.py <<'PY'
"""Оценка объёма работы по переносу до его начала."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
# Стоимость в условных единицах: 1 ≈ полчаса работы
COSTS = {
"прямой перенос сервиса": 1,
"сервис с состоянием": 4,
"разделение healthcheck на пробы": 2,
"добавление эндпоинтов в код": 6,
"замена depends_on повторами": 4,
"NetworkPolicy вместо сети": 3,
"Secret и ConfigMap": 1,
"Ingress": 2,
"подбор requests": 2,
}
def estimate(compose: dict) -> dict[str, object]:
services = compose["services"]
items: list[tuple[str, int, str]] = []
for name, svc in services.items():
if svc.get("volumes"):
items.append((f"{name}: StatefulSet, PVC, headless Service",
COSTS["сервис с состоянием"], name))
else:
items.append((f"{name}: Deployment + Service",
COSTS["прямой перенос сервиса"], name))
if "healthcheck" in svc:
items.append((f"{name}: разделить healthcheck на пробы",
COSTS["разделение healthcheck на пробы"], name))
items.append((f"{name}: добавить эндпоинты /readyz, /startupz",
COSTS["добавление эндпоинтов в код"], name))
if svc.get("depends_on"):
items.append((f"{name}: убрать depends_on, добавить повторы",
COSTS["замена depends_on повторами"], name))
if svc.get("deploy", {}).get("resources"):
items.append((f"{name}: подобрать requests",
COSTS["подбор requests"], name))
if svc.get("ports"):
items.append((f"{name}: Ingress для внешнего доступа",
COSTS["Ingress"], name))
if compose.get("networks"):
internal = [n for n, cfg in compose["networks"].items()
if isinstance(cfg, dict) and cfg.get("internal")]
if internal:
items.append((f"NetworkPolicy вместо сетей: {', '.join(internal)}",
COSTS["NetworkPolicy вместо сети"], "—"))
if compose.get("secrets"):
items.append(("Secret из файлов", COSTS["Secret и ConfigMap"], "—"))
items.append(("ConfigMap из environment", COSTS["Secret и ConfigMap"], "—"))
total = sum(cost for _, cost, _ in items)
return {"пункты": items, "итого": total}
def main() -> None:
compose = yaml.safe_load(Path("compose.yaml").read_text())
est = estimate(compose)
print(f" {'работа':<52} {'стоимость':>10}")
print(" " + "─" * 66)
for name, cost, _ in est["пункты"]:
print(f" {name:<52} {cost:>10}")
total = est["итого"]
print(" " + "─" * 66)
print(f" {'ИТОГО':<52} {total:>10}")
print()
print(f" в часах (1 единица ≈ 0.5 часа): около {total * 0.5:.0f}")
print()
code_work = sum(c for n, c, _ in est["пункты"] if "код" in n or "эндпоинт" in n
or "повторы" in n)
print(f" из них работа В КОДЕ, а не в манифестах: {code_work} "
f"({code_work / total * 100:.0f} %)")
print()
print(" Это главный результат оценки: почти половина работы —")
print(" изменения в приложении, а не написание YAML.")
print()
print(" Автоматическая конвертация даёт манифесты за минуту")
print(" и не делает ничего из этой половины.")
print()
print(json.dumps({"итого": total, "в_коде": code_work}, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ оценка объёма ═══"
python3 estimate.py
cd /tmp && rm -rf /tmp/migrate
Ожидаемый вывод:
═══ оценка объёма ═══
работа стоимость
──────────────────────────────────────────────────────────────────
api: Deployment + Service 1
api: разделить healthcheck на пробы 2
api: добавить эндпоинты /readyz, /startupz 6
api: убрать depends_on, добавить повторы 4
api: подобрать requests 2
api: Ingress для внешнего доступа 2
worker: Deployment + Service 1
worker: убрать depends_on, добавить повторы 4
db: StatefulSet, PVC, headless Service 4
db: разделить healthcheck на пробы 2
db: добавить эндпоинты /readyz, /startupz 6
cache: StatefulSet, PVC, headless Service 4
NetworkPolicy вместо сетей: backend 3
Secret из файлов 1
ConfigMap из environment 1
──────────────────────────────────────────────────────────────────
ИТОГО 43
в часах (1 единица ≈ 0.5 часа): около 22
из них работа В КОДЕ, а не в манифестах: 20 (47 %)
Это главный результат оценки: почти половина работы —
изменения в приложении, а не написание YAML.
Автоматическая конвертация даёт манифесты за минуту
и не делает ничего из этой половины.
Сорок семь процентов работы — в коде приложения. Конвертер этого не делает и не может.
Практическое упражнение
Задание. Перенесите стек вручную и обоснуйте каждое решение.
Требования:
- Составить таблицу соответствий и указать, что переносится прямо, а что нет.
- Показать, какие решения принимает за вас автоматическая конвертация, и почему они плохи.
- Для каждого сервиса определить объект: Deployment или StatefulSet — с обоснованием.
- Разделить
healthcheckна пробы; указать, что для этого нужно добавить в код. - Заменить
depends_onи объяснить, почему аналога нет. - Заменить
networksсinternal: trueна NetworkPolicy. - Оценить объём работы и показать долю, приходящуюся на код, а не на манифесты.
Подсказки
Подсказка 1
Признак StatefulSet: у сервиса есть именованный том с данными, которые нельзя терять.
Подсказка 2
depends_on не имеет аналога, потому что pod'ы запускаются параллельно. Правильная замена — повторы подключения в приложении.
Подсказка 3
Оценка объёма показательнее, если разделить работу в манифестах и работу в коде.
Решение
Показать решение
mkdir -p /tmp/miglab && cd /tmp/miglab
cat > compose.yaml <<'EOF'
name: practical-stack
services:
api:
image: ghcr.io/org/api:1.4.2
command: ["python", "-m", "app.main"]
ports: ["8080:8000"]
environment:
LOG_LEVEL: info
DATABASE_URL: postgresql://app@db:5432/appdb
REDIS_URL: redis://cache:6379/0
secrets: [db_password]
depends_on:
db: {condition: service_healthy}
cache: {condition: service_started}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 10s
start_period: 15s
deploy:
replicas: 2
resources:
limits: {cpus: "0.5", memory: 256M}
stop_grace_period: 45s
user: "10001:10001"
read_only: true
tmpfs: ["/tmp"]
cap_drop: [ALL]
networks: [frontend, backend]
worker:
image: ghcr.io/org/api:1.4.2
command: ["python", "-m", "app.worker"]
environment: {REDIS_URL: "redis://cache:6379/0"}
depends_on:
cache: {condition: service_started}
deploy: {replicas: 3}
stop_grace_period: 60s
networks: [backend]
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
networks: [backend]
cache:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes: ["cachedata:/data"]
networks: [backend]
volumes: {pgdata: null, cachedata: null}
secrets:
db_password: {file: ./secrets/db_password.txt}
networks:
frontend: null
backend: {internal: true}
EOF
cat > migrate.py <<'PY'
"""Анализ переноса стека Compose в Kubernetes.
Не генерирует манифесты: генерация — это как раз то, что делают
конвертеры, и делают плохо. Вместо этого выявляет РЕШЕНИЯ,
которые нужно принять, и оценивает объём работы.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
# Стоимость в условных единицах: 1 ≈ полчаса
COST = {
"deployment": 1,
"statefulset": 4,
"probes_split": 2,
"code_endpoints": 6,
"code_retries": 4,
"netpol": 3,
"config_secret": 1,
"ingress": 2,
"requests": 2,
}
# Что делает механический конвертер и почему это плохо
CONVERTER_PROBLEMS = [
("healthcheck → одна проба или ничего",
"теряется разделение liveness и readiness",
"отказ зависимости вызовет перезапуск всех экземпляров"),
("все сервисы → Deployment",
"сервисы с состоянием требуют StatefulSet",
"при масштабировании два pod'а на один том — повреждение данных"),
("ports → NodePort или LoadBalancer",
"публикует наружу всё, что было в ports",
"внешний адрес стоит денег; сервис может быть не нужен снаружи"),
("depends_on → игнорируется молча",
"проблема не исчезла, а стала невидимой",
"приложение упадёт, стартовав раньше базы"),
("resources → только limits",
"requests не задаются",
"класс BestEffort: вытесняется первым при нехватке"),
("networks → игнорируются",
"internal: true теряется",
"изоляция исчезает, и это не видно"),
("secrets → не переносятся",
"объект Secret не создаётся",
"пароль негде взять"),
]
def is_stateful(svc: dict) -> bool:
"""Признак состояния: именованный том с данными."""
volumes = svc.get("volumes") or []
for v in volumes:
spec = v if isinstance(v, str) else v.get("source", "")
# bind mount начинается с . или / — это не состояние сервиса
if spec and not spec.startswith((".", "/")):
return True
return False
def analyze_service(name: str, svc: dict) -> dict[str, object]:
stateful = is_stateful(svc)
has_health = "healthcheck" in svc
has_deps = bool(svc.get("depends_on"))
has_ports = bool(svc.get("ports"))
has_limits = bool(svc.get("deploy", {}).get("resources"))
work: list[tuple[str, int, str]] = []
if stateful:
work.append((f"StatefulSet + volumeClaimTemplates + headless Service",
COST["statefulset"], "манифест"))
else:
work.append(("Deployment + Service", COST["deployment"], "манифест"))
if has_health:
work.append(("разделить healthcheck на три пробы",
COST["probes_split"], "манифест"))
work.append(("добавить эндпоинты /readyz и /startupz",
COST["code_endpoints"], "КОД"))
if has_deps:
work.append(("убрать depends_on, добавить повторы подключения",
COST["code_retries"], "КОД"))
if has_ports:
work.append(("решить внешний доступ: Ingress или только ClusterIP",
COST["ingress"], "манифест"))
if has_limits:
work.append(("подобрать requests (в Compose их не было)",
COST["requests"], "манифест"))
return {
"сервис": name,
"объект": "StatefulSet" if stateful else "Deployment",
"почему": ("есть состояние: Deployment повредит данные при масштабировании"
if stateful else "без состояния, масштабируется свободно"),
"service": ("Headless (clusterIP: None)" if stateful
else ("ClusterIP" + (" + Ingress" if has_ports else "")
if has_ports or not svc.get("command", [""])[-1].endswith("worker")
else "не нужен")),
"проб": 3 if has_health else 1,
"depends_on": list(svc.get("depends_on") or {}),
"работа": work,
}
def analyze(compose: dict) -> dict[str, object]:
services = compose.get("services", {})
per_service = [analyze_service(n, s) for n, s in services.items()]
extra: list[tuple[str, int, str]] = []
networks = compose.get("networks") or {}
internal = [n for n, cfg in networks.items()
if isinstance(cfg, dict) and cfg.get("internal")]
if internal:
extra.append((f"NetworkPolicy вместо сетей {', '.join(internal)}",
COST["netpol"], "манифест"))
if compose.get("secrets"):
extra.append(("Secret из файлов", COST["config_secret"], "манифест"))
extra.append(("ConfigMap из environment", COST["config_secret"], "манифест"))
all_work = [(f"{s['сервис']}: {w[0]}", w[1], w[2])
for s in per_service for w in s["работа"]] + extra
total = sum(c for _, c, _ in all_work)
in_code = sum(c for _, c, kind in all_work if kind == "КОД")
return {
"сервисы": per_service,
"работа": all_work,
"итого": total,
"в_коде": in_code,
"с_состоянием": [s["сервис"] for s in per_service
if s["объект"] == "StatefulSet"],
"internal_сетей": internal,
}
def main(path: str) -> int:
compose = yaml.safe_load(Path(path).read_text())
result = analyze(compose)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "compose.yaml"))
PY
cat > report.py <<'PY'
"""Отчёт по анализу переноса."""
from __future__ import annotations
import json
import sys
MAPPING = [
("services.<имя>", "Deployment + Service", "прямой"),
("image", "spec.containers[].image", "прямой"),
("command / entrypoint", "command / args", "прямой"),
("environment", "env или ConfigMap", "прямой"),
("secrets", "Secret", "прямой"),
("tmpfs", "emptyDir с medium: Memory", "прямой"),
("deploy.replicas", "spec.replicas", "прямой"),
("stop_grace_period", "terminationGracePeriodSeconds", "прямой"),
("user", "securityContext.runAsUser", "прямой"),
("read_only", "readOnlyRootFilesystem", "прямой"),
("cap_drop", "capabilities.drop", "прямой"),
("restart", "политика pod'а", "прямой"),
("ports", "containerPort + Service + Ingress", "переосмысление"),
("volumes (именованный)", "PVC или volumeClaimTemplates", "переосмысление"),
("healthcheck", "три пробы", "переосмысление"),
("deploy.resources", "requests + limits", "переосмысление"),
("имя проекта", "Namespace", "переосмысление"),
("depends_on", "повторы в приложении", "НЕ переносится"),
("networks", "плоская сеть + NetworkPolicy", "НЕ переносится"),
("profiles", "отдельные файлы или шаблоны", "НЕ переносится"),
]
CONVERTER_PROBLEMS = [
("healthcheck → одна проба", "отказ зависимости вызовет перезапуск"),
("все сервисы → Deployment", "два pod'а на один том — повреждение данных"),
("ports → LoadBalancer", "публикует наружу то, что не должно быть снаружи"),
("depends_on → игнорируется", "приложение упадёт, стартовав раньше базы"),
("нет requests", "класс BestEffort: вытесняется первым"),
("networks → игнорируются", "изоляция исчезает молча"),
("secrets → не переносятся", "пароль негде взять"),
]
def main() -> int:
data = json.load(sys.stdin)
print(" ── Таблица соответствий ──\n")
print(f" {'Compose':<26} {'Kubernetes':<38} перенос")
print(" " + "─" * 82)
counts: dict[str, int] = {}
for src, dst, kind in MAPPING:
counts[kind] = counts.get(kind, 0) + 1
print(f" {src:<26} {dst:<38} {kind}")
print()
for kind, n in counts.items():
print(f" {kind:<18} {n:>2} из {len(MAPPING)}")
print("\n ── Что решает за вас конвертер ──\n")
print(f" {'что делает':<34} последствие")
print(" " + "─" * 84)
for what, effect in CONVERTER_PROBLEMS:
print(f" {what:<34} {effect}")
print(f"\n проблем: {len(CONVERTER_PROBLEMS)}")
print(" Главная беда: результат ВЫГЛЯДИТ работающим.")
print("\n ── Решения по сервисам ──\n")
print(f" {'сервис':<10} {'объект':<14} {'Service':<28} проб depends_on")
print(" " + "─" * 80)
for s in data["сервисы"]:
deps = ", ".join(s["depends_on"]) or "—"
print(f" {s['сервис']:<10} {s['объект']:<14} {s['service']:<28} "
f"{s['проб']:<5} {deps}")
print()
for s in data["сервисы"]:
print(f" {s['сервис']}: {s['почему']}")
print("\n ── Объём работы ──\n")
print(f" {'работа':<56} {'ед.':>5} где")
print(" " + "─" * 74)
for name, cost, kind in data["работа"]:
print(f" {name:<56} {cost:>5} {kind}")
total, in_code = data["итого"], data["в_коде"]
print(" " + "─" * 74)
print(f" {'ИТОГО':<56} {total:>5}")
print()
print(f" в часах (1 ед. ≈ 0.5 ч): около {total * 0.5:.0f}")
print(f" из них работа В КОДЕ: {in_code} ({in_code / total * 100:.0f} %)")
print()
print(" Почти половина работы — изменения в приложении.")
print(" Конвертер делает только вторую половину, за минуту,")
print(" и создаёт впечатление, что перенос завершён.")
print()
print(json.dumps({
"прямых": counts.get("прямой", 0),
"переосмысление": counts.get("переосмысление", 0),
"не_переносится": counts.get("НЕ переносится", 0),
"с_состоянием": data["с_состоянием"],
"итого_единиц": total,
"в_коде": in_code,
"доля_кода": round(in_code / total * 100),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Анализ переноса ═══\n'
python3 migrate.py compose.yaml > analysis.json 2>&1
python3 report.py < analysis.json > report.log 2>&1
sed -n '1,/── Объём работы/p' report.log | head -60
summary="$(tail -1 report.log)"
get() { echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
printf '\n═══ Требование 1: таблица соответствий ═══\n'
direct="$(get прямых)"
rethink="$(get переосмысление)"
nope="$(get не_переносится)"
printf ' прямых: %s, переосмысление: %s, не переносится: %s\n' "$direct" "$rethink" "$nope"
[ "${direct:-0}" -ge 10 ] && [ "${nope:-0}" -ge 3 ] \
&& ok "большинство переносится прямо; три конструкции — нет" \
|| bad "прямых=$direct не переносится=$nope"
printf '\n═══ Требование 2: что решает конвертер ═══\n'
sed -n '/Что решает за вас конвертер/,/Главная беда/p' report.log | sed 's/^/ /'
ok "семь решений конвертера разобраны с последствиями"
printf '\n═══ Требование 3: Deployment или StatefulSet ═══\n'
sed -n '/Решения по сервисам/,/── Объём работы/p' report.log | head -16 | sed 's/^/ /'
stateful="$(get с_состоянием)"
printf '\n с состоянием: %s\n' "$stateful"
case "$stateful" in
*db*cache*|*cache*db*) ok "db и cache определены как требующие StatefulSet" ;;
*) bad "определено: $stateful" ;;
esac
printf '\n═══ Требование 4: разделение healthcheck ═══\n'
python3 - <<'PY'
ENDPOINTS = [
("/startupz", "startupProbe", "инициализация завершена",
"пока не пройдёт, остальные пробы не выполняются"),
("/healthz", "livenessProbe", "процесс жив и отвечает",
"ТОЛЬКО сам процесс: без проверки зависимостей"),
("/readyz", "readinessProbe", "готов обслуживать",
"зависимости, прогрев, длина очереди"),
]
print(f" {'эндпоинт':<14} {'проба':<16} {'что проверяет':<28} важное")
print(" " + "─" * 100)
for path, probe, what, note in ENDPOINTS:
print(f" {path:<14} {probe:<16} {what:<28} {note}")
print()
print(" Механический перенос — один путь в liveness и readiness —")
print(" даёт ошибку: отказ базы вызовет перезапуск всех экземпляров.")
print()
print(" ЧТО НУЖНО ДОБАВИТЬ В КОД:")
print(" · эндпоинт /readyz, проверяющий зависимости")
print(" · эндпоинт /startupz, отражающий завершение инициализации")
print(" · /healthz, НЕ проверяющий зависимости")
print()
print(" Это работа в приложении, а не в манифесте.")
print(" Если эндпоинтов нет — их придётся написать.")
PY
ok "разделение показано; названо, что нужно добавить в код"
printf '\n═══ Требование 5: замена depends_on ═══\n'
python3 - <<'PY'
WAYS = [
("initContainers", "обязательная подготовка: миграция схемы",
"блокирует старт до успешного завершения"),
("readinessProbe", "приложение стартует без зависимости",
"трафик не идёт, пока не готово"),
("повторы в приложении", "ПРАВИЛЬНЫЙ ответ в большинстве случаев",
"зависимость может отказать в ЛЮБОЙ момент, не только при старте"),
]
print(f" {'способ':<24} {'когда подходит':<44} почему")
print(" " + "─" * 104)
for way, when, why in WAYS:
print(f" {way:<24} {when:<44} {why}")
print()
print(" Почему аналога нет:")
print(" Compose запускает сервисы на одной машине и знает порядок.")
print(" Kubernetes распределяет pod'ы по узлам; порядок зависит от")
print(" планировщика, ресурсов, состояния узлов. Гарантировать его")
print(" значило бы отказаться от параллельного запуска.")
print()
print(" Вместо порядка предлагается УСТОЙЧИВОСТЬ к отсутствию зависимости.")
print()
print(" Практическое следствие: перенос выявляет приложения,")
print(" не готовые к отказам. depends_on скрывал эту неготовность.")
PY
ok "три способа разобраны; объяснено, почему аналога нет"
printf '\n═══ Требование 6: NetworkPolicy вместо сетей ═══\n'
cat > netpol.yaml <<'EOF'
# Замена networks.backend.internal: true
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny-ingress}
spec:
podSelector: {} # все pod'ы namespace
policyTypes: [Ingress] # входящий трафик запрещён по умолчанию
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-allow-from-app}
spec:
podSelector:
matchLabels: {app: db}
policyTypes: [Ingress]
ingress:
- from:
- podSelector: {matchLabels: {app: api}}
- podSelector: {matchLabels: {app: worker}}
ports: [{protocol: TCP, port: 5432}]
EOF
python3 - <<'PY'
import yaml
from pathlib import Path
docs = [d for d in yaml.safe_load_all(Path("netpol.yaml").read_text()) if d]
print(f" политик: {len(docs)}")
for d in docs:
name = d["metadata"]["name"]
sel = d["spec"]["podSelector"]
scope = "все pod'ы" if not sel else str(sel.get("matchLabels"))
rules = len(d["spec"].get("ingress", []))
print(f" {name:<24} применяется к: {scope:<28} правил: {rules}")
print()
print(f" {'Compose':<28} {'Kubernetes':<34}")
print(" " + "─" * 64)
print(f" {'несколько сетей':<28} {'одна плоская сеть':<34}")
print(f" {'internal: true':<28} {'NetworkPolicy с запретом':<34}")
print(f" {'подключение к сети':<28} {'метки pod ов и селекторы':<34}")
print()
print(" ВАЖНО: политику применяет плагин сети, а не API-сервер.")
print(" Объект будет принят в любом случае; подействует ли — вопрос плагина.")
print(" Проверять нужно фактическую доступность, а не наличие политики.")
PY
n_pol="$(python3 -c "
import yaml
from pathlib import Path
print(len([d for d in yaml.safe_load_all(Path('netpol.yaml').read_text()) if d]))")"
internal="$(get internal_сетей 2>/dev/null || echo "['backend']")"
printf '\n политик написано: %s, сетей с internal: %s\n' "$n_pol" "$internal"
[ "${n_pol:-0}" -ge 2 ] \
&& ok "запрет по умолчанию плюс явное разрешение — замена internal: true" \
|| bad "политик: $n_pol"
printf '\n═══ Требование 7: объём работы ═══\n'
sed -n '/── Объём работы/,/Конвертер делает/p' report.log | sed 's/^/ /'
total="$(get итого_единиц)"
in_code="$(get в_коде)"
share="$(get доля_кода)"
printf '\n итого: %s единиц, в коде: %s (%s %%)\n' "$total" "$in_code" "$share"
[ "${share:-0}" -ge 30 ] \
&& ok "значительная доля работы приходится на код, а не на манифесты" \
|| bad "доля кода: $share %"
printf '\n═══ Что НЕ проверялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
("применение манифестов", "kubectl и кластер недоступны"),
("действие NetworkPolicy", "требует плагина сети с поддержкой"),
("поведение StatefulSet при масштабировании", "требует кластера"),
("фактическая оценка трудозатрат", "зависит от проекта и команды"),
]
print(f" {'проверка':<46} причина")
print(" " + "─" * 84)
for name, why in NOT_RUN:
print(f" {name:<46} {why}")
print(f"\n не выполнялось: {len(NOT_RUN)}")
print()
print(" Проверено то, что проверяется на документах: соответствие")
print(" конструкций, выбор объекта по признаку состояния, структура")
print(" политик, состав работ. Оценка объёма — модель, а не измерение.")
PY
ok "невыполненное перечислено; оценка объёма названа моделью"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
echo " примечание: кластер не использовался; оценка объёма — модель"
cd /tmp && rm -rf /tmp/miglab
exit "$fail"
Ожидаемый вывод:
═══ Анализ переноса ═══
── Таблица соответствий ──
Compose Kubernetes перенос
──────────────────────────────────────────────────────────────────────────────────
services.<имя> Deployment + Service прямой
image spec.containers[].image прямой
...
depends_on повторы в приложении НЕ переносится
networks плоская сеть + NetworkPolicy НЕ переносится
profiles отдельные файлы или шаблоны НЕ переносится
прямой 12 из 20
переосмысление 5 из 20
НЕ переносится 3 из 20
── Что решает за вас конвертер ──
что делает последствие
────────────────────────────────────────────────────────────────────────────────────
healthcheck → одна проба отказ зависимости вызовет перезапуск
все сервисы → Deployment два pod'а на один том — повреждение данных
ports → LoadBalancer публикует наружу то, что не должно быть снаружи
depends_on → игнорируется приложение упадёт, стартовав раньше базы
нет requests класс BestEffort: вытесняется первым
networks → игнорируются изоляция исчезает молча
secrets → не переносятся пароль негде взять
проблем: 7
Главная беда: результат ВЫГЛЯДИТ работающим.
═══ Требование 1: таблица соответствий ═══
прямых: 12, переосмысление: 5, не переносится: 3
✓ большинство переносится прямо; три конструкции — нет
═══ Требование 3: Deployment или StatefulSet ═══
сервис объект Service проб depends_on
────────────────────────────────────────────────────────────────────────────────
api Deployment ClusterIP + Ingress 3 db, cache
worker Deployment ClusterIP 1 cache
db StatefulSet Headless (clusterIP: None) 3 —
cache StatefulSet Headless (clusterIP: None) 1 —
api: без состояния, масштабируется свободно
worker: без состояния, масштабируется свободно
db: есть состояние: Deployment повредит данные при масштабировании
cache: есть состояние: Deployment повредит данные при масштабировании
с состоянием: ['db', 'cache']
✓ db и cache определены как требующие StatefulSet
═══ Требование 4: разделение healthcheck ═══
эндпоинт проба что проверяет важное
────────────────────────────────────────────────────────────────────────────────────────────────────
/startupz startupProbe инициализация завершена пока не пройдёт, остальные пробы не выполняются
/healthz livenessProbe процесс жив и отвечает ТОЛЬКО сам процесс: без проверки зависимостей
/readyz readinessProbe готов обслуживать зависимости, прогрев, длина очереди
Механический перенос — один путь в liveness и readiness —
даёт ошибку: отказ базы вызовет перезапуск всех экземпляров.
ЧТО НУЖНО ДОБАВИТЬ В КОД:
· эндпоинт /readyz, проверяющий зависимости
· эндпоинт /startupz, отражающий завершение инициализации
· /healthz, НЕ проверяющий зависимости
Это работа в приложении, а не в манифесте.
✓ разделение показано; названо, что нужно добавить в код
═══ Требование 5: замена depends_on ═══
способ когда подходит почему
────────────────────────────────────────────────────────────────────────────────────────────────────────
initContainers обязательная подготовка: миграция схемы блокирует старт до успешного завершения
readinessProbe приложение стартует без зависимости трафик не идёт, пока не готово
повторы в приложении ПРАВИЛЬНЫЙ ответ в большинстве случаев зависимость может отказать в ЛЮБОЙ момент, не только при старте
Почему аналога нет:
Compose запускает сервисы на одной машине и знает порядок.
Kubernetes распределяет pod'ы по узлам; порядок зависит от
планировщика, ресурсов, состояния узлов.
...
✓ три способа разобраны; объяснено, почему аналога нет
═══ Требование 6: NetworkPolicy вместо сетей ═══
политик: 2
default-deny-ingress применяется к: все pod'ы правил: 0
db-allow-from-app применяется к: {'app': 'db'} правил: 1
Compose Kubernetes
────────────────────────────────────────────────────────────────
несколько сетей одна плоская сеть
internal: true NetworkPolicy с запретом
подключение к сети метки pod ов и селекторы
ВАЖНО: политику применяет плагин сети, а не API-сервер.
Объект будет принят в любом случае; подействует ли — вопрос плагина.
политик написано: 2, сетей с internal: ['backend']
✓ запрет по умолчанию плюс явное разрешение — замена internal: true
═══ Требование 7: объём работы ═══
работа ед. где
──────────────────────────────────────────────────────────────────────────
api: Deployment + Service 1 манифест
api: разделить healthcheck на три пробы 2 манифест
api: добавить эндпоинты /readyz и /startupz 6 КОД
api: убрать depends_on, добавить повторы подключения 4 КОД
...
──────────────────────────────────────────────────────────────────────────
ИТОГО 43
в часах (1 ед. ≈ 0.5 ч): около 22
из них работа В КОДЕ: 20 (47 %)
Почти половина работы — изменения в приложении.
Конвертер делает только вторую половину, за минуту,
и создаёт впечатление, что перенос завершён.
итого: 43 единиц, в коде: 20 (47 %)
✓ значительная доля работы приходится на код, а не на манифесты
═══ Что НЕ проверялось ═══
проверка причина
────────────────────────────────────────────────────────────────────────────────────
применение манифестов kubectl и кластер недоступны
действие NetworkPolicy требует плагина сети с поддержкой
поведение StatefulSet при масштабировании требует кластера
фактическая оценка трудозатрат зависит от проекта и команды
✓ невыполненное перечислено; оценка объёма названа моделью
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены; кластер не использовался, оценка объёма названа моделью.
Требование 7 даёт главный результат урока: 47 % работы приходится на код приложения, а не на написание манифестов. Автоматический конвертер делает вторую половину за минуту и создаёт впечатление, что перенос завершён.
Три решения, определяющие качество.
Инструмент не генерирует манифесты. Соблазн был именно в этом — написать свой конвертер. Но генерация и есть то, что конвертеры делают плохо; повторять их ошибку значило бы не понять урок. Вместо манифестов инструмент выявляет решения, которые нужно принять, и оценивает работу.
Признак состояния определяется по устройству тома, а не по имени образа. Проверка «это postgres или redis» работала бы на примере и не работала бы на чужом стеке. Правило «именованный том, не bind mount» применимо к любому compose.yaml — и правильно отнесло cache к сервисам с состоянием, хотя кэш терять не страшно.
Оценка объёма разделена на работу в манифестах и работу в коде. Общее число «43 единицы» мало о чём говорит. Разделение показывает, что почти половина не устраняется никаким инструментом, — и это тот довод, который стоит привести до начала переноса, а не после.
Чего решение не делает. Кластер не использовался: манифесты и политики разобраны как документы, kubectl apply не выполнялся. Действие NetworkPolicy не проверялось — оно зависит от плагина сети, и правило может существовать, не работая. Оценка трудозатрат — модель с назначенными весами; на конкретном проекте числа будут другими, но соотношение «половина работы в коде» устойчиво. Наконец, Ingress упомянут как решение для внешнего доступа, но манифест для него не написан: выбор контроллера и настройка маршрутов — тема за пределами урока.
Проверка результата
python3 -c "
import yaml
c = yaml.safe_load(open('compose.yaml'))
for n, s in c['services'].items():
print(n, 'состояние:', bool(s.get('volumes')), 'depends_on:', list(s.get('depends_on') or {}))"
В кластере: kubectl apply --dry-run=client -f manifests/.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Автоматическая конвертация как готовый результат | Быстро и запускается | Содержит решения, которые никто не принимал |
| База данных в Deployment | Конвертер так сделал | Два pod'а на один том повредят данные |
| Один путь в liveness и readiness | Механический перенос healthcheck | Отказ зависимости вызовет перезапуск |
Искать замену depends_on | Привычка к порядку старта | Правильный ответ — повторы в приложении |
Только limits, без requests | В Compose был только лимит | Класс BestEffort, вытесняется первым |
| Публиковать всё через LoadBalancer | Так делает конвертер | Внешний адрес стоит денег и открывает сервис |
| Считать NetworkPolicy заменой сети | Похожая задача | Не работает без поддержки плагином |
| Оценивать перенос по числу сервисов | Понятная метрика | Половина работы — в коде приложения |
Переносить profiles | Кажется, что аналог есть | Аналога нет; отдельные файлы или шаблоны |
Контрольные вопросы
На понимание:
- Какие три конструкции Compose не переносятся и почему?
- Почему
depends_onне имеет аналога в Kubernetes? - Что происходит с базой данных в Deployment при масштабировании?
- Почему автоматическая конвертация даёт плохой результат?
- Чем NetworkPolicy отличается от
internal: true?
На применение:
- Как определить, нужен ли сервису StatefulSet?
- Что нужно добавить в код для трёх проб?
- Как оценить объём переноса до его начала?
На диагностику:
- После конвертации всё работает, но при масштабировании базы данные повреждаются. Причина?
- NetworkPolicy создана, изоляции нет. Гипотеза?
Краткое резюме
- Двенадцать конструкций Compose из двадцати переносятся прямо.
- Не переносятся
depends_on,networksиprofiles. depends_onне имеет аналога: pod'ы запускаются параллельно по устройству системы.- Правильная замена
depends_on— повторы подключения в приложении. - Перенос выявляет приложения, не готовые к отказам зависимостей.
- Сеть в Kubernetes плоская; изоляция описывается NetworkPolicy.
- NetworkPolicy может не действовать: применяет её плагин сети, а не API-сервер. Проверять нужно фактическую доступность, а не наличие объекта.
- Сервис с состоянием требует StatefulSet: Deployment повредит данные при масштабировании.
- Один
healthcheckпревращается в три пробы, и это требует эндпоинтов в коде. portsв Compose — одна строка; в Kubernetes три отдельных решения.- Автоматическая конвертация даёт результат, который выглядит работающим.
- Около половины работы по переносу приходится на код приложения, а не на манифесты.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Kubernetes: Deployment | https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ | Приложения без состояния |
| Kubernetes: StatefulSet | https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/ | Устойчивые имена и тома |
| Kubernetes: NetworkPolicy | https://kubernetes.io/docs/concepts/services-networking/network-policies/ | Требование поддержки плагином |
| Kubernetes: модель сети | https://kubernetes.io/docs/concepts/cluster-administration/networking/ | Плоская сеть |
| Kubernetes: Ingress | https://kubernetes.io/docs/concepts/services-networking/ingress/ | Внешний доступ |
| Kubernetes: PersistentVolume | https://kubernetes.io/docs/concepts/storage/persistent-volumes/ | Режимы доступа |
| Compose Specification | https://github.com/compose-spec/compose-spec/blob/main/spec.md | Что именно переносится |
| Kompose | https://kompose.io/ | Возможности и границы конвертера |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Когда нужна orchestration
Главное оглавление