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

18.2. Объекты Kubernetes

Цели

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

  • объяснить, чем pod отличается от container, и назвать случай, когда их несколько;
  • различить Deployment, ReplicaSet и pod по зоне ответственности;
  • объяснить, зачем нужен Service, если у pod'а есть адрес;
  • назвать три вида проб и указать, что ломается при неверном выборе;
  • объяснить разницу requests и limits и её последствия для планирования;
  • сопоставить каждый объект с тем, что вы уже делали в Docker.

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

Кластер для этого урока не требуется. Манифесты разбираются как документы; поведение объясняется механизмом, а не запуском.

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

ТерминОбъяснение
PodМинимальная единица запуска: один или несколько container'ов
DeploymentОписание желаемого состояния набора pod'ов
ReplicaSetПоддерживает заданное число одинаковых pod'ов
ServiceСтабильный адрес для набора pod'ов
requestsЧто гарантировано; влияет на планирование
limitsПотолок; влияет на поведение под нагрузкой

Теория

Pod — не то же самое, что container

text
Pod
├── общий сетевой namespace   ← все container'ы видят один localhost
├── общий IPC namespace
├── общие тома
└── container'ы:
    ├── приложение
    └── вспомогательный (по необходимости)

Pod — это группа container'ов с общими namespaces, планируемая как целое. Обычно в нём один container.

Это ровно то, что даёт --network container: и --ipc container: в Docker (урок 17.3): общая сеть, свои файловые системы.

Что общее в pod'еЧто своё
Сетевой namespace: адрес, порты, localhostФайловая система каждого container'а
IPC namespaceПроцессы, если не задано иное
Тома, если смонтированы в обаЛимиты ресурсов

Когда container'ов больше одного:

СлучайПример
SidecarСбор логов, прокси, обновление конфигурации
Init-containerМиграция базы до старта приложения
AdapterПреобразование формата метрик

Правило: несколько container'ов в pod'е оправданы, только если они должны жить и умирать вместе и делить сеть. Два независимых сервиса — это два pod'а.

Deployment, ReplicaSet, Pod

text
Deployment          описывает желаемое: образ, реплики, стратегия обновления
    │  создаёт и управляет
    ▼
ReplicaSet          поддерживает N одинаковых pod'ов
    │  создаёт
    ▼
Pod                 запускается на узле
ОбъектОтвечает заАналог в Docker
PodЗапуск container'ов вместеdocker run с общими namespaces
ReplicaSetЧисло экземпляровНет аналога
DeploymentОбновление и откатНет аналога

При изменении образа Deployment создаёт новый ReplicaSet и постепенно переводит pod'ы, следя за готовностью. Старый ReplicaSet сохраняется — это и делает откат мгновенным.

Отсюда практическое следствие: Deployment решает задачу, которая в Docker решалась вручную — заменить версию без перерыва в обслуживании (урок 11.3).

Зачем Service, если у pod'а есть адрес

У pod'а есть IP-адрес. Проблема в том, что он временный: pod пересоздан — адрес другой.

text
Service                     стабильное имя и адрес
   │  выбирает по меткам
   ▼
pod-a (10.1.2.3)   pod-b (10.1.2.7)   pod-c (10.1.5.1)

Service даёт постоянное DNS-имя и распределяет обращения между pod'ами, прошедшими readiness-пробу.

Тип ServiceЧто даёт
ClusterIPАдрес внутри кластера; по умолчанию
NodePortПорт на каждом узле
LoadBalancerВнешний балансировщик от платформы
ExternalNameПсевдоним для внешнего имени
Headless (clusterIP: None)DNS возвращает адреса всех pod'ов

Ближайший знакомый аналог — встроенный DNS Docker в пользовательской сети (урок 8.4): обращение по имени сервиса вместо адреса. Service добавляет к этому распределение нагрузки и учёт готовности.

Три вида проб

ПробаВопросЧто при отказе
startupProbeИнициализация завершена?Остальные пробы не выполняются
livenessProbeПроцесс жив?Container перезапускается
readinessProbeГотов принимать трафик?Pod убирается из Service

Разделение существует потому, что ответы на эти вопросы различаются.

Типичная ошибка: одна проба на всё. Если readiness и liveness совпадают, то временная недоступность базы приведёт к перезапуску приложения — хотя перезапуск ничем не поможет.

text
База недоступна:
  readiness должна упасть   → трафик перестанет идти, pod останется жив
  liveness должна пройти    → перезапуск не восстановит базу

Правило: liveness проверяет только сам процесс; readiness — готовность обслуживать.

startupProbe нужна приложениям с долгим стартом: без неё пришлось бы задавать большой initialDelaySeconds у liveness, и настоящее зависание обнаруживалось бы поздно.

Это уточняет модель из урока 11.3: там healthcheck один, здесь их три, и каждая управляет своим.

requests и limits

requestslimits
Что означаетГарантированоПотолок
На что влияетПланирование: выбор узлаПоведение под нагрузкой
Память при превышенииOOM kill
CPU при превышенииThrottling
Аналог в DockerНет--memory, --cpus

Первая строка — то, чего нет в Docker. requests не ограничивает; он сообщает планировщику, сколько ресурса нужно зарезервировать.

Отсюда три класса качества обслуживания:

КлассУсловиеКого убьют первым при нехватке
Guaranteedrequests == limits для всех ресурсовПоследним
Burstablerequests < limitsВторым
BestEffortНичего не заданоПервым

Практический вывод: pod без requests попадает в BestEffort и будет вытеснен при нехватке памяти на узле — даже если он важнее соседей.

Поведение при превышении limits — то же, что разбиралось в уроке 13.3: память даёт OOM, процессор — throttling. Механизм тот же cgroup v2.

ConfigMap и Secret

ОбъектДля чегоКак попадает в container
ConfigMapКонфигурацияПеременные окружения или файлы
SecretПароли, токены, ключиТо же

Secret в Kubernetes по умолчанию не шифруется — он кодируется base64, как auth в config.json (урок 14.2). Шифрование хранилища включается отдельно.

Способ подключения важнее выбора объекта:

СпособОбновление без перезапускаВиден в describe
Переменная окруженияНетДа
Файл через томДаНет

Монтирование файлом предпочтительнее по обеим причинам — тем же, что в уроке 12.6: переменная окружения наследуется потомками и попадает в отчёты об ошибках.

Тома

ОбъектАналог в DockerНазначение
emptyDirАнонимный томВременные данные на время жизни pod'а
emptyDir с medium: Memory--tmpfsДанные в памяти
hostPathBind mountКаталог узла — применять осторожно
PersistentVolumeClaimИменованный томПостоянное хранилище
configMap, secretBind mount файлаКонфигурация как файлы

PersistentVolumeClaim — запрос на хранилище: приложение просит объём и режим доступа, платформа предоставляет.

Режимы доступа существенны:

РежимСмысл
ReadWriteOnceОдин узел на запись
ReadOnlyManyМного узлов на чтение
ReadWriteManyМного узлов на запись — поддерживается не всеми хранилищами

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

Namespace

Логическое разделение объектов внутри кластера: имена уникальны в пределах namespace, а не всего кластера.

Не путать с namespaces ядра из урока 17.3 — совпадение слова случайно. Namespace Kubernetes не изолирует процессы; он разделяет имена, квоты и права доступа.


Внутренний механизм

Почему pod, а не container

Планировщик размещает pod целиком на одном узле. Если бы единицей был container, пришлось бы отдельно описывать, какие container'ы обязаны быть рядом и делить сеть.

Pod делает эту связь явной: всё внутри него планируется вместе, делит адрес и умирает вместе.

Технически это реализуется через pause-container — минимальный процесс, который держит namespaces pod'а. Остальные container'ы присоединяются к ним, как --network container: в Docker (урок 17.3).

Отсюда: перезапуск одного container'а в pod'е не меняет IP-адрес — namespace держит pause-container, а он не перезапускался.

Как Service направляет трафик

Service — не процесс и не прокси. Это правило, которое компонент на каждом узле превращает в записи netfilter или IPVS.

Обращение к адресу Service перехватывается и перенаправляется на один из адресов готовых pod'ов. Это тот же механизм DNAT, что применяется при публикации портов в Docker (урок 8.3).

Отсюда: pod, не прошедший readiness-пробу, исключается из набора назначений — трафик к нему просто перестаёт направляться.


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

Манифест как документ

bash
mkdir -p /tmp/k8sobj && cd /tmp/k8sobj

