18.2. Объекты Kubernetes
Цели
После этого материала вы сможете:
- объяснить, чем pod отличается от container, и назвать случай, когда их несколько;
- различить Deployment, ReplicaSet и pod по зоне ответственности;
- объяснить, зачем нужен Service, если у pod'а есть адрес;
- назвать три вида проб и указать, что ломается при неверном выборе;
- объяснить разницу requests и limits и её последствия для планирования;
- сопоставить каждый объект с тем, что вы уже делали в Docker.
Предварительные знания
- 18.1. Какие задачи решают Docker и Kubernetes;
- 11.3. Healthchecks и readiness;
- 13.3. Нехватка ресурсов — OOM и throttling.
Кластер для этого урока не требуется. Манифесты разбираются как документы; поведение объясняется механизмом, а не запуском.
Ключевые термины
| Термин | Объяснение |
|---|---|
Pod | Минимальная единица запуска: один или несколько container'ов |
Deployment | Описание желаемого состояния набора pod'ов |
ReplicaSet | Поддерживает заданное число одинаковых pod'ов |
Service | Стабильный адрес для набора pod'ов |
requests | Что гарантировано; влияет на планирование |
limits | Потолок; влияет на поведение под нагрузкой |
Теория
Pod — не то же самое, что container
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
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 пересоздан — адрес другой.
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 совпадают, то временная недоступность базы приведёт к перезапуску приложения — хотя перезапуск ничем не поможет.
База недоступна:
readiness должна упасть → трафик перестанет идти, pod останется жив
liveness должна пройти → перезапуск не восстановит базу
Правило: liveness проверяет только сам процесс; readiness — готовность обслуживать.
startupProbe нужна приложениям с долгим стартом: без неё пришлось бы задавать большой initialDelaySeconds у liveness, и настоящее зависание обнаруживалось бы поздно.
Это уточняет модель из урока 11.3: там healthcheck один, здесь их три, и каждая управляет своим.
requests и limits
requests | limits | |
|---|---|---|
| Что означает | Гарантировано | Потолок |
| На что влияет | Планирование: выбор узла | Поведение под нагрузкой |
| Память при превышении | — | OOM kill |
| CPU при превышении | — | Throttling |
| Аналог в Docker | Нет | --memory, --cpus |
Первая строка — то, чего нет в Docker. requests не ограничивает; он сообщает планировщику, сколько ресурса нужно зарезервировать.
Отсюда три класса качества обслуживания:
| Класс | Условие | Кого убьют первым при нехватке |
|---|---|---|
Guaranteed | requests == limits для всех ресурсов | Последним |
Burstable | requests < 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 | Данные в памяти |
hostPath | Bind mount | Каталог узла — применять осторожно |
PersistentVolumeClaim | Именованный том | Постоянное хранилище |
configMap, secret | Bind 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-пробу, исключается из набора назначений — трафик к нему просто перестаёт направляться.
Команды и примеры
Манифест как документ
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
Ожидаемый вывод:
═══ проверка синтаксиса ═══
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.
Три пробы: что ломается при одной
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
Ожидаемый вывод:
═══ три пробы ═══
проба вопрос при отказе
────────────────────────────────────────────────────────────────────────────────────────────
startupProbe инициализация завершена? остальные пробы не выполняются, пока не пройдёт
livenessProbe процесс жив? container ПЕРЕЗАПУСКАЕТСЯ
readinessProbe готов принимать трафик? pod убирается из Service, но НЕ перезапускается
startupProbe нужна для: приложение с долгим стартом
livenessProbe нужна для: зависание, взаимоблокировка, утечка дескрипторов
readinessProbe нужна для: прогрев, недоступная зависимость, перегрузка
событие readiness если ОДНА проба
──────────────────────────────────────────────────────────────────────────────────────────────────────────
База данных временно недоступна должна упасть — трафик уйдёт на другие pod'ы ПЕРЕЗАПУСК: бесполезный и вредный
Приложение зависло, не отвечает упадёт — трафик уйдёт работает верно
Прогрев кэша при старте, 40 секунд не пройдёт — трафика не будет ПЕРЕЗАПУСК в цикле: старт не успевает
Пик нагрузки, очередь переполнена упадёт — нагрузка перераспределится ПЕРЕЗАПУСК под нагрузкой: станет хуже
сценариев, где одна проба даёт неверное поведение: 3 из 4
Правило: liveness проверяет ТОЛЬКО сам процесс;
readiness — готовность обслуживать.
Проверка зависимостей в liveness — самая частая ошибка:
отказ базы вызывает перезапуск всех экземпляров приложения,
и восстановление становится дольше.
Три сценария из четырёх дают неверное поведение при одной пробе. Худший — последний: перезапуск под нагрузкой усугубляет перегрузку.
requests и limits: классы обслуживания
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
Ожидаемый вывод:
═══ классы обслуживания ═══
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'ами
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
Ожидаемый вывод:
═══ структура 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: что он на самом деле
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
Ожидаемый вывод:
═══ что внутри 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 ДА нет
наследуется потомками ДА нет
попадёт в отчёт об ошибке ДА нет
Файл предпочтительнее по всем четырём признакам.
...
Соответствие тому, что вы уже делали
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
Ожидаемый вывод:
═══ что из этого вы уже знаете ═══
объект 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. Принципиально нового — четыре, и все они про поддержание состояния.
Практическое упражнение
Задание. Разберите манифесты и сопоставьте объекты с тем, что вы уже умеете.
Требования:
- Написать манифест Deployment с тремя пробами, ресурсами и
securityContext. - Объяснить, что ломается при использовании одной пробы вместо трёх, — на четырёх сценариях.
- Определить класс обслуживания по
requestsиlimits; показать порядок вытеснения. - Показать, что общее и что своё у container'ов в одном pod'е.
- Показать, что Secret — это base64, не печатая значений.
- Составить таблицу соответствия объектов тому, что делалось в Docker.
Подсказки
Подсказка 1
Guaranteed требует, чтобы requests были равны limits для всех ресурсов всех container'ов.
Подсказка 2
Проверка зависимостей в liveness — самая частая ошибка. Отказ базы вызовет перезапуск всех экземпляров.
Подсказка 3
Печатайте длину декодированного значения, а не само значение.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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, он пропустит.
Проверка результата
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 ядра | Одно слово | Разделяет имена, не изолирует процессы |
Контрольные вопросы
На понимание:
- Чем pod отличается от container и когда их несколько?
- Зачем нужен Service, если у pod'а есть IP-адрес?
- Три пробы — что проверяет каждая и что происходит при отказе?
- Чем
requestsотличается отlimitsи на что влияет каждый? - Почему pod без указания ресурсов вытесняется первым?
На применение:
- Как настроить пробы для приложения с сорокасекундным прогревом?
- Как подключить Secret, чтобы он не попал в отчёт об ошибке?
- Как добиться класса
Guaranteed?
На диагностику:
- При недоступности базы перезапускаются все экземпляры приложения. Причина?
- Pod вытесняется с узла, хотя потребляет меньше соседей. Гипотеза?
Краткое резюме
- Pod — группа container'ов с общими namespaces, планируемая как целое.
- Обычно в pod'е один container; несколько оправданы, если они живут и умирают вместе.
- Namespaces pod'а держит pause-container — поэтому перезапуск не меняет IP-адрес.
- Deployment управляет ReplicaSet, ReplicaSet — числом pod'ов.
- Старый ReplicaSet сохраняется, и это делает откат мгновенным.
- Service даёт стабильное имя и распределяет трафик между готовыми pod'ами.
- Три пробы отвечают на три разных вопроса; одна проба на всё ломает три сценария из четырёх.
- Liveness проверяет только сам процесс; проверка зависимостей в ней — частая ошибка.
requestsвлияет на планирование,limits— на поведение под нагрузкой.- Pod без
requestsпопадает вBestEffortи вытесняется первым. - Secret кодируется base64, а не шифруется; шифрование хранилища включается отдельно.
- Пятнадцать объектов из девятнадцати имеют аналог в том, что вы уже делали.
Официальные источники
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → От Compose к Kubernetes
Главное оглавление