Kubernetes: Pods, Services и reconciliation
Проследите путь image через Deployment, scheduling, readiness, Service routing и постепенное обновление Pods.
Временная шкала
Разбираем: Kubernetes: Pods, Services и reconciliation
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Docker умеет запустить контейнер на одной машине. Kubernetes похож на диспетчера парка машин: вы описываете, что должны работать три экземпляра приложения, а диспетчер постоянно сравнивает желаемое с реальностью, размещает Pods, заменяет упавшие и подключает готовые к общему адресу.
Kubernetes — декларативный orchestrator контейнерных workloads. API server хранит желаемые objects, controllers выполняют reconciliation, scheduler выбирает node, kubelet поддерживает Pod lifecycle, а Service даёт стабильный virtual endpoint для динамического набора Ready Pods.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Cluster
Control plane и набор worker nodes, совместно запускающих и управляющих Kubernetes objects.
Pod
Минимальная deployable unit Kubernetes: один или несколько тесно связанных containers с общей сетью и volumes.
Deployment
Controller для stateless Pods, который управляет replicas, ReplicaSets и rolling updates.
Service
Стабильный network endpoint и DNS name для динамического набора Pods, выбранных selector-ом.
Label / selector
Label помечает object key/value; selector связывает controller или Service с подходящими objects.
Reconciliation loop
Controller снова и снова сравнивает desired state со observed state и делает шаги для устранения разницы.
Readiness probe
Проверка готовности принимать трафик. Failure исключает Pod из Service endpoints, но не обязан перезапускать container.
Liveness probe
Проверка, способен ли container продолжать работу. Устойчивый failure приводит к restart container.
Resource request / limit
Request участвует в scheduling и резервировании; limit ограничивает допустимое runtime consumption.
Rolling update
Постепенная замена старых Pods новыми с контролем maxSurge и maxUnavailable.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Отправьте manifest
kubectl передаёт YAML API server-у; schema validation проверяет apiVersion, kind, metadata и spec.
- 02Сохраните desired state
Control plane сохраняет Deployment и увеличивает generation при изменении его Pod template.
- 03Создайте ReplicaSet
Deployment controller обнаруживает расхождение и создаёт ReplicaSet для текущей revision.
- 04Запланируйте Pods
Scheduler выбирает nodes, где выполняются requests, affinity, taints и прочие placement constraints.
- 05Запустите containers
Kubelet на node просит container runtime pull-нуть image и поддерживает объявленный Pod lifecycle.
- 06Проверьте готовность
Readiness probe допускает Pod в Service endpoints только после готовности приложения.
- 07Маршрутизируйте трафик
Service selector находит Ready Pods и даёт клиентам стабильное имя независимо от Pod IP.
- 08Согласуйте изменения
При новой image controller создаёт новые Pods, ждёт Ready и удаляет старые в рамках rollout strategy.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Kubernetes не собирает image
CI собирает и отправляет image в registry. Kubernetes получает ссылку и запускает это содержимое на nodes.
Pod не является маленькой VM
Containers внутри Pod делят network namespace: обращаются друг к другу через localhost и имеют один Pod IP.
Replica не равна резервной копии
Три stateless Pods повышают availability процесса, но не заменяют backup данных или multi-zone database.
Readiness и liveness отвечают на разные вопросы
NotReady Pod убирают из трафика. Liveness failure вызывает restart. Если liveness зависит от PostgreSQL, outage базы может перезапустить весь fleet.
Requests важны для scheduler
Без requests scheduler не знает реальную потребность. CPU limit обычно ведёт к throttling, memory limit — к OOM kill при превышении.
Secret не шифруется одним именем
Kubernetes Secret отделяет данные от Pod spec, но base64 не является encryption. Нужны RBAC, encryption at rest и внешний secret workflow.
Service не публикует приложение в интернет автоматически
ClusterIP доступен внутри cluster. Внешний HTTP обычно проходит через Ingress/Gateway или Service типа LoadBalancer.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-loop-lab
spec:
replicas: 3
selector:
matchLabels:
app: node-loop-lab
template:
metadata:
labels:
app: node-loop-lab
spec:
containers:
- name: app
image: ghcr.io/example/node-loop-lab:1.0.0
readinessProbe:
httpGet:
path: /api/health
port: 3000Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
const pause = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
export const dockerfileExample = `# syntax=docker/dockerfile:1
FROM node:24-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM dependencies AS build
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --chown=node:node --from=build /app/.next/standalone ./
COPY --chown=node:node --from=build /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r => { if (!r.ok) process.exit(1) })"
CMD ["node", "server.js"]`;
export const composeExample = `services:
app:
build:
context: .
target: runtime
init: true
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://app:password@postgres:5432/app
depends_on:
postgres:
condition: service_healthy
mem_limit: 2g
pids_limit: 128
restart: unless-stopped
postgres:
image: postgres:18-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 12`;
export const kubernetesManifestExample = `apiVersion: apps/v1
kind: Deployment
metadata:
name: node-loop-lab
spec:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: node-loop-lab
template:
metadata:
labels:
app: node-loop-lab
spec:
containers:
- name: app
image: ghcr.io/example/node-loop-lab:1.0.0
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: node-loop-lab
spec:
selector:
app: node-loop-lab
ports:
- name: http
port: 80
targetPort: http`;
function dockerStages(source) {
const stages = [];
for (const line of source.split('\n')) {
const match = line.match(/^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i);
if (match) {
stages.push({
image: match[1],
name: match[2] ?? `stage-${stages.length + 1}`,
});
}
}
return stages;
}
export async function dockerBuildAndRun(emit) {
const stages = dockerStages(dockerfileExample);
emit(
'build-context',
'context',
'Docker client собирает build context; .dockerignore исключает node_modules, .git и секреты',
);
await pause(15);
emit(
'dockerfile',
'parse',
`Dockerfile описывает ${stages.length} стадии: ${stages.map((stage) => stage.name).join(' → ')}`,
);
for (const stage of stages) {
await pause(15);
emit(
'buildkit',
'stage',
`BuildKit строит stage ${stage.name} из immutable base image ${stage.image}`,
);
}
emit(
'cache',
'layer',
'COPY package*.json расположен до COPY исходников: изменение кода не инвалидирует слой npm ci',
);
await pause(15);
emit(
'image',
'artifact',
'Runtime image получает standalone build, но не исходный build toolchain',
);
await pause(15);
emit(
'container',
'process',
'Container запускает node server.js как USER node; init передаёт сигналы и убирает zombie processes',
);
await pause(15);
emit(
'network',
'publish',
'Port mapping 127.0.0.1:3000:3000 публикует container port только на loopback хоста',
);
await pause(15);
emit(
'health',
'probe',
'Healthcheck проверяет /api/health; healthy не означает, что все внешние зависимости доступны',
);
emit(
'result',
'summary',
'Image — неизменяемый шаблон; container — запущенный process с writable layer и runtime configuration',
);
}
function readyPods(pods) {
return pods.filter((pod) => pod.ready);
}
export async function kubernetesReconciliation(emit) {
const desiredReplicas = 3;
let generation = 1;
let pods = [
{ name: 'node-loop-lab-old-1', version: '1.0.0', ready: true },
];
emit(
'api-server',
'desired-state',
`Deployment принят: desired replicas=${desiredReplicas}, image=1.0.0`,
);
await pause(15);
while (pods.length < desiredReplicas) {
const pod = {
name: `node-loop-lab-old-${pods.length + 1}`,
version: '1.0.0',
ready: false,
};
pods.push(pod);
emit(
'deployment-controller',
'reconcile',
`Actual=${pods.length - 1}, desired=${desiredReplicas}: ReplicaSet создаёт ${pod.name}`,
);
await pause(15);
pod.ready = true;
emit(
'kubelet',
'readiness',
`${pod.name} прошёл readinessProbe и добавлен в endpoints Service`,
);
}
emit(
'service',
'routing',
`Service выбирает по label ${readyPods(pods).length} ready Pods из ${pods.length}`,
);
await pause(15);
pods[1].ready = false;
emit(
'readiness',
'traffic',
`${pods[1].name} стал NotReady: container продолжает работать, но Service исключил его из трафика`,
);
await pause(15);
pods[1].ready = true;
generation += 1;
const newPod = {
name: `node-loop-lab-new-${generation}`,
version: '1.1.0',
ready: false,
};
pods.push(newPod);
emit(
'rolling-update',
'surge',
`maxSurge=1: создан ${newPod.name}, старые ready Pods пока обслуживают трафик`,
);
await pause(15);
newPod.ready = true;
pods = pods.filter((pod) => pod.name !== 'node-loop-lab-old-1');
emit(
'rolling-update',
'replace',
'Новый Pod стал Ready; controller удалил один старый Pod без снижения ready replicas',
);
emit(
'scheduler',
'resources',
'Scheduler размещает Pod по requests; limits ограничивают runtime, но не резервируют дополнительный ресурс',
);
emit(
'result',
'summary',
'Kubernetes непрерывно сравнивает desired и actual state; controller исправляет расхождение, а Service маршрутизирует только Ready Pods',
);
}
Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
Применить и проверить rollout
Создать objects и наблюдать фактическое состояние Deployment.
kubectl apply -f node-loop-lab.yml
kubectl rollout status deployment/node-loop-lab
kubectl get pods -l app=node-loop-lab
kubectl describe deployment node-loop-lab- apply создаёт или declaratively обновляет objects.
- rollout status ждёт доступности новой revision.
- describe показывает conditions и recent events.
Deployment skeleton
Поддерживать три взаимозаменяемых Pods.
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-loop-lab
spec:
replicas: 3
selector:
matchLabels:
app: node-loop-lab
template:
metadata:
labels:
app: node-loop-lab
spec:
containers:
- name: app
image: ghcr.io/example/node-loop-lab:1.0.0- Pod template change создаёт новую Deployment revision.
- Image tag должен указывать на опубликованный registry artifact.
Service selector
Дать Pods стабильное DNS-имя и virtual IP.
apiVersion: v1
kind: Service
metadata:
name: node-loop-lab
spec:
selector:
app: node-loop-lab
ports:
- port: 80
targetPort: http- Selector должен совпасть с Pod label.
- Service не зависит от меняющихся Pod IP.
Три разных probes
Разделить медленный запуск, готовность к трафику и зависание.
startupProbe:
httpGet: { path: /api/health, port: http }
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet: { path: /api/health, port: http }
periodSeconds: 5
livenessProbe:
httpGet: { path: /api/health, port: http }
periodSeconds: 10
failureThreshold: 3- Startup probe задерживает liveness/readiness до старта.
- Readiness failure прекращает новый traffic.
- Liveness failure после threshold вызывает restart.
Requests и limits
Дать scheduler сигнал и поставить runtime boundary.
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi- 250m означает четверть CPU core как request.
- Mi/Gi — binary units памяти.
- Значения выбирают по измерениям, не копируют вслепую.
Обновление и rollback
Перейти на immutable version и вернуться при инциденте.
kubectl set image deployment/node-loop-lab \
app=ghcr.io/example/node-loop-lab:1.1.0
kubectl rollout status deployment/node-loop-lab
kubectl rollout undo deployment/node-loop-lab- set image меняет desired Pod template.
- Rollback возвращает предыдущую Deployment revision, но не откатывает database migration.
Диагностика Pod
Различить application logs, object status и node events.
kubectl get pod <pod> -o wide
kubectl logs <pod> -c app --previous
kubectl describe pod <pod>
kubectl get events --sort-by=.metadata.creationTimestamp- --previous читает logs предыдущего container после restart.
- describe показывает probe failures, scheduling и image pull events.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Один общий health endpoint превращает сбой БД в restart storm
Deployment использует image:latest, не задаёт requests и направляет liveness/readiness на endpoint, который возвращает 500 при кратком outage PostgreSQL.
Kubelet одновременно перезапускает все Pods из-за внешней зависимости. Оставшиеся replicas получают больше трафика, а scheduler без requests может плотно разместить приложение на перегруженном node.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: ghcr.io/example/api:latest
livenessProbe:
httpGet:
path: /health
port: 3000
periodSeconds: 2Liveness отвечает не на вопрос «process необратимо завис?», а на вопрос «доступна ли сейчас БД?». Moving tag скрывает точную revision, а отсутствие requests/strategy делает размещение и rollout непредсказуемее.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
strategy:
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: ghcr.io/example/api:1.7.3
startupProbe:
httpGet: { path: /health/live, port: 3000 }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /health/live, port: 3000 }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /health/ready, port: 3000 }
periodSeconds: 5
resources:
requests: { cpu: 250m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }Live endpoint проверяет способность process продвигаться без требования доступности всего мира. Ready endpoint снимает Pod с трафика при временной неспособности обслуживать запросы. Version, strategy и resources делают rollout и placement наблюдаемыми.
Что делают непривычные вызовы из обоих фрагментов кода.
replicas: 3- Задаёт желаемое количество Pods; controllers асинхронно создают или удаляют instances для достижения этого состояния.
matchLabels- Selector связывает Deployment с управляемыми Pods; те же labels позволяют Service найти traffic backends.
startupProbe- Даёт медленно запускающемуся container отдельное окно и не активирует liveness/readiness до первого успеха.
livenessProbe- После последовательных failures сообщает kubelet, что container нужно restart-нуть для восстановления progress.
readinessProbe- Управляет готовностью Pod к новому traffic; failure убирает endpoint из Service без обязательного restart.
resources.requests / limits- Requests участвуют в scheduling и capacity accounting, limits задают верхнюю runtime-границу CPU/memory.
maxUnavailable / maxSurge- Ограничивают число недоступных и дополнительных Pods во время постепенной замены Deployment revision.
Популярные заблуждения
Миф слева, корректная модель справа.
Kubernetes заменяет Dockerfile и registry.
Orchestrator запускает готовые images; build и supply chain остаются отдельной задачей CI.
Если container Running, приложение готово.
Process может ещё загружаться или быть неспособным обслуживать запросы; это различает readiness.
Liveness должна проверять все dependencies.
Внешний outage не всегда лечится restart-ом приложения и может вызвать cascading failure.
Можно использовать image: latest.
Движущийся tag делает rollout и rollback невоспроизводимыми; используют immutable version или digest.
Три replicas гарантируют high availability.
Scheduler может разместить их на одном node или в одной зоне без topology constraints.
kubectl apply сразу обновляет все Pods.
API изменяет desired state, после чего controllers асинхронно выполняют rollout.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Чем Pod, Deployment и Service отличаются друг от друга?
- Кто сравнивает desired replicas с actual replicas?
- Почему Service selector должен совпасть с Pod labels?
- Что случится при readiness failure и при liveness failure?
- Чем resource request отличается от limit?
- Почему latest мешает воспроизводимому rollback?
- Почему три Pods не заменяют backup PostgreSQL?
- Что реально делает kubectl apply?