cat > deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  labels:
    app: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: api
    spec:
      # Соответствует USER в Dockerfile ([урок 12.3])
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
      # Время на обработку SIGTERM ([урок 11.3])
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          # Закрепление по digest ([урок 14.4])
          image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
          ports:
            - name: http
              containerPort: 8000
          # Три пробы, каждая отвечает на свой вопрос
          startupProbe:
            httpGet:
              path: /startupz
              port: http
            periodSeconds: 2
            failureThreshold: 30      # до 60 секунд на инициализацию
          livenessProbe:
            httpGet:
              path: /healthz          # только сам процесс
              port: http
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz           # готовность обслуживать
              port: http
            periodSeconds: 3
            timeoutSeconds: 2
            failureThreshold: 2
          resources:
            requests:                 # влияет на ПЛАНИРОВАНИЕ
              cpu: 100m
              memory: 128Mi
            limits:                   # влияет на ПОВЕДЕНИЕ
              cpu: 500m
              memory: 256Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          env:
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: api-config
                  key: log_level
          volumeMounts:
            - name: tmp
              mountPath: /tmp
            - name: secrets
              mountPath: /run/secrets
              readOnly: true
      volumes:
        - name: tmp
          emptyDir: {}
        - name: secrets
          secret:
            secretName: api-secrets
EOF

cat > service.yaml <<'EOF'
apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  type: ClusterIP
  selector:
    app: api          # те же метки, что у pod'ов
  ports:
    - name: http
      port: 80        # адрес Service
      targetPort: http  # порт в container'е
EOF

echo "═══ проверка синтаксиса ═══"
python3 - <<'PY'
import yaml
from pathlib import Path

for name in ("deployment.yaml", "service.yaml"):
    doc = yaml.safe_load(Path(name).read_text())
    print(f"  {name}: {doc['kind']} «{doc['metadata']['name']}» — валиден")
PY

echo "═══ структура Deployment ═══"
python3 - <<'PY'
import yaml
from pathlib import Path

d = yaml.safe_load(Path("deployment.yaml").read_text())
spec = d["spec"]
pod = spec["template"]["spec"]
container = pod["containers"][0]

print(f"  реплик: {spec['replicas']}")
print(f"  стратегия: {spec['strategy']['type']}, "
      f"maxSurge={spec['strategy']['rollingUpdate']['maxSurge']}, "
      f"maxUnavailable={spec['strategy']['rollingUpdate']['maxUnavailable']}")
print(f"  terminationGracePeriodSeconds: {pod['terminationGracePeriodSeconds']}")
print(f"  container'ов в pod'е: {len(pod['containers'])}")
print()
probes = [k for k in container if k.endswith("Probe")]
print(f"  проб: {len(probes)} — {', '.join(probes)}")
for p in probes:
    path = container[p]["httpGet"]["path"]
    period = container[p].get("periodSeconds")
    print(f"    {p:<16} {path:<12} каждые {period} с")
print()
res = container["resources"]
print(f"  requests: cpu={res['requests']['cpu']}, memory={res['requests']['memory']}")
print(f"  limits:   cpu={res['limits']['cpu']}, memory={res['limits']['memory']}")
PY

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

text
═══ проверка синтаксиса ═══
  deployment.yaml: Deployment «api» — валиден
  service.yaml: Service «api» — валиден
═══ структура Deployment ═══
  реплик: 3
  стратегия: RollingUpdate, maxSurge=1, maxUnavailable=0
  terminationGracePeriodSeconds: 45
  container'ов в pod'е: 1

  проб: 3 — startupProbe, livenessProbe, readinessProbe
    startupProbe     /startupz    каждые 2 с
    livenessProbe    /healthz     каждые 10 с
    readinessProbe   /readyz      каждые 3 с

  requests: cpu=100m, memory=128Mi
  limits:   cpu=500m, memory=256Mi

maxUnavailable: 0 означает: при обновлении сначала поднимается новый pod, и только после его готовности убирается старый. Это ровно требование из урока 11.3.

Три пробы: что ломается при одной

bash
cd /tmp/k8sobj
cat > probes.py <<'PY'
"""Три пробы Kubernetes и последствия неверного выбора."""
from __future__ import annotations

PROBES = [
    ("startupProbe", "инициализация завершена?",
     "остальные пробы не выполняются, пока не пройдёт",
     "приложение с долгим стартом"),
    ("livenessProbe", "процесс жив?",
     "container ПЕРЕЗАПУСКАЕТСЯ",
     "зависание, взаимоблокировка, утечка дескрипторов"),
    ("readinessProbe", "готов принимать трафик?",
     "pod убирается из Service, но НЕ перезапускается",
     "прогрев, недоступная зависимость, перегрузка"),
]

SCENARIOS = [
    {
        "событие": "База данных временно недоступна",
        "readiness": "должна упасть — трафик уйдёт на другие pod'ы",
        "liveness": "должна пройти — перезапуск базу не вернёт",
        "если одна проба": "ПЕРЕЗАПУСК: бесполезный и вредный",
    },
    {
        "событие": "Приложение зависло, не отвечает",
        "readiness": "упадёт — трафик уйдёт",
        "liveness": "упадёт — перезапуск поможет",
        "если одна проба": "работает верно",
    },
    {
        "событие": "Прогрев кэша при старте, 40 секунд",
        "readiness": "не пройдёт — трафика не будет",
        "liveness": "должна пройти или ещё не выполняться",
        "если одна проба": "ПЕРЕЗАПУСК в цикле: старт не успевает",
    },
    {
        "событие": "Пик нагрузки, очередь переполнена",
        "readiness": "упадёт — нагрузка перераспределится",
        "liveness": "должна пройти",
        "если одна проба": "ПЕРЕЗАПУСК под нагрузкой: станет хуже",
    },
]


def main() -> None:
    print(f"  {'проба':<16} {'вопрос':<32} при отказе")
    print("  " + "─" * 92)
    for name, question, on_fail, when in PROBES:
        print(f"  {name:<16} {question:<32} {on_fail}")
    print()
    for name, _, _, when in PROBES:
        print(f"    {name:<16} нужна для: {when}")

    print()
    print(f"  {'событие':<38} {'readiness':<42} если ОДНА проба")
    print("  " + "─" * 106)
    broken = 0
    for s in SCENARIOS:
        if "ПЕРЕЗАПУСК" in s["если одна проба"]:
            broken += 1
        print(f"  {s['событие']:<38} {s['readiness']:<42} {s['если одна проба']}")

    print()
    print(f"  сценариев, где одна проба даёт неверное поведение: "
          f"{broken} из {len(SCENARIOS)}")
    print()
    print("  Правило: liveness проверяет ТОЛЬКО сам процесс;")
    print("  readiness — готовность обслуживать.")
    print()
    print("  Проверка зависимостей в liveness — самая частая ошибка:")
    print("  отказ базы вызывает перезапуск всех экземпляров приложения,")
    print("  и восстановление становится дольше.")


if __name__ == "__main__":
    main()
PY

echo "═══ три пробы ═══"
python3 probes.py

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

text
═══ три пробы ═══
  проба            вопрос                           при отказе
  ────────────────────────────────────────────────────────────────────────────────────────────
  startupProbe     инициализация завершена?         остальные пробы не выполняются, пока не пройдёт
  livenessProbe    процесс жив?                     container ПЕРЕЗАПУСКАЕТСЯ
  readinessProbe   готов принимать трафик?          pod убирается из Service, но НЕ перезапускается

    startupProbe     нужна для: приложение с долгим стартом
    livenessProbe    нужна для: зависание, взаимоблокировка, утечка дескрипторов
    readinessProbe   нужна для: прогрев, недоступная зависимость, перегрузка

  событие                                readiness                                  если ОДНА проба
  ──────────────────────────────────────────────────────────────────────────────────────────────────────────
  База данных временно недоступна        должна упасть — трафик уйдёт на другие pod'ы  ПЕРЕЗАПУСК: бесполезный и вредный
  Приложение зависло, не отвечает        упадёт — трафик уйдёт                      работает верно
  Прогрев кэша при старте, 40 секунд     не пройдёт — трафика не будет              ПЕРЕЗАПУСК в цикле: старт не успевает
  Пик нагрузки, очередь переполнена      упадёт — нагрузка перераспределится        ПЕРЕЗАПУСК под нагрузкой: станет хуже

  сценариев, где одна проба даёт неверное поведение: 3 из 4

  Правило: liveness проверяет ТОЛЬКО сам процесс;
  readiness — готовность обслуживать.

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

Три сценария из четырёх дают неверное поведение при одной пробе. Худший — последний: перезапуск под нагрузкой усугубляет перегрузку.

