Главная/Docker и Kubernetes/Урок

18.3. От Compose к Kubernetes

Цели

После этого материала вы сможете:

  • сопоставить каждую конструкцию Compose с объектами Kubernetes;
  • назвать три вещи, которые не переносятся, и объяснить почему;
  • объяснить, почему автоматическая конвертация даёт плохой результат;
  • перенести стек из раздела 09 вручную и обосновать каждое решение;
  • определить, что в вашем compose.yaml требует переосмысления до переноса;
  • оценить объём работы по переносу до его начала.

Предварительные знания

Кластер не требуется. Манифесты разбираются как документы.

Ключевые термины

ТерминОбъяснение
конвертацияАвтоматическое преобразование Compose в манифесты
DeploymentОбъект для приложений без собственного состояния
StatefulSetОбъект для приложений с состоянием и устойчивыми именами
PVCЗапрос на постоянное хранилище
IngressМаршрутизация внешнего HTTP-трафика

Теория

Таблица соответствий

Конструкция ComposeОбъекты KubernetesПеренос
services.<имя>Deployment + ServiceПрямой
imagespec.containers[].imageПрямой
command, entrypointcommand, argsПрямой
environmentenv или ConfigMapПрямой
env_fileConfigMap из файлаПрямой
secretsSecretПрямой
portsService + containerPortПереосмысление
volumes (именованный)PVC + volumeMountsПереосмысление
volumes (bind mount)ConfigMap, Secret или hostPathПереосмысление
tmpfsemptyDir с medium: MemoryПрямой
healthchecklivenessProbe + readinessProbeПереосмысление
depends_oninitContainers, пробы, повторыНе переносится
deploy.replicasspec.replicasПрямой
deploy.resourcesrequests + limitsПереосмысление
restartВсегда действует; политика на уровне pod'аПрямой
networksПлоская сеть кластераНе переносится
stop_grace_periodterminationGracePeriodSecondsПрямой
usersecurityContext.runAsUserПрямой
read_onlyreadOnlyRootFilesystemПрямой
cap_dropsecurityContext.capabilities.dropПрямой
profilesОтдельные файлы или инструмент шаблоновНе переносится
Имя проектаNamespaceЧастично

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

Что не переносится: depends_on

yaml
depends_on:
  db:
    condition: service_healthy

В Kubernetes такого механизма нет. Причина в модели: pod'ы запускаются параллельно, и порядок не гарантируется в принципе.

Три способа заменить:

СпособКогда подходит
initContainersОбязательная подготовка: миграция, ожидание базы
readinessProbeПриложение может стартовать без зависимости
Повторы в приложенииПравильный ответ в большинстве случаев

Третья строка — главный вывод. Kubernetes исходит из того, что зависимость может отказать в любой момент, а не только при старте.

Приложение, умеющее переподключаться, не нуждается ни в depends_on, ни в initContainers. Приложение, падающее при недоступности базы, будет падать и в эксплуатации — просто отложенно.

Отсюда практическое следствие: перенос в Kubernetes выявляет приложения, не готовые к отказам зависимостей.

Что не переносится: сети

yaml
networks:
  frontend:
  backend:
    internal: true

В Kubernetes сеть плоская: любой pod может обратиться к любому. Изоляция достигается не сетями, а NetworkPolicy — правилами, которые фильтруют трафик.

ComposeKubernetes
Несколько сетейОдна плоская сеть
internal: trueNetworkPolicy с запретом по умолчанию
Подключение к сетиМетки pod'а и селекторы в политике

Существенное различие: NetworkPolicy применяет плагин сети, а не API-сервер. Объект будет принят в любом случае; подействует ли он — зависит от плагина.

Проверено на кластере kind с плагином kindnet: политика подействовала, связь закрылась (verify/FACTS.md). То есть «молча не работает» — не общее правило, а возможный режим отказа.

Отсюда единственно надёжный подход: проверять фактическую доступность, а не наличие объекта.

Проверять нужно фактическую доступность, а не наличие политики.

Что переосмысливается: ports

yaml
ports:
  - "8080:8000"

В Compose это одна строка. В Kubernetes — три отдельных решения:

РешениеОбъект
Какой порт слушает приложениеcontainerPort
Как обращаться внутри кластераService
Как обращаться снаружиIngress, LoadBalancer или NodePort

Публикация «наружу» в Kubernetes — отдельное решение с последствиями по стоимости и безопасности, а не побочный эффект строки в файле.

Полезное наблюдение: большинство сервисов, публиковавших порты в Compose «чтобы проверить», наружу не нужны вовсе.

Что переосмысливается: тома и состояние

yaml
volumes:
  pgdata:

Именованный том становится PVC. Но вместе с ним меняется и объект, управляющий приложением:

СвойствоDeploymentStatefulSet
Имена pod'овСлучайныеУстойчивые: db-0, db-1
ХранилищеОбщее или без негоСвоё у каждого pod'а
Порядок запускаПроизвольныйПо очереди
Для чегоПриложения без состоянияБазы данных, очереди

База данных в Deployment с одним PVC работает, пока реплика одна. При попытке масштабирования два pod'а обратятся к одному тому — и это либо не запустится (ReadWriteOnce), либо повредит данные.

Правило: приложение с состоянием — StatefulSet, не Deployment.

Отдельный вопрос, который перенос заставляет задать: нужно ли вообще держать базу в кластере. Управляемая база вне кластера снимает целый класс задач (урок 18.4).

Что переосмысливается: healthcheck

yaml
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'аОбычно верно

Главная беда не в отдельных ошибках, а в том, что результат выглядит работающим. Он запускается, отвечает на запросы — и содержит решения, которые никто не принимал.

Разумное применение конвертера: получить черновик, чтобы увидеть объём работы, и переписать вручную.