requests и limits: классы обслуживания

bash
cd /tmp/k8sobj
cat > qos.py <<'PY'
"""Классы качества обслуживания и их последствия."""
from __future__ import annotations

CASES = [
    {"имя": "критичный сервис",
     "requests": {"cpu": "500m", "memory": "512Mi"},
     "limits": {"cpu": "500m", "memory": "512Mi"}},
    {"имя": "обычное приложение",
     "requests": {"cpu": "100m", "memory": "128Mi"},
     "limits": {"cpu": "500m", "memory": "256Mi"}},
    {"имя": "фоновая задача",
     "requests": {"cpu": "50m", "memory": "64Mi"},
     "limits": {}},
    {"имя": "без указания ресурсов",
     "requests": {}, "limits": {}},
]


def qos_class(requests: dict, limits: dict) -> str:
    if not requests and not limits:
        return "BestEffort"
    if requests and limits and requests == limits:
        return "Guaranteed"
    return "Burstable"


EVICTION_ORDER = {"BestEffort": 1, "Burstable": 2, "Guaranteed": 3}


def main() -> None:
    print(f"  {'pod':<26} {'requests':<24} {'limits':<24} класс")
    print("  " + "─" * 90)
    rows = []
    for c in CASES:
        cls = qos_class(c["requests"], c["limits"])
        rows.append((c["имя"], cls))
        req = ", ".join(f"{k}={v}" for k, v in c["requests"].items()) or "—"
        lim = ", ".join(f"{k}={v}" for k, v in c["limits"].items()) or "—"
        print(f"  {c['имя']:<26} {req:<24} {lim:<24} {cls}")

    print()
    print("  Порядок вытеснения при нехватке памяти на узле:")
    for name, cls in sorted(rows, key=lambda r: EVICTION_ORDER[r[1]]):
        print(f"    {EVICTION_ORDER[cls]}. {name:<26} ({cls})")

    print()
    print(f"  {'величина':<12} {'на что влияет':<34} аналог в Docker")
    print("  " + "─" * 76)
    print(f"  {'requests':<12} {'ПЛАНИРОВАНИЕ: выбор узла':<34} нет аналога")
    print(f"  {'limits':<12} {'поведение под нагрузкой':<34} --memory, --cpus")
    print()
    print("  requests не ограничивает — он резервирует. Планировщик")
    print("  размещает pod только на узле, где эта величина свободна.")
    print()
    print("  Практический вывод: pod без requests попадает в BestEffort")
    print("  и будет вытеснен ПЕРВЫМ — даже если он важнее соседей.")
    print()
    print("  Поведение при превышении limits — то же, что в Docker:")
    print("    память → OOM kill")
    print("    CPU    → throttling ([урок 13.3])")
    print("  Механизм тот же: cgroup v2.")


if __name__ == "__main__":
    main()
PY

echo "═══ классы обслуживания ═══"
python3 qos.py

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

text
═══ классы обслуживания ═══
  pod                        requests                 limits                   класс
  ──────────────────────────────────────────────────────────────────────────────────────────
  критичный сервис           cpu=500m, memory=512Mi   cpu=500m, memory=512Mi   Guaranteed
  обычное приложение         cpu=100m, memory=128Mi   cpu=500m, memory=256Mi   Burstable
  фоновая задача             cpu=50m, memory=64Mi     —                        Burstable
  без указания ресурсов      —                        —                        BestEffort

  Порядок вытеснения при нехватке памяти на узле:
    1. без указания ресурсов      (BestEffort)
    2. обычное приложение         (Burstable)
    2. фоновая задача             (Burstable)
    3. критичный сервис           (Guaranteed)

  величина     на что влияет                      аналог в Docker
  ────────────────────────────────────────────────────────────────────────────
  requests     ПЛАНИРОВАНИЕ: выбор узла           нет аналога
  limits       поведение под нагрузкой            --memory, --cpus

  requests не ограничивает — он резервирует. Планировщик
  размещает pod только на узле, где эта величина свободна.

  Практический вывод: pod без requests попадает в BestEffort
  и будет вытеснен ПЕРВЫМ — даже если он важнее соседей.

  Поведение при превышении limits — то же, что в Docker:
    память → OOM kill
    CPU    → throttling ([урок 13.3])
  Механизм тот же: cgroup v2.

Строка «без указания ресурсов» вытесняется первой. Это неочевидно: отсутствие ограничений выглядит как отсутствие проблем, а даёт низший приоритет.

Pod с несколькими container'ами

bash
cd /tmp/k8sobj
cat > sidecar.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: app-with-sidecar
spec:
  # Init-container: выполняется ДО основных, до успешного завершения
  initContainers:
    - name: migrate
      image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
      command: ["python", "-m", "src.migrate"]
      envFrom:
        - secretRef:
            name: db-credentials
  containers:
    # Основной container
    - name: api
      image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
      ports:
        - containerPort: 8000
      volumeMounts:
        - name: shared-logs
          mountPath: /var/log/app
    # Sidecar: читает те же файлы, отправляет наружу
    - name: log-shipper
      image: ghcr.io/org/shipper@sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f8a0b1c3e5d7f9a2b4c6e8d0f1a3b
      volumeMounts:
        - name: shared-logs
          mountPath: /var/log/app
          readOnly: true
  volumes:
    - name: shared-logs
      emptyDir: {}
EOF

echo "═══ структура pod'а ═══"
python3 - <<'PY'
import yaml
from pathlib import Path

pod = yaml.safe_load(Path("sidecar.yaml").read_text())
spec = pod["spec"]

inits = spec.get("initContainers", [])
mains = spec.get("containers", [])
print(f"  init-container'ов: {len(inits)}")
for c in inits:
    print(f"    {c['name']}: {' '.join(c.get('command', []))}")
print(f"  основных container'ов: {len(mains)}")
for c in mains:
    mounts = [m["mountPath"] for m in c.get("volumeMounts", [])]
    print(f"    {c['name']:<14} тома: {', '.join(mounts) or '—'}")
print(f"  томов: {len(spec.get('volumes', []))}")
PY

echo "═══ что общее, что своё ═══"
python3 - <<'PY'
SHARED = [
    ("сетевой namespace", "оба видят один localhost и один IP-адрес"),
    ("IPC namespace", "разделяемая память доступна обоим"),
    ("тома", "если смонтированы в оба container'а"),
    ("жизненный цикл", "планируются, запускаются и удаляются вместе"),
]
OWN = [
    ("файловая система", "у каждого своя из своего образа"),
    ("процессы", "по умолчанию свой PID namespace"),
    ("лимиты ресурсов", "задаются на каждый container отдельно"),
    ("пробы", "у каждого свои"),
]
print(f"  {'ОБЩЕЕ в pod е':<24} следствие")
print("  " + "─" * 76)
for name, why in SHARED:
    print(f"  {name:<24} {why}")
print()
print(f"  {'СВОЁ у каждого':<24} следствие")
print("  " + "─" * 76)
for name, why in OWN:
    print(f"  {name:<24} {why}")
print()
print("  Это ровно то, что даёт --network container: и --ipc container:")
print("  в Docker ([урок 17.3]): общая сеть, свои файловые системы.")
print()
print("  Технически namespaces держит pause-container — минимальный")
print("  процесс, к которому присоединяются остальные. Поэтому")
print("  перезапуск container'а не меняет IP-адрес pod'а.")
print()
print("  Правило: несколько container'ов оправданы, только если они")
print("  должны жить и умирать вместе и делить сеть.")
print("  Два независимых сервиса — это ДВА pod'а.")
PY

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

text
═══ структура pod'а ═══
  init-container'ов: 1
    migrate: python -m src.migrate
  основных container'ов: 2
    api            тома: /var/log/app
    log-shipper    тома: /var/log/app
  томов: 1
═══ что общее, что своё ═══
  ОБЩЕЕ в pod е            следствие
  ────────────────────────────────────────────────────────────────────────────
  сетевой namespace        оба видят один localhost и один IP-адрес
  IPC namespace            разделяемая память доступна обоим
  тома                     если смонтированы в оба container'а
  жизненный цикл           планируются, запускаются и удаляются вместе

  СВОЁ у каждого           следствие
  ────────────────────────────────────────────────────────────────────────────
  файловая система         у каждого своя из своего образа
  процессы                 по умолчанию свой PID namespace
  лимиты ресурсов          задаются на каждый container отдельно
  пробы                    у каждого свои

  Это ровно то, что даёт --network container: и --ipc container:
  в Docker ([урок 17.3]): общая сеть, свои файловые системы.
  ...

Init-container решает задачу, которая в Compose решалась через depends_on с условием (урок 9.3): миграция выполняется до старта приложения и должна успешно завершиться.

Secret: что он на самом деле

bash
cd /tmp/k8sobj
cat > secret.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
type: Opaque
data:
  # base64, НЕ шифрование
  db_password: cGFzc3dvcmQtaXotcHJpbWVyYQ==
  api_token: dG9rZW4taXotcHJpbWVyYQ==
EOF

echo "═══ что внутри Secret ═══"
python3 - <<'PY'
import base64
import yaml
from pathlib import Path

s = yaml.safe_load(Path("secret.yaml").read_text())
print(f"  type: {s['type']}")
print()
print(f"  {'ключ':<16} {'в манифесте':<32} {'длина после base64 -d':<24}")
print("  " + "─" * 76)
for key, value in s["data"].items():
    decoded = base64.b64decode(value).decode("utf-8", "replace")
    print(f"  {key:<16} {value:<32} {len(decoded)} символов")
print()
print("  Значения НЕ выводятся: они восстанавливаются одной командой,")
print("  и печатать их в отчёте — создавать вторую утечку ([урок 12.6]).")
print()
print("  base64 — ОБРАТИМОЕ кодирование, а не шифрование.")
print("  Это ровно то же, что поле auth в ~/.docker/config.json")
print("  ([урок 14.2]).")
print()
print("  Шифрование хранилища кластера включается ОТДЕЛЬНО.")
print("  По умолчанию Secret лежит в хранилище как есть.")
PY

echo "═══ два способа подключения ═══"
python3 - <<'PY'
WAYS = [
    {"способ": "переменная окружения (envFrom, valueFrom)",
     "обновление без перезапуска": "НЕТ",
     "виден в describe pod": "ДА",
     "наследуется потомками": "ДА",
     "попадёт в отчёт об ошибке": "ДА"},
    {"способ": "файл через том (volumeMounts)",
     "обновление без перезапуска": "да",
     "виден в describe pod": "нет",
     "наследуется потомками": "нет",
     "попадёт в отчёт об ошибке": "нет"},
]
keys = ["обновление без перезапуска", "виден в describe pod",
        "наследуется потомками", "попадёт в отчёт об ошибке"]
print(f"  {'свойство':<32} {'переменная':<14} файл")
print("  " + "─" * 62)
for key in keys:
    print(f"  {key:<32} {WAYS[0][key]:<14} {WAYS[1][key]}")
print()
print("  Файл предпочтительнее по всем четырём признакам.")
print("  Те же доводы, что в уроке 12.6: переменная окружения")
print("  наследуется всеми потомками и попадает в отчёты об ошибках.")
print()
print("  Дополнительно: файл обновляется без перезапуска pod'а —")
print("  ротация секрета не требует перезапуска приложения,")
print("  если оно перечитывает файл.")
PY

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

text
═══ что внутри Secret ═══
  type: Opaque

  ключ             в манифесте                      длина после base64 -d   
  ────────────────────────────────────────────────────────────────────────────
  db_password      cGFzc3dvcmQtaXotcHJpbWVyYQ==     19 символов
  api_token        dG9rZW4taXotcHJpbWVyYQ==         16 символов

  Значения НЕ выводятся: они восстанавливаются одной командой,
  и печатать их в отчёте — создавать вторую утечку ([урок 12.6]).

  base64 — ОБРАТИМОЕ кодирование, а не шифрование.
  Это ровно то же, что поле auth в ~/.docker/config.json
  ([урок 14.2]).

  Шифрование хранилища кластера включается ОТДЕЛЬНО.
  По умолчанию Secret лежит в хранилище как есть.
═══ два способа подключения ═══
  свойство                         переменная     файл
  ──────────────────────────────────────────────────────────────
  обновление без перезапуска       НЕТ            да
  виден в describe pod             ДА             нет
  наследуется потомками            ДА             нет
  попадёт в отчёт об ошибке        ДА             нет

  Файл предпочтительнее по всем четырём признакам.
  ...

Соответствие тому, что вы уже делали

bash
cd /tmp/k8sobj
cat > mapping.py <<'PY'
"""Соответствие объектов Kubernetes тому, что делалось в Docker."""
from __future__ import annotations

import json

MAPPING = [
    ("Pod", "docker run с --network container:",
     "группа container'ов с общими namespaces", "прямое"),
    ("ReplicaSet", "—", "поддержание N экземпляров", "нет аналога"),
    ("Deployment", "—", "обновление и откат", "нет аналога"),
    ("Service", "встроенный DNS сети Docker",
     "стабильное имя плюс балансировка", "частичное"),
    ("ConfigMap", "environment в Compose", "конфигурация", "прямое"),
    ("Secret", "secrets в Compose", "секреты как файлы", "прямое"),
    ("emptyDir", "анонимный том", "временные данные", "прямое"),
    ("emptyDir + Memory", "--tmpfs", "данные в памяти", "прямое"),
    ("hostPath", "bind mount", "каталог узла", "прямое"),
    ("PersistentVolumeClaim", "именованный том", "постоянное хранилище", "частичное"),
    ("livenessProbe", "HEALTHCHECK", "перезапуск при отказе", "частичное"),
    ("readinessProbe", "—", "управление трафиком", "нет аналога"),
    ("startupProbe", "start_period в healthcheck", "долгая инициализация", "частичное"),
    ("limits", "--memory, --cpus", "потолок ресурсов", "прямое"),
    ("requests", "—", "резервирование при планировании", "нет аналога"),
    ("securityContext", "--user, --cap-drop, --read-only", "ограничения", "прямое"),
    ("terminationGracePeriodSeconds", "stop_grace_period", "время на SIGTERM", "прямое"),
    ("initContainers", "depends_on: service_completed_successfully",
     "подготовка до старта", "частичное"),
    ("Namespace", "имя проекта Compose", "разделение имён", "частичное"),
]