Порядок ручного переноса

text
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 описывает, кто с кем может говорить, и применяется плагином сети.


Команды и примеры

Исходный стек

bash
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

Ожидаемый вывод:

text
═══ состав стека ═══
  сервисов: 4
  томов:    2
  секретов: 1
  сетей:    2

  сервис      реплик состояние    публикует    зависит от
  ──────────────────────────────────────────────────────────────────────
  api              2 нет          8080:8000    db, cache
  worker           3 нет          —            cache
  db               1 да           —            —
  cache            1 да           —            —

Два сервиса имеют состояние — это первый признак того, что перенос не будет механическим.

Что даёт автоматическая конвертация

bash
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

Ожидаемый вывод:

text
═══ механическая конвертация ═══
  Результат механической конвертации:
    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, состояние ЕСТЬ — самая опасная. Пока реплика одна, всё работает. Первая попытка масштабирования повредит данные.

Ручной перенос: решения по каждому сервису

bash
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

Ожидаемый вывод:

text
═══ решения по сервисам ═══
  сервис     объект                                       почему
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
  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). Зависимость была объявлена без надобности.

Манифесты после переноса

bash
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

Ожидаемый вывод:

text
═══ проверка манифестов ═══
  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, которые тоже нужны.

Оценка объёма работы

bash
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

Ожидаемый вывод:

text
═══ оценка объёма ═══
  работа                                               стоимость
  ──────────────────────────────────────────────────────────────────
  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.

  Автоматическая конвертация даёт манифесты за минуту
  и не делает ничего из этой половины.

Сорок семь процентов работы — в коде приложения. Конвертер этого не делает и не может.


Практическое упражнение

Задание. Перенесите стек вручную и обоснуйте каждое решение.

Требования:

  1. Составить таблицу соответствий и указать, что переносится прямо, а что нет.
  2. Показать, какие решения принимает за вас автоматическая конвертация, и почему они плохи.
  3. Для каждого сервиса определить объект: Deployment или StatefulSet — с обоснованием.
  4. Разделить healthcheck на пробы; указать, что для этого нужно добавить в код.
  5. Заменить depends_on и объяснить, почему аналога нет.
  6. Заменить networks с internal: true на NetworkPolicy.
  7. Оценить объём работы и показать долю, приходящуюся на код, а не на манифесты.

Подсказки

Подсказка 1

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

Подсказка 2

depends_on не имеет аналога, потому что pod'ы запускаются параллельно. Правильная замена — повторы подключения в приложении.

Подсказка 3

Оценка объёма показательнее, если разделить работу в манифестах и работу в коде.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Анализ переноса ═══
  ── Таблица соответствий ──

    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 упомянут как решение для внешнего доступа, но манифест для него не написан: выбор контроллера и настройка маршрутов — тема за пределами урока.

Проверка результата

bash
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Кажется, что аналог естьАналога нет; отдельные файлы или шаблоны

Контрольные вопросы

На понимание:

  1. Какие три конструкции Compose не переносятся и почему?
  2. Почему depends_on не имеет аналога в Kubernetes?
  3. Что происходит с базой данных в Deployment при масштабировании?
  4. Почему автоматическая конвертация даёт плохой результат?
  5. Чем NetworkPolicy отличается от internal: true?

На применение:

  1. Как определить, нужен ли сервису StatefulSet?
  2. Что нужно добавить в код для трёх проб?
  3. Как оценить объём переноса до его начала?

На диагностику:

  1. После конвертации всё работает, но при масштабировании базы данные повреждаются. Причина?
  2. NetworkPolicy создана, изоляции нет. Гипотеза?

Краткое резюме

  1. Двенадцать конструкций Compose из двадцати переносятся прямо.
  2. Не переносятся depends_on, networks и profiles.
  3. depends_on не имеет аналога: pod'ы запускаются параллельно по устройству системы.
  4. Правильная замена depends_on — повторы подключения в приложении.
  5. Перенос выявляет приложения, не готовые к отказам зависимостей.
  6. Сеть в Kubernetes плоская; изоляция описывается NetworkPolicy.
  7. NetworkPolicy может не действовать: применяет её плагин сети, а не API-сервер. Проверять нужно фактическую доступность, а не наличие объекта.
  8. Сервис с состоянием требует StatefulSet: Deployment повредит данные при масштабировании.
  9. Один healthcheck превращается в три пробы, и это требует эндпоинтов в коде.
  10. ports в Compose — одна строка; в Kubernetes три отдельных решения.
  11. Автоматическая конвертация даёт результат, который выглядит работающим.
  12. Около половины работы по переносу приходится на код приложения, а не на манифесты.

Официальные источники

ИсточникСсылкаЧто подтверждает
Kubernetes: Deploymenthttps://kubernetes.io/docs/concepts/workloads/controllers/deployment/Приложения без состояния
Kubernetes: StatefulSethttps://kubernetes.io/docs/concepts/workloads/controllers/statefulset/Устойчивые имена и тома
Kubernetes: NetworkPolicyhttps://kubernetes.io/docs/concepts/services-networking/network-policies/Требование поддержки плагином
Kubernetes: модель сетиhttps://kubernetes.io/docs/concepts/cluster-administration/networking/Плоская сеть
Kubernetes: Ingresshttps://kubernetes.io/docs/concepts/services-networking/ingress/Внешний доступ
Kubernetes: PersistentVolumehttps://kubernetes.io/docs/concepts/storage/persistent-volumes/Режимы доступа
Compose Specificationhttps://github.com/compose-spec/compose-spec/blob/main/spec.mdЧто именно переносится
Komposehttps://kompose.io/Возможности и границы конвертера

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Когда нужна orchestration
Главное оглавление

Markdown на GitHub ↗