def main() -> None:
    print(f"  {'объект Kubernetes':<32} {'что вы уже делали':<44} соответствие")
    print("  " + "─" * 96)
    counts: dict[str, int] = {}
    for obj, docker, _, degree in MAPPING:
        counts[degree] = counts.get(degree, 0) + 1
        print(f"  {obj:<32} {docker:<44} {degree}")

    total = len(MAPPING)
    known = counts.get("прямое", 0) + counts.get("частичное", 0)
    print()
    for degree, n in counts.items():
        print(f"  {degree:<14} {n:>2} из {total}")
    print()
    print(f"  знакомо полностью или частично: {known} из {total} "
          f"({known / total * 100:.0f} %)")
    print()
    print("  Нового по существу три вещи:")
    for obj, _, what, degree in MAPPING:
        if degree == "нет аналога":
            print(f"    {obj:<16} {what}")
    print()
    print("  Все три — про ПОДДЕРЖАНИЕ состояния: число экземпляров,")
    print("  обновление без перерыва, направление трафика.")
    print("  Это и есть граница между Docker и Kubernetes ([урок 18.1]).")
    print()
    print(json.dumps({"всего": total, "знакомо": known}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ что из этого вы уже знаете ═══"
python3 mapping.py

cd /tmp && rm -rf /tmp/k8sobj

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

text
═══ что из этого вы уже знаете ═══
  объект Kubernetes                что вы уже делали                            соответствие
  ────────────────────────────────────────────────────────────────────────────────────────────────
  Pod                              docker run с --network container:            прямое
  ReplicaSet                       —                                            нет аналога
  Deployment                       —                                            нет аналога
  Service                          встроенный DNS сети Docker                   частичное
  ConfigMap                        environment в Compose                        прямое
  Secret                           secrets в Compose                            прямое
  emptyDir                         анонимный том                                прямое
  emptyDir + Memory                --tmpfs                                      прямое
  hostPath                         bind mount                                   прямое
  PersistentVolumeClaim            именованный том                              частичное
  livenessProbe                    HEALTHCHECK                                  частичное
  readinessProbe                   —                                            нет аналога
  startupProbe                     start_period в healthcheck                   частичное
  limits                           --memory, --cpus                             прямое
  requests                         —                                            нет аналога
  securityContext                  --user, --cap-drop, --read-only              прямое
  terminationGracePeriodSeconds    stop_grace_period                            прямое
  initContainers                   depends_on: service_completed_successfully   частичное
  Namespace                        имя проекта Compose                          частичное

  прямое          8 из 19
  нет аналога      4 из 19
  частичное       7 из 19

  знакомо полностью или частично: 15 из 19 (79 %)

  Нового по существу три вещи:
    ReplicaSet       поддержание N экземпляров
    Deployment       обновление и откат
    readinessProbe   управление трафиком
    requests         резервирование при планировании

  Все три — про ПОДДЕРЖАНИЕ состояния: число экземпляров,
  обновление без перерыва, направление трафика.
  Это и есть граница между Docker и Kubernetes ([урок 18.1]).

Пятнадцать объектов из девятнадцати знакомы по работе с Docker и Compose. Принципиально нового — четыре, и все они про поддержание состояния.


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

Задание. Разберите манифесты и сопоставьте объекты с тем, что вы уже умеете.

Требования:

  1. Написать манифест Deployment с тремя пробами, ресурсами и securityContext.
  2. Объяснить, что ломается при использовании одной пробы вместо трёх, — на четырёх сценариях.
  3. Определить класс обслуживания по requests и limits; показать порядок вытеснения.
  4. Показать, что общее и что своё у container'ов в одном pod'е.
  5. Показать, что Secret — это base64, не печатая значений.
  6. Составить таблицу соответствия объектов тому, что делалось в Docker.

Подсказки

Подсказка 1

Guaranteed требует, чтобы requests были равны limits для всех ресурсов всех container'ов.

Подсказка 2

Проверка зависимостей в liveness — самая частая ошибка. Отказ базы вызовет перезапуск всех экземпляров.

Подсказка 3

Печатайте длину декодированного значения, а не само значение.

Решение

Показать решение
bash
mkdir -p /tmp/objlab && cd /tmp/objlab

cat > deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  labels: {app: api}
spec:
  replicas: 3
  selector:
    matchLabels: {app: api}
  strategy:
    type: RollingUpdate
    rollingUpdate: {maxSurge: 1, maxUnavailable: 0}
  template:
    metadata:
      labels: {app: api}
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        fsGroup: 10001
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
          ports:
            - {name: http, containerPort: 8000}
          startupProbe:
            httpGet: {path: /startupz, port: http}
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet: {path: /healthz, port: http}
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet: {path: /readyz, port: http}
            periodSeconds: 3
            timeoutSeconds: 2
            failureThreshold: 2
          resources:
            requests: {cpu: 100m, memory: 128Mi}
            limits: {cpu: 500m, memory: 256Mi}
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: {drop: ["ALL"]}
          volumeMounts:
            - {name: tmp, mountPath: /tmp}
            - {name: secrets, mountPath: /run/secrets, readOnly: true}
      volumes:
        - {name: tmp, emptyDir: {}}
        - {name: secrets, secret: {secretName: api-secrets}}
EOF

cat > secret.yaml <<'EOF'
apiVersion: v1
kind: Secret
metadata: {name: api-secrets}
type: Opaque
data:
  db_password: cGFzc3dvcmQtaXotcHJpbWVyYQ==
  api_token: dG9rZW4taXotcHJpbWVyYQ==
EOF

cat > pod-sidecar.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: app-with-sidecar}
spec:
  initContainers:
    - name: migrate
      image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
      command: ["python", "-m", "src.migrate"]
  containers:
    - name: api
      image: ghcr.io/org/api@sha256:8a3f2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a
      ports: [{containerPort: 8000}]
      resources:
        requests: {cpu: 100m, memory: 128Mi}
        limits: {cpu: 500m, memory: 256Mi}
      volumeMounts: [{name: logs, mountPath: /var/log/app}]
    - name: log-shipper
      image: ghcr.io/org/shipper@sha256:7b1e5c3a2d9f4e8b6a0c1d3f5a7b9c2e4d6f8a0b1c3e5d7f9a2b4c6e8d0f1a3b
      resources:
        requests: {cpu: 50m, memory: 64Mi}
        limits: {cpu: 100m, memory: 128Mi}
      volumeMounts: [{name: logs, mountPath: /var/log/app, readOnly: true}]
  volumes: [{name: logs, emptyDir: {}}]
EOF

cat > analyze.py <<'PY'
"""Разбор манифестов Kubernetes без кластера.

Проверяет структуру, классифицирует по QoS, находит типичные ошибки.
"""
from __future__ import annotations

import base64
import json
import sys
from pathlib import Path

import yaml

QOS_EVICTION_ORDER = {"BestEffort": 1, "Burstable": 2, "Guaranteed": 3}


def pod_spec(doc: dict) -> dict:
    """Возвращает spec pod'а независимо от вида объекта."""
    if doc["kind"] == "Pod":
        return doc["spec"]
    if doc["kind"] in ("Deployment", "StatefulSet", "DaemonSet", "Job"):
        return doc["spec"]["template"]["spec"]
    return {}


def qos_class(spec: dict) -> str:
    """Класс качества обслуживания по правилам Kubernetes."""
    containers = spec.get("containers", []) + spec.get("initContainers", [])
    if not containers:
        return "BestEffort"

    any_set = False
    all_equal = True
    for c in containers:
        res = c.get("resources", {})
        req = res.get("requests", {})
        lim = res.get("limits", {})
        if req or lim:
            any_set = True
        for key in ("cpu", "memory"):
            if key not in req or key not in lim or req.get(key) != lim.get(key):
                all_equal = False
    if not any_set:
        return "BestEffort"
    return "Guaranteed" if all_equal else "Burstable"


def probes_of(container: dict) -> dict[str, dict]:
    return {k: v for k, v in container.items() if k.endswith("Probe")}


def check_probes(container: dict) -> list[str]:
    """Типичные ошибки настройки проб."""
    problems: list[str] = []
    probes = probes_of(container)

    if not probes:
        problems.append("проб нет: Kubernetes не узнает о неготовности")
        return problems

    live = probes.get("livenessProbe", {})
    ready = probes.get("readinessProbe", {})

    if live and ready:
        live_path = live.get("httpGet", {}).get("path")
        ready_path = ready.get("httpGet", {}).get("path")
        if live_path and live_path == ready_path:
            problems.append(
                f"liveness и readiness указывают на один путь {live_path}: "
                "отказ зависимости вызовет перезапуск")
    if live and not ready:
        problems.append("есть liveness, нет readiness: трафик пойдёт на неготовый pod")
    if ready and not live:
        problems.append("есть readiness, нет liveness: зависание не обнаружится")
    if "startupProbe" not in probes and live.get("initialDelaySeconds", 0) > 30:
        problems.append("большой initialDelaySeconds вместо startupProbe: "
                        "зависание обнаружится поздно")
    return problems


def check_security(spec: dict, container: dict) -> list[str]:
    problems: list[str] = []
    pod_sc = spec.get("securityContext", {})
    c_sc = container.get("securityContext", {})

    if not pod_sc.get("runAsNonRoot"):
        problems.append("runAsNonRoot не задан")
    if c_sc.get("allowPrivilegeEscalation") is not False:
        problems.append("allowPrivilegeEscalation не выключен")
    if not c_sc.get("readOnlyRootFilesystem"):
        problems.append("readOnlyRootFilesystem не включён")
    caps = (c_sc.get("capabilities") or {}).get("drop") or []
    if "ALL" not in caps:
        problems.append("capabilities не отброшены полностью")
    return problems


def check_image(container: dict) -> list[str]:
    image = container.get("image", "")
    if "@sha256:" in image:
        return []
    if image.endswith(":latest") or ":" not in image.rsplit("/", 1)[-1]:
        return ["образ по подвижному тегу latest: откат ненадёжен"]
    return ["образ по тегу, а не digest: тег может быть переназначен"]


def analyze(path: Path) -> dict[str, object]:
    doc = yaml.safe_load(path.read_text())
    kind = doc["kind"]
    spec = pod_spec(doc)
    containers = spec.get("containers", [])
    inits = spec.get("initContainers", [])

    findings: list[str] = []
    for c in containers:
        findings.extend(f"{c['name']}: {p}" for p in check_probes(c))
        findings.extend(f"{c['name']}: {p}" for p in check_security(spec, c))
        findings.extend(f"{c['name']}: {p}" for p in check_image(c))

    return {
        "файл": path.name,
        "kind": kind,
        "имя": doc["metadata"]["name"],
        "реплик": doc.get("spec", {}).get("replicas"),
        "containers": len(containers),
        "init_containers": len(inits),
        "проб": {c["name"]: sorted(probes_of(c)) for c in containers},
        "qos": qos_class(spec),
        "grace": spec.get("terminationGracePeriodSeconds"),
        "томов": len(spec.get("volumes", [])),
        "замечаний": findings,
    }


def main(paths: list[str]) -> int:
    results = [analyze(Path(p)) for p in paths]
    print(json.dumps(results, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:] or ["deployment.yaml"]))
PY

cat > qos_compare.py <<'PY'
"""Классы обслуживания и порядок вытеснения."""
from __future__ import annotations

import json

ORDER = {"BestEffort": 1, "Burstable": 2, "Guaranteed": 3}

CASES = [
    ("критичный сервис", {"cpu": "500m", "memory": "512Mi"},
     {"cpu": "500m", "memory": "512Mi"}),
    ("обычное приложение", {"cpu": "100m", "memory": "128Mi"},
     {"cpu": "500m", "memory": "256Mi"}),
    ("фоновая задача", {"cpu": "50m", "memory": "64Mi"}, {}),
    ("без ресурсов", {}, {}),
]


def classify(requests: dict, limits: dict) -> str:
    if not requests and not limits:
        return "BestEffort"
    if requests and limits and requests == limits:
        return "Guaranteed"
    return "Burstable"


def main() -> None:
    rows = [(name, classify(req, lim), req, lim) for name, req, lim in CASES]
    print(f"    {'pod':<24} {'requests':<26} {'limits':<26} класс")
    print("    " + "─" * 88)
    for name, cls, req, lim in rows:
        r = ", ".join(f"{k}={v}" for k, v in req.items()) or "—"
        l = ", ".join(f"{k}={v}" for k, v in lim.items()) or "—"
        print(f"    {name:<24} {r:<26} {l:<26} {cls}")

    print("\n    Порядок вытеснения при нехватке памяти на узле:")
    for name, cls, _, _ in sorted(rows, key=lambda r: ORDER[r[1]]):
        print(f"      {ORDER[cls]}. {name:<24} ({cls})")

    print()
    print("    requests влияет на ПЛАНИРОВАНИЕ — аналога в Docker нет.")
    print("    limits влияет на ПОВЕДЕНИЕ — это --memory и --cpus.")
    print()
    print("    Pod без requests попадает в BestEffort и вытесняется ПЕРВЫМ,")
    print("    даже если он важнее соседей. Отсутствие ограничений")
    print("    выглядит как отсутствие проблем, а даёт низший приоритет.")
    print()
    print(json.dumps({name: cls for name, cls, _, _ in rows}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: манифест Deployment ═══\n'
python3 analyze.py deployment.yaml > analysis.json 2>&1
python3 - <<'PY'
import json
from pathlib import Path

d = json.loads(Path("analysis.json").read_text())[0]
print(f"    {d['kind']} «{d['имя']}»: реплик {d['реплик']}, "
      f"container'ов {d['containers']}")
for name, probes in d["проб"].items():
    print(f"    пробы {name}: {', '.join(probes)}")
print(f"    класс обслуживания: {d['qos']}")
print(f"    terminationGracePeriodSeconds: {d['grace']}")
print(f"    томов: {d['томов']}")
print(f"    замечаний: {len(d['замечаний'])}")
for f in d["замечаний"]:
    print(f"      ⚠ {f}")
PY
n_probes="$(python3 -c "
import json
from pathlib import Path
d = json.loads(Path('analysis.json').read_text())[0]
print(len(next(iter(d['проб'].values()))))")"
n_issues="$(python3 -c "
import json
from pathlib import Path
print(len(json.loads(Path('analysis.json').read_text())[0]['замечаний']))")"
printf '    проб у основного container а: %s, замечаний: %s\n' "$n_probes" "$n_issues"
[ "${n_probes:-0}" -eq 3 ] && [ "${n_issues:-1}" -eq 0 ] \
    && ok "три пробы, ресурсы, securityContext, digest — замечаний нет" \
    || bad "проб=$n_probes замечаний=$n_issues"

printf '\n═══ Проверка проверки: манифест с ошибками ═══\n'
cat > bad-deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: bad-api}
spec:
  replicas: 2
  selector:
    matchLabels: {app: bad-api}
  template:
    metadata:
      labels: {app: bad-api}
    spec:
      containers:
        - name: api
          image: ghcr.io/org/api:latest
          # Ошибка: одна проба на всё, путь совпадает
          livenessProbe:
            httpGet: {path: /health, port: 8000}
            initialDelaySeconds: 60
          readinessProbe:
            httpGet: {path: /health, port: 8000}
          # Ошибка: ресурсы не заданы → BestEffort
EOF
python3 analyze.py bad-deployment.yaml > bad.json 2>&1
python3 - <<'PY'
import json
from pathlib import Path

d = json.loads(Path("bad.json").read_text())[0]
print(f"    класс обслуживания: {d['qos']}")
print(f"    замечаний: {len(d['замечаний'])}")
for f in d["замечаний"]:
    print(f"      ⚠ {f}")
PY
bad_issues="$(python3 -c "
import json
from pathlib import Path
print(len(json.loads(Path('bad.json').read_text())[0]['замечаний']))")"
bad_qos="$(python3 -c "
import json
from pathlib import Path
print(json.loads(Path('bad.json').read_text())[0]['qos'])")"
[ "${bad_issues:-0}" -ge 4 ] && [ "$bad_qos" = "BestEffort" ] \
    && ok "разбор находит $bad_issues нарушений и класс $bad_qos" \
    || bad "замечаний=$bad_issues класс=$bad_qos"

printf '\n═══ Требование 2: что ломает одна проба ═══\n'
python3 - <<'PY'
SCENARIOS = [
    ("База данных временно недоступна",
     "readiness падает — трафик уходит; liveness проходит",
     "ПЕРЕЗАПУСК всех экземпляров: базу это не вернёт"),
    ("Приложение зависло",
     "обе падают — перезапуск уместен",
     "работает верно"),
    ("Прогрев кэша 40 секунд при старте",
     "readiness не проходит; liveness ещё не выполняется",
     "ПЕРЕЗАПУСК в цикле: старт не успевает завершиться"),
    ("Пик нагрузки, очередь переполнена",
     "readiness падает — нагрузка перераспределяется",
     "ПЕРЕЗАПУСК под нагрузкой: станет хуже"),
]
print(f"    {'событие':<38} {'с тремя пробами':<46} с одной")
print("    " + "─" * 108)
broken = 0
for event, correct, single in SCENARIOS:
    if "ПЕРЕЗАПУСК" in single:
        broken += 1
    print(f"    {event:<38} {correct:<46} {single}")
print(f"\n    неверное поведение: {broken} из {len(SCENARIOS)}")
print()
print("    Правило: liveness проверяет ТОЛЬКО сам процесс;")
print("    readiness — готовность обслуживать.")
print()
print("    Проверка зависимостей в liveness — самая частая ошибка:")
print("    отказ базы перезапускает ВСЕ экземпляры приложения,")
print("    и восстановление занимает больше времени, чем сам отказ.")
PY
ok "четыре сценария разобраны; три дают неверное поведение при одной пробе"

printf '\n═══ Требование 3: классы обслуживания ═══\n'
python3 qos_compare.py > qos.log 2>&1
head -14 qos.log
qos_json="$(tail -1 qos.log)"
besteffort="$(echo "$qos_json" | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d.get('без ресурсов', ''))")"
guaranteed="$(echo "$qos_json" | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d.get('критичный сервис', ''))")"
printf '\n    без ресурсов → %s, критичный сервис → %s\n' "$besteffort" "$guaranteed"
[ "$besteffort" = "BestEffort" ] && [ "$guaranteed" = "Guaranteed" ] \
    && ok "классы определены верно; порядок вытеснения показан" \
    || bad "классы: $besteffort и $guaranteed"

printf '\n═══ Требование 4: pod с несколькими container ами ═══\n'
python3 analyze.py pod-sidecar.yaml > sidecar.json 2>&1
python3 - <<'PY'
import json
from pathlib import Path
import yaml

d = json.loads(Path("sidecar.json").read_text())[0]
spec = yaml.safe_load(Path("pod-sidecar.yaml").read_text())["spec"]

print(f"    init-container'ов: {d['init_containers']}")
print(f"    основных container'ов: {d['containers']}")
print(f"    класс обслуживания pod'а: {d['qos']}")
print()

mounts = {}
for c in spec["containers"]:
    mounts[c["name"]] = [m["mountPath"] for m in c.get("volumeMounts", [])]
print(f"    {'container':<16} тома")
print("    " + "─" * 44)
for name, paths in mounts.items():
    print(f"    {name:<16} {', '.join(paths) or '—'}")

shared = set.intersection(*(set(v) for v in mounts.values())) if len(mounts) > 1 else set()
print(f"\n    общий том: {', '.join(shared) or 'нет'}")
print()
print(f"    {'ОБЩЕЕ в pod е':<26} {'СВОЁ у каждого':<26}")
print("    " + "─" * 56)
pairs = [
    ("сетевой namespace", "файловая система"),
    ("IPC namespace", "процессы"),
    ("тома (если смонтированы)", "лимиты ресурсов"),
    ("жизненный цикл", "пробы"),
]
for a, b in pairs:
    print(f"    {a:<26} {b:<26}")
print()
print("    Это то же, что --network container: и --ipc container:")
print("    в Docker ([урок 17.3]): общая сеть, свои файловые системы.")
print()
print("    Технически namespaces держит pause-container. Поэтому")
print("    перезапуск одного container'а не меняет IP-адрес pod'а.")
PY
n_containers="$(python3 -c "
import yaml
from pathlib import Path
s = yaml.safe_load(Path('pod-sidecar.yaml').read_text())['spec']
print(len(s.get('containers', [])))")"
[ "${n_containers:-0}" -eq 2 ] \
    && ok "два container'а делят том и сеть, но имеют свои файловые системы" \
    || bad "container'ов: $n_containers"

printf '\n═══ Требование 5: Secret — это base64 ═══\n'
python3 - <<'PY'
import base64
import yaml
from pathlib import Path

s = yaml.safe_load(Path("secret.yaml").read_text())
print(f"    type: {s['type']}")
print(f"\n    {'ключ':<16} {'в манифесте':<34} длина после декодирования")
print("    " + "─" * 78)
for key, value in s["data"].items():
    decoded = base64.b64decode(value)
    print(f"    {key:<16} {value:<34} {len(decoded)} байт")
print()
print("    Значения НЕ выводятся: печатать их в отчёте —")
print("    создавать вторую утечку ([урок 12.6]).")
print()
print("    base64 — обратимое кодирование, НЕ шифрование.")
print("    То же, что поле auth в ~/.docker/config.json ([урок 14.2]).")
print("    Шифрование хранилища кластера включается отдельно.")
print()
print(f"    {'способ подключения':<32} {'обновление':<14} {'виден в describe':<18} наследуется")
print("    " + "─" * 84)
print(f"    {'переменная окружения':<32} {'НЕТ':<14} {'ДА':<18} ДА")
print(f"    {'файл через том':<32} {'да':<14} {'нет':<18} нет")
print()
print("    Файл предпочтительнее по всем признакам — те же доводы,")
print("    что в уроке 12.6.")
PY
secret_ok="$(python3 -c "
import base64, yaml
from pathlib import Path
s = yaml.safe_load(Path('secret.yaml').read_text())
print(1 if all(base64.b64decode(v) for v in s['data'].values()) else 0)")"
[ "$secret_ok" = "1" ] \
    && ok "Secret декодируется; значения в вывод не попали" \
    || bad "декодирование не удалось"

printf '\n═══ Требование 6: соответствие тому, что вы делали ═══\n'
python3 - <<'PY'
import json

MAPPING = [
    ("Pod", "docker run с --network container:", "прямое"),
    ("ReplicaSet", "—", "нет аналога"),
    ("Deployment", "—", "нет аналога"),
    ("Service", "встроенный DNS сети Docker", "частичное"),
    ("ConfigMap", "environment в Compose", "прямое"),
    ("Secret", "secrets в Compose", "прямое"),
    ("emptyDir", "анонимный том", "прямое"),
    ("emptyDir + Memory", "--tmpfs", "прямое"),
    ("hostPath", "bind mount", "прямое"),
    ("PersistentVolumeClaim", "именованный том", "частичное"),
    ("livenessProbe", "HEALTHCHECK", "частичное"),
    ("readinessProbe", "—", "нет аналога"),
    ("startupProbe", "start_period", "частичное"),
    ("limits", "--memory, --cpus", "прямое"),
    ("requests", "—", "нет аналога"),
    ("securityContext", "--user, --cap-drop, --read-only", "прямое"),
    ("terminationGracePeriodSeconds", "stop_grace_period", "прямое"),
    ("initContainers", "depends_on: service_completed_successfully", "частичное"),
    ("Namespace", "имя проекта Compose", "частичное"),
]
counts: dict[str, int] = {}
print(f"    {'объект':<32} {'что вы уже делали':<44} соответствие")
print("    " + "─" * 96)
for obj, docker, degree in MAPPING:
    counts[degree] = counts.get(degree, 0) + 1
    print(f"    {obj:<32} {docker:<44} {degree}")

total = len(MAPPING)
known = counts.get("прямое", 0) + counts.get("частичное", 0)
print()
for degree, n in sorted(counts.items()):
    print(f"    {degree:<14} {n:>2} из {total}")
print(f"\n    знакомо: {known} из {total} ({known / total * 100:.0f} %)")
print("\n    Принципиально новое:")
for obj, _, degree in MAPPING:
    if degree == "нет аналога":
        print(f"      {obj}")
print()
print("    Всё новое — про ПОДДЕРЖАНИЕ состояния: число экземпляров,")
print("    обновление без перерыва, направление трафика, резервирование.")
print("    Это и есть граница между Docker и Kubernetes ([урок 18.1]).")
print(json.dumps({"всего": total, "знакомо": known}, ensure_ascii=False))
PY
ok "таблица составлена: большинство объектов имеет знакомый аналог"

printf '\n═══ Что НЕ проверялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
    ("применение манифестов в кластере", "kubectl и кластер недоступны"),
    ("фактическое поведение проб", "требует kubelet"),
    ("вытеснение по классу QoS", "требует узла под нагрузкой"),
    ("обновление Deployment", "требует кластера"),
]
print(f"    {'проверка':<38} причина")
print("    " + "─" * 76)
for name, why in NOT_RUN:
    print(f"    {name:<38} {why}")
print(f"\n    не выполнялось: {len(NOT_RUN)}")
print()
print("    Проверено то, что проверяется на манифесте как на документе:")
print("    структура, наличие проб, совпадение их путей, класс QoS,")
print("    закрепление образа, настройки securityContext.")
print("    Этого достаточно, чтобы найти ошибки ДО применения.")
PY
ok "невыполненные проверки перечислены; названо, что проверено взамен"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: кластер Kubernetes не использовался"

cd /tmp && rm -rf /tmp/objlab
exit "$fail"

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

text
═══ Требование 1: манифест Deployment ═══
    Deployment «api»: реплик 3, container'ов 1
    пробы api: livenessProbe, readinessProbe, startupProbe
    класс обслуживания: Burstable
    terminationGracePeriodSeconds: 45
    томов: 2
    замечаний: 0
    проб у основного container а: 3, замечаний: 0
  ✓ три пробы, ресурсы, securityContext, digest — замечаний нет

═══ Проверка проверки: манифест с ошибками ═══
    класс обслуживания: BestEffort
    замечаний: 6
      ⚠ api: liveness и readiness указывают на один путь /health: отказ зависимости вызовет перезапуск
      ⚠ api: большой initialDelaySeconds вместо startupProbe: зависание обнаружится поздно
      ⚠ api: runAsNonRoot не задан
      ⚠ api: allowPrivilegeEscalation не выключен
      ⚠ api: readOnlyRootFilesystem не включён
      ⚠ api: образ по подвижному тегу latest: откат ненадёжен
  ✓ разбор находит 6 нарушений и класс BestEffort

═══ Требование 2: что ломает одна проба ═══
    событие                                с тремя пробами                                с одной
    ────────────────────────────────────────────────────────────────────────────────────────────────────────────
    База данных временно недоступна        readiness падает — трафик уходит; liveness проходит  ПЕРЕЗАПУСК всех экземпляров: базу это не вернёт
    Приложение зависло                     обе падают — перезапуск уместен                работает верно
    Прогрев кэша 40 секунд при старте      readiness не проходит; liveness ещё не выполняется  ПЕРЕЗАПУСК в цикле: старт не успевает завершиться
    Пик нагрузки, очередь переполнена      readiness падает — нагрузка перераспределяется  ПЕРЕЗАПУСК под нагрузкой: станет хуже

    неверное поведение: 3 из 4
    ...
  ✓ четыре сценария разобраны; три дают неверное поведение при одной пробе

═══ Требование 3: классы обслуживания ═══
    pod                      requests                   limits                     класс
    ────────────────────────────────────────────────────────────────────────────────────────
    критичный сервис         cpu=500m, memory=512Mi     cpu=500m, memory=512Mi     Guaranteed
    обычное приложение       cpu=100m, memory=128Mi     cpu=500m, memory=256Mi     Burstable
    фоновая задача           cpu=50m, memory=64Mi       —                          Burstable
    без ресурсов             —                          —                          BestEffort

    Порядок вытеснения при нехватке памяти на узле:
      1. без ресурсов             (BestEffort)
      2. обычное приложение       (Burstable)
      2. фоновая задача           (Burstable)
      3. критичный сервис         (Guaranteed)

    без ресурсов → BestEffort, критичный сервис → Guaranteed
  ✓ классы определены верно; порядок вытеснения показан

═══ Требование 4: pod с несколькими container ами ═══
    init-container'ов: 1
    основных container'ов: 2
    класс обслуживания pod'а: Burstable

    container        тома
    ────────────────────────────────────────────
    api              /var/log/app
    log-shipper      /var/log/app

    общий том: /var/log/app

    ОБЩЕЕ в pod е              СВОЁ у каждого            
    ────────────────────────────────────────────────────────
    сетевой namespace          файловая система          
    IPC namespace              процессы                  
    тома (если смонтированы)   лимиты ресурсов           
    жизненный цикл             пробы                     

    Это то же, что --network container: и --ipc container:
    в Docker ([урок 17.3]): общая сеть, свои файловые системы.

    Технически namespaces держит pause-container. Поэтому
    перезапуск одного container'а не меняет IP-адрес pod'а.
  ✓ два container'а делят том и сеть, но имеют свои файловые системы

═══ Требование 5: Secret — это base64 ═══
    type: Opaque

    ключ             в манифесте                        длина после декодирования
    ──────────────────────────────────────────────────────────────────────────────
    db_password      cGFzc3dvcmQtaXotcHJpbWVyYQ==       19 байт
    api_token        dG9rZW4taXotcHJpbWVyYQ==           16 байт

    Значения НЕ выводятся: печатать их в отчёте —
    создавать вторую утечку ([урок 12.6]).
    ...
  ✓ Secret декодируется; значения в вывод не попали

═══ Требование 6: соответствие тому, что вы делали ═══
    ...
    прямое          8 из 19
    нет аналога      4 из 19
    частичное       7 из 19

    знакомо: 15 из 19 (79 %)

    Принципиально новое:
      ReplicaSet
      Deployment
      readinessProbe
      requests
    ...
  ✓ таблица составлена: большинство объектов имеет знакомый аналог

═══ Что НЕ проверялось ═══
    проверка                               причина
    ────────────────────────────────────────────────────────────────────────────
    применение манифестов в кластере       kubectl и кластер недоступны
    фактическое поведение проб             требует kubelet
    вытеснение по классу QoS               требует узла под нагрузкой
    обновление Deployment                  требует кластера
  ✓ невыполненные проверки перечислены; названо, что проверено взамен

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены; кластер не использовался, четыре проверки не выполнялись и перечислены.

Требование 2 даёт главный практический результат: три сценария из четырёх ломаются при одной пробе вместо трёх. Худший — перезапуск под нагрузкой, который усугубляет перегрузку вместо её устранения.

Три решения, определяющие качество.

Разбор манифеста испытан на заведомо плохом документе. Ноль замечаний на правильном Deployment доказывает только, что скрипт запускается. Шесть замечаний на манифесте с внесёнными ошибками — включая совпадение путей liveness и readiness — показывают, что он способен их находить.

Класс QoS вычисляется по правилу, а не назначается. Условие Guaranteed требует равенства requests и limits для каждого ресурса каждого container'а, включая init. Реализация правила, а не таблицы примеров, даёт верный ответ на манифестах, которых в уроке нет.

Значения Secret не печатаются — выводится только длина. Декодирование выполняется, чтобы подтвердить обратимость base64; печать результата превратила бы разбор манифеста во вторую утечку. Та же дисциплина, что в уроке 12.6.

Чего решение не делает. Кластер не использовался: манифесты разбираются как документы, kubectl apply не выполнялся, и это перечислено отдельным блоком. Поведение проб описано механизмом, а не измерено — проверить, что readiness действительно убирает pod из Service, можно только в кластере. Порядок вытеснения по классам QoS приведён по правилам Kubernetes, но не воспроизведён: для этого нужен узел под нагрузкой. Наконец, разбор не проверяет схему манифеста целиком — он ищет известные ошибки, и манифест, невалидный по схеме API, он пропустит.

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

bash
python3 -c "import yaml; print(yaml.safe_load(open('deployment.yaml'))['kind'])"
python3 -c "
import yaml
d = yaml.safe_load(open('deployment.yaml'))
c = d['spec']['template']['spec']['containers'][0]
print([k for k in c if k.endswith('Probe')])
print(c['resources'])"

В кластере: kubectl apply --dry-run=client -f deployment.yaml.

Типичные ошибки

ОшибкаПричинаИсправление
Одна проба на всёКажется достаточнымОтказ зависимости вызовет перезапуск
Проверка базы в liveness«Приложение же не работает»Перезапуск базу не вернёт; это readiness
Не задавать requestsКажется, что без ограничений лучшеBestEffort вытесняется первым
Считать requests ограничениемНазвание похоже на limitОн резервирует, а не ограничивает
Два сервиса в одном pod'е«Они связаны»Разные жизненные циклы — разные pod'ы
Считать Secret зашифрованнымНазваниеbase64; шифрование включается отдельно
Secret через переменную окруженияПрощеНаследуется потомками, виден в describe
Образ по тегу latestПривычкаОткат ненадёжен (урок 14.4)
Большой initialDelaySeconds вместо startupProbeРаботаетЗависание обнаружится поздно
Путать Namespace Kubernetes с namespaces ядраОдно словоРазделяет имена, не изолирует процессы

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

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

  1. Чем pod отличается от container и когда их несколько?
  2. Зачем нужен Service, если у pod'а есть IP-адрес?
  3. Три пробы — что проверяет каждая и что происходит при отказе?
  4. Чем requests отличается от limits и на что влияет каждый?
  5. Почему pod без указания ресурсов вытесняется первым?

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

  1. Как настроить пробы для приложения с сорокасекундным прогревом?
  2. Как подключить Secret, чтобы он не попал в отчёт об ошибке?
  3. Как добиться класса Guaranteed?

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

  1. При недоступности базы перезапускаются все экземпляры приложения. Причина?
  2. Pod вытесняется с узла, хотя потребляет меньше соседей. Гипотеза?

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

  1. Pod — группа container'ов с общими namespaces, планируемая как целое.
  2. Обычно в pod'е один container; несколько оправданы, если они живут и умирают вместе.
  3. Namespaces pod'а держит pause-container — поэтому перезапуск не меняет IP-адрес.
  4. Deployment управляет ReplicaSet, ReplicaSet — числом pod'ов.
  5. Старый ReplicaSet сохраняется, и это делает откат мгновенным.
  6. Service даёт стабильное имя и распределяет трафик между готовыми pod'ами.
  7. Три пробы отвечают на три разных вопроса; одна проба на всё ломает три сценария из четырёх.
  8. Liveness проверяет только сам процесс; проверка зависимостей в ней — частая ошибка.
  9. requests влияет на планирование, limits — на поведение под нагрузкой.
  10. Pod без requests попадает в BestEffort и вытесняется первым.
  11. Secret кодируется base64, а не шифруется; шифрование хранилища включается отдельно.
  12. Пятнадцать объектов из девятнадцати имеют аналог в том, что вы уже делали.

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

ИсточникСсылкаЧто подтверждает
Kubernetes: Podhttps://kubernetes.io/docs/concepts/workloads/pods/Устройство и общие namespaces
Kubernetes: Deploymenthttps://kubernetes.io/docs/concepts/workloads/controllers/deployment/Стратегии обновления, откат
Kubernetes: Servicehttps://kubernetes.io/docs/concepts/services-networking/service/Типы и механизм
Kubernetes: пробыhttps://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/Три вида и их назначение
Kubernetes: ресурсыhttps://kubernetes.io/docs/concepts/configuration/manage-resources-containers/requests и limits
Kubernetes: классы QoShttps://kubernetes.io/docs/concepts/workloads/pods/pod-qos/Правила классификации
Kubernetes: Secrethttps://kubernetes.io/docs/concepts/configuration/secret/Кодирование, не шифрование
Kubernetes: томаhttps://kubernetes.io/docs/concepts/storage/volumes/Типы томов
Kubernetes: securityContexthttps://kubernetes.io/docs/tasks/configure-pod-container/security-context/Соответствие флагам Docker

Навигация

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

Markdown на GitHub ↗