devops-курс Все курсы

✻ Урок 5.2 · Тема 5: Kubernetes и Helm

Поды и Deployment: запускаем «Заметки»

⏱ 3.5 ч

Зачем это нужно

В Compose ты говорил: «запусти этот контейнер». В Kubernetes ты говоришь: «хочу три копии приложения, всегда», и кластер сам следит, чтобы так и было. Копия (реплика, replica) - это один из одинаковых запущенных экземпляров приложения: три копии значит три одинаковых контейнера, и если один упадёт, остальные продолжат отвечать. Под (Pod) - обёртка, внутри которой в кластере запускается контейнер (подробно разберём в теории). Упал контейнер, умер узел (машина кластера, урок 5.1), кто-то случайно удалил под: копия вернётся без твоего участия. На работе с этого начинается любой деплой (выкатку новой версии приложения): манифест (manifest, описание объекта в YAML), kubectl apply и разбор статусов вроде ImagePullBackOff (кластер не может скачать образ, например, из-за опечатки в имени) и CrashLoopBackOff (контейнер запускается и сразу падает, кластер пробует снова и снова), которые видит каждый, кто хоть раз выкатывал сервис. kubectl apply - команда, которая отдаёт кластеру файл с описанием: «вот как должно быть». Подробно разберём в теории.

Шаг проекта: в кластере kind появляется Deployment (объект, который держит нужное число копий и умеет обновлять версию без простоя) notes в namespace notes: 3 реплики образа notes:0.4.0, данные в emptyDir (временная пустая папка, которая живёт ровно столько, сколько жив под; после удаления пода заметки пропадают, это осознанный долг до урока 5.5), файл k8s/base/10-deployment.yaml.

Что нужно знать

  • Урок 5.1: зачем Kubernetes и кластер kind: у тебя есть кластер notes, контекст kind-notes и namespace notes; ты знаешь, что такое желаемое и фактическое состояние, контроллер (программа, которая сравнивает желаемое с реальным и исправляет расхождение), манифест (YAML-файл с описанием объекта) и метка (пара «ключ = значение» на объекте; в этом уроке разберём подробнее).
  • Урок 4.2: Dockerfile и образ: образ notes, порт 8080, пользователь uid 10001 (номер непривилегированного пользователя, под которым работает приложение), переменные окружения (пары «имя = значение», которые программа читает при старте; так ей передают настройки).
  • Урок 4.7: образы и реестр: образ ghcr.io/<github-user>/notes:0.4.0 и почему тег latest не используем.
  • Урок 4.9: диагностика Docker: logs, inspect, exec, код выхода 137.
  • Урок 1.4: процессы и сигналы: что такое SIGTERM (вежливая просьба завершиться) и SIGKILL (немедленное убийство процесса), пригодится при удалении подов.

Картина целиком

Представь бригаду на стройке.

  • Рабочий это твой контейнер: делает работу, но сам по себе за собой не следит.
  • Вагончик это под (Pod): в нём работает один рабочий, иногда с помощником. У вагончика свой номер телефона (IP-адрес), и всё, что внутри, общается друг с другом по внутренней связи. Вагончик одноразовый: сломался, привезли новый с новым номером.
  • Бригадир это ReplicaSet. Ему сказали: «На объекте всегда три вагончика». Он пересчитывает их и, если не хватает, заказывает ещё.
  • Прораб это Deployment. Он руководит бригадирами и умеет менять версию работы: нанимает нового бригадира с новыми инструкциями, постепенно переводит вагончики к нему, а старого бригадира держит про запас на случай отката.
flowchart TD
    D["Deployment notes<br>хочу 3 копии образа notes:0.4.0"] -->|владеет| R["ReplicaSet notes-6d8c9b7f5<br>держи ровно 3 пода с меткой<br>app.kubernetes.io/name=notes"]
    R -->|владеет| P1["Pod ...x2k7p<br>контейнер, свой IP"]
    R -->|владеет| P2["Pod ...a1b2c<br>контейнер, свой IP"]
    R -->|владеет| P3["Pod ...z3y4x<br>контейнер, свой IP"]

На схеме есть метка (label): пара «ключ = значение», которую ты приклеиваешь к объекту, как цветной стикер на коробку. Здесь стикер app.kubernetes.io/name=notes означает «этот под относится к приложению notes». Бригадир ReplicaSet по такому стикеру узнаёт «своих» вагончиков: он считает все поды с этой меткой. Без меток кластеру пришлось бы искать поды по именам, а имена у подов случайные. Подробно про метки и про то, почему у ключа такое длинное имя, разберём в теории ниже.

Ты пишешь один файл, 10-deployment.yaml, и отдаёшь его кластеру. Всё, что ниже Deployment на схеме, кластер создаёт сам. Где аналогия перестаёт работать: рабочие в вагончике одинаковые копии одной программы, а не разные люди.

Теория

Под: минимальная единица запуска

Docker запускает контейнеры по одному. Но иногда программе нужен помощник, который всегда работает рядом с ней: тот, кто собирает и отправляет её логи, или прокси (посредник, через который идёт трафик). Им нужно быть на одной машине, видеть друг друга по localhost и делить папку. Отдельная сущность для «контейнеры, которые живут вместе» и есть под (Pod). Kubernetes управляет не контейнерами, а подами.

Комната в общежитии. У комнаты один адрес (IP), соседи по комнате делят и стол (общие тома), и телефон. Аналогия перестаёт работать в том, что комнату не «ремонтируют»: если под сломался, его выбрасывают и заводят новый.

Под это обёртка вокруг одного или нескольких контейнеров, которые:

  • всегда работают на одном узле (никогда не разъедутся);
  • делят один IP-адрес и один набор портов: внутри пода контейнеры достают друг до друга по localhost;
  • могут делить тома (volumes, папки, подключаемые в контейнеры, как -v в Docker, урок 4.3).

Обычно в поде один контейнер: твоё приложение. Второй добавляют, когда он нужен только рядом с первым (сборщик логов, прокси). Такой контейнер называют sidecar («коляска мотоцикла»). Контейнер, который отработал перед стартом основного и завершился (миграции базы, ожидание зависимости), это init-контейнер (init container).

Так выглядит под с двумя контейнерами. Не применяй его, это только для чтения:

apiVersion: v1                 # основная группа API
kind: Pod                      # тип объекта: под
metadata:
  name: demo                   # имя пода
spec:                          # желаемое состояние
  containers:                  # список контейнеров пода (дефис = один элемент списка)
    - name: app                # первый контейнер: приложение
      image: busybox:1.37
      command: ["sh", "-c", "while true; do date > /shared/now; sleep 5; done"]
      volumeMounts:            # куда подключить том внутри контейнера
        - name: shared
          mountPath: /shared
    - name: reader             # второй контейнер, «коляска»: читает то, что пишет первый
      image: busybox:1.37
      command: ["sh", "-c", "while true; do cat /shared/now; sleep 5; done"]
      volumeMounts:
        - name: shared
          mountPath: /shared
  volumes:                     # тома пода: общие для всех его контейнеров
    - name: shared
      emptyDir: {}             # пустая папка, которая живёт, пока жив под

Контейнер app каждые 5 секунд записывает время в файл, reader читает тот же файл. Они делят том shared, потому что оба подключили его. Ключ command заменяет команду запуска из образа. Оба контейнера при этом стартуют, останавливаются и удаляются вместе.

Прикинь сам: в поде два контейнера, один слушает порт 8080. Как второй контейнер того же пода достучится до него?

По localhost:8080: у контейнеров пода один IP и один набор портов. Ни имя сервиса, ни отдельный адрес не нужны. Зато два контейнера одного пода не могут слушать один и тот же порт.

Осторожно: не думай, что под и контейнер это одно и то же. Контейнер это программа в образе, а под это «жилплощадь» для одного или нескольких контейнеров. Второе: что «голый» под (созданный прямо манифестом kind: Pod или командой kubectl run) можно держать в работе. Если он умрёт, его никто не вернёт: нет контроллера, который следил бы за количеством копий. Поэтому в работе поды создают не руками, а через контроллеры (controller): объекты, которые следят за состоянием, как ReplicaSet и Deployment ниже.

Проверь понимание: ты запустил под командой kubectl run без Deployment и удалил его. Вернётся ли он?

Ответ

Нет. У такого пода нет контроллера, который следил бы за количеством копий. Желаемое состояние «под должен существовать» нигде не записано, поэтому и возвращать нечего.

Главное: под это общая «жилплощадь» контейнеров: один узел, один IP, общие тома; сам по себе он не восстанавливается, для этого нужен контроллер.

Что именно происходит с подом от создания до удаления, видно по его статусам.

Жизненный цикл пода и статусы, которые ты будешь видеть

Колонка STATUS в kubectl get pods это первое, что ты увидишь при любой проблеме. Если не знать, что стоит за каждым словом, поиск причины превращается в гадание.

Путь курьера: получил заказ, поехал за посылкой, едет к клиенту, доставил. Если что-то сломалось, статус говорит, на каком этапе.

У пода есть фаза (phase) в поле status: крупный этап.

Фаза Значение
Pending Под принят кластером, но ещё не запущен: ждёт выбора узла или скачивания образа
Running Под назначен на узел, хотя бы один контейнер работает
Succeeded, Failed Все контейнеры завершились (успешно или нет), для разовых задач
Unknown Кластер потерял связь с узлом и не знает состояние

Колонка STATUS у kubectl get pods богаче, в ней уже причины, а не фазы:

STATUS Что происходит
ContainerCreating Узел назначен, kubelet готовит контейнер: скачивает образ, подключает тома
ErrImagePull, ImagePullBackOff Образ скачать не удаётся; BackOff значит «повторяю с нарастающей паузой»
CrashLoopBackOff Контейнер стартует, падает, кластер перезапускает его с растущей паузой
Terminating Под удаляется

Когда под удаляют, происходит вот что (вспомни SIGTERM из урока 1.4):

flowchart TD
    A["t=0: kubectl delete pod<br>статус Terminating, под убирают из списка получателей трафика"] --> B["t=0: kubelet шлёт SIGTERM<br>заканчивай"]
    B --> C{"Процесс вышел<br>за 30 секунд?"}
    C -->|да| D["Под удалён"]
    C -->|нет, t=30| E["SIGKILL: принудительно"]
    E --> D

Эти 30 секунд называются terminationGracePeriodSeconds (пауза до принудительного убийства), 30 секунд это значение по умолчанию. Наше приложение при SIGTERM пишет в лог shutting down и выходит: это заложено в нём с темы 1.

Ты видишь CrashLoopBackOff и RESTARTS 5. Расшифровка: контейнер уже запускался, упал, был запущен ещё раз, и так пять раз. Пауза между попытками растёт: по умолчанию 10, 20, 40 секунд и так до потолка в 5 минут (числа зависят от версии и настроек kubelet; кластер не хочет забивать узел бесконечными перезапусками). Значит, искать надо ошибку внутри приложения: смотреть логи упавшего запуска и код выхода, а не проблемы с образом (образ-то скачан, иначе был бы ImagePullBackOff).

Осторожно, путаница: Pending и «образ скачивается». Скачивание образа это уже ContainerCreating, а Pending чаще означает, что планировщик пока не нашёл узел. И вторая путаница: Running не значит «работает правильно», а только «процесс запущен». Проверка, что приложение реально готово принимать запросы, это пробы (урок 5.7).

Прикинь сам: под показывает STATUS: Pending, а в describe есть строка Pulling image. Это ошибка планировщика?

Нет. Скачивание образа идёт после выбора узла, а в колонке STATUS его обычно показывает ContainerCreating. Если в describe уже виден Pulling, узел назначен, и проблему планировщика искать не надо.

Проверь понимание: статус ImagePullBackOff и CrashLoopBackOff. Что общего и какая главная разница в причинах?

Ответ

Общее: оба слова BackOff значат «кластер повторяет попытку с растущей паузой». Разница: при ImagePullBackOff контейнер вообще не запускался, потому что образ не скачался (опечатка, нет прав). При CrashLoopBackOff образ скачан и контейнер стартует, но процесс внутри падает.

Главное: статус в get pods называет причину: ImagePullBackOff значит «образ не скачать», CrashLoopBackOff значит «процесс падает», Running не гарантирует, что приложение готово.

Следить за числом подов и их перезапуском должен не человек, а специальный объект: ReplicaSet.

ReplicaSet: держит число копий

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

Бригадир, который каждое утро пересчитывает вагончики на объекте по бумажке «нужно три». Лишний увезёт, недостающий закажет.

ReplicaSet хранит три вещи: сколько нужно копий (replicas), по какому признаку узнавать «свои» поды (селектор, selector: условие по меткам) и шаблон пода (template): как выглядит новая копия. Работает как цикл согласования из урока 5.1: посчитал поды с подходящими метками, сравнил с replicas, добавил или убрал разницу. Каждому созданному поду он записывает себя в поле ownerReferences («мой владелец»). Оно нужно для порядка: при удалении владельца кластер убирает и то, чем он владел.

replicas: 3, селектор app.kubernetes.io/name=notes.

Событие Подходящих подов Действие ReplicaSet
Старт 0 создать 3
Удалили один под руками 2 создать 1
Вручную запустили ещё под с меткой app.kubernetes.io/name=notes 4 удалить 1 (он подходит под селектор, значит «свой»)
Убрали метку app.kubernetes.io/name у одного пода 2 (тот больше не подходит) создать 1; «освобождённый» под остаётся жить сам по себе

Последняя строка неочевидна и полезна на практике: если поду нужно «сделать карантин» для разбора, у него снимают метку, и ReplicaSet тут же запускает замену, а подозрительный под остаётся для изучения.

Прикинь сам: replicas: 3, у тебя три пода. Ты убрал метку у одного из них. Сколько подов станет в кластере и сколько из них ReplicaSet считает своими?

Всего четыре: ReplicaSet создаст замену, потому что видит только два подходящих пода. «Освобождённый» под без метки остаётся жить сам по себе, а считаются «своими» только три: два старых и новый.

Осторожно: не думай, что ReplicaSet создают руками. Почти никогда: его создаёт Deployment. Ещё путают: ReplicaSet следит за числом подов, но не умеет обновлять их образ. Для обновления нужен Deployment.

Проверь понимание: replicas: 3, а у тебя пять подов с меткой app.kubernetes.io/name=notes: три от Deployment и два запущены руками. Что сделает ReplicaSet?

Ответ

Удалит два лишних. Он считает все поды, подходящие под селектор, и не различает «свои» и «чужие» по происхождению. Поэтому метки нельзя раздавать случайным подам.

Главное: ReplicaSet сравнивает число подов с подходящими метками и replicas и добирает разницу; обновлять образ он не умеет.

Чтобы новая версия приложения доезжала до подов без простоя, над ReplicaSet стоит Deployment.

Deployment: обновления над ReplicaSet

ReplicaSet держит число копий, но при смене образа не умеет ничего сделать: шаблон у него неизменяем. Нужен объект, который умеет выкатывать новые версии плавно, чтобы сайт не падал, и откатывать назад. Это Deployment.

Прораб. Бригадиров (ReplicaSet) он не подменяет, а нанимает нового на каждую новую «инструкцию» (шаблон пода), постепенно переводит вагончики к нему и оставляет старого бригадира про запас: если что, вернёт всё обратно.

Цепочка владения такая: Deployment notes владеет ReplicaSet notes-<хэш>, тот владеет подами notes-<хэш>-<случайные символы>. Хэш (hash) в имени это короткий «отпечаток» шаблона пода: из содержимого шаблона считается набор символов, и при любом изменении шаблона он меняется. Поменял образ, значит, поменялся хэш, значит, Deployment создаёт новый ReplicaSet с новым хэшем и постепенно переводит на него поды (шаг за шагом: чуть поднял новых, чуть погасил старых), старый ReplicaSet остаётся с нулём подов для отката. Подробно об обновлении и откате в уроке 5.7.

Сейчас notes-6d8c9b7f5 держит три пода. Ты меняешь образ с 0.4.0 на 0.4.1 и применяешь манифест:

до:      RS notes-6d8c9b7f5 (0.4.0): 3 пода
         RS ...              (нет)
в ходе:  RS notes-6d8c9b7f5 (0.4.0): 2 пода   <- гасим по одному
         RS notes-7c54d8b9d (0.4.1): 2 пода   <- поднимаем по одному
после:   RS notes-6d8c9b7f5 (0.4.0): 0 подов  <- остался для отката
         RS notes-7c54d8b9d (0.4.1): 3 пода

Имена хэшей здесь придуманы для иллюстрации: у тебя будут другие. Смысл: в любой момент поды-работники есть, сервис не останавливается.

Прикинь сам: ты поменял в манифесте replicas: 3 на 5 и применил. Появится ли новый ReplicaSet?

Нет. Число копий не входит в шаблон пода, хэш не меняется, поэтому тот же ReplicaSet просто довозит ещё два пода. Новый ReplicaSet рождается, когда меняется шаблон: образ, переменные, тома, метки пода.

Осторожно: не думай, что при kubectl scale или смене replicas создаётся новый ReplicaSet. Нет: число копий не входит в шаблон пода, хэш не меняется, ReplicaSet тот же. Новый ReplicaSet появляется, только когда меняется шаблон (образ, переменные, тома, метки пода).

Проверь понимание: ты вручную удалил ReplicaSet, которым владеет Deployment. Что произойдёт?

Ответ

Deployment заметит, что ReplicaSet для текущего шаблона нет, и создаст его заново. Поды пересоздадутся. Управлять нужно верхним объектом (Deployment), а не тем, что он создаёт.

Главное: Deployment владеет ReplicaSet, а тот подами; новая версия образа создаёт новый ReplicaSet, а старый остаётся с нулём подов для отката.

Всё, что связывает эти объекты между собой, держится на метках и селекторах.

Метки и селекторы: как объекты находят друг друга

В кластере тысячи объектов. Кто из подов принадлежит ReplicaSet? Каким подам отправить трафик? Имена для этого не годятся (они случайные), нужна связь по свойствам.

Цветные стикеры на коробках. Ты клеишь на коробки красные и синие наклейки, а грузчику говоришь: «Возьми всё с красной наклейкой». Ему не важно, что в коробке.

Метки (labels) это пары ключ: значение на объекте. Придумать можно любые, но в курсе (и в большинстве компаний) принята метка с длинным именем app.kubernetes.io/name: notes. Разберём, почему оно такое.

Ключ метки состоит из двух частей через /: префикс (app.kubernetes.io, похож на адрес сайта) и имя (name). Префикс нужен, чтобы ключи разных авторов не столкнулись. Представь, что в общем холодильнике офиса каждый пишет на контейнере просто «Обед»: чей он? Если подписать «Обед (Аня, бухгалтерия)», путаницы нет. Так и здесь: коротким app пользуется кто угодно, и один инструмент может понять его не так, как другой, а префикс app.kubernetes.io закреплён за самим Kubernetes как общий договор.

Этот договор называется рекомендуемые метки (recommended labels): Kubernetes советует всем подписывать приложения одинаково, и потому их понимают Helm (урок 5.9), панели мониторинга, Backstage и прочие инструменты. Набор такой:

Ключ метки Что означает Пример
app.kubernetes.io/name имя приложения notes
app.kubernetes.io/instance конкретная установка приложения notes-prod
app.kubernetes.io/version версия 0.4.0
app.kubernetes.io/component роль части приложения api, database
app.kubernetes.io/part-of к какой большой системе относится notes-platform
app.kubernetes.io/managed-by чем управляется Helm

Нам в курсе нужна только name, остальные ты увидишь, когда дойдёшь до Helm. Пример из жизни: на работе у одной системы два сервиса, api и worker. Обоим ставят app.kubernetes.io/part-of: notes, а разным ещё и component. Тогда одной командой можно найти все части системы, а другой только API. Ключ без префикса (version, tier) тоже допустим для своих нужд, как ниже в примере.

Селектор (selector) это условие «объекты, у которых метка такая-то». В нашем Deployment два места, которые должны совпадать:

spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: notes    # условие: искать поды с меткой app.kubernetes.io/name=notes
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notes  # метка, которую получит каждый новый под

Если они не совпадают, ReplicaSet создавал бы поды, которых сам же не видит, и бесконечно плодил бы новые. Поэтому API отклоняет такой манифест сразу. Ещё одно ограничение: селектор существующего Deployment менять нельзя (поле неизменяемо), только удалить и создать заново. Позже по меткам Service найдёт поды (урок 5.3), а ты будешь искать их командой kubectl get pods -l app.kubernetes.io/name=notes (-l от label).

Три пода, у каждого метки:

 под A: app.kubernetes.io/name=notes, version=0.4.0
 под B: app.kubernetes.io/name=notes, version=0.4.0
 под C: app.kubernetes.io/name=other
 
 селектор app.kubernetes.io/name=notes               -> A, B
 селектор app.kubernetes.io/name=notes,version=0.4.1 -> никого

Запятая в -l означает «и»: должны выполняться оба условия.

Осторожно, путаница: метки и имена. Имя уникально и идентифицирует один объект, метка общая для многих и служит для группировки. И второе: метки в metadata Deployment (labels: app.kubernetes.io/name: notes на самом верху) не то же самое, что метки в шаблоне пода. Первые вешаются на сам Deployment, вторые на поды, и селектор смотрит на вторые.

Прикинь сам: у пода A метки name=notes, version=0.4.0, у пода C name=other. Какие поды выберет селектор name=notes,version=0.4.1?

Никакие: запятая означает «и», а ни у кого нет версии 0.4.1. Это не ошибка, просто пустой результат.

Проверь понимание: ты поменял в шаблоне пода app.kubernetes.io/name: notes на app.kubernetes.io/name: notes-v2, а в selector.matchLabels оставил app.kubernetes.io/name: notes. Что скажет kubectl apply?

Ответ

Ошибку валидации: селектор не совпадает с метками шаблона (selector does not match template labels). API не принимает такой Deployment, потому что созданные им поды не подходили бы под его же селектор.

Главное: метки это общие стикеры для группировки, имя это уникальный идентификатор; селектор Deployment обязан совпасть с метками в шаблоне пода.

Метки невидимы, пока о них не спросишь, поэтому дальше научимся их показывать.

Как увидеть метки и отобрать по ним: show-labels и -l

Метки невидимы, пока о них не спросишь. Когда что-то не находится (Service не видит под, ReplicaSet создаёт лишние), первым делом смотрят именно метки: опечатка в одном символе ломает связь молча, без ошибки.

Стикеры на коробках видны, только если повернуть коробку этикеткой к себе. Флаг --show-labels и есть такой поворот.

Флаг --show-labels добавляет к выводу kubectl get последнюю колонку LABELS: все метки объекта через запятую. Флаг -l (от label, длинная форма --selector) работает наоборот: показывает только объекты, подходящие под условие.

Так выглядит вывод, когда в namespace notes три пода Deployment (имена и время у тебя будут другими):

$ kubectl -n notes get pods --show-labels
NAME                     READY   STATUS    RESTARTS   AGE   LABELS
notes-6d8c9b7f5-2k7pl    1/1     Running   0          5m    app.kubernetes.io/name=notes,pod-template-hash=6d8c9b7f5
notes-6d8c9b7f5-a1b2c    1/1     Running   0          5m    app.kubernetes.io/name=notes,pod-template-hash=6d8c9b7f5
notes-6d8c9b7f5-z3y4x    1/1     Running   0          5m    app.kubernetes.io/name=notes,pod-template-hash=6d8c9b7f5

$ kubectl -n notes get pods -l app.kubernetes.io/name=notes

Колонка LABELS: две метки. Первая, app.kubernetes.io/name=notes, твоя, из шаблона пода. Вторую, pod-template-hash, Deployment дописывает сам: это тот же хэш, что в имени ReplicaSet, и по нему ReplicaSet отличает «свои» поды от подов старых версий. Вторая команда покажет те же три строки, потому что все три пода подходят под условие.

Осторожно, путаница: в -l пишут знак = (app.kubernetes.io/name=notes), а в манифесте двоеточие с пробелом (app.kubernetes.io/name: notes). Это одна и та же метка в двух записях: YAML-файл и командная строка просто устроены по-разному. Вторая путаница: если в -l допустить опечатку, kubectl не выдаст ошибку, а напишет No resources found in notes namespace (найдено ноль объектов).

Прикинь сам: в -l ты написал app.kubernetes.io/name=notez с опечаткой. Что выдаст kubectl get pods -l ...?

Не ошибку, а No resources found in notes namespace: условию просто никто не соответствует. Поэтому при пустом выводе сначала смотри метки через --show-labels.

Проверь понимание: kubectl get pods -l app.kubernetes.io/name=notes пишет No resources found, хотя kubectl get pods показывает три пода. Какие две причины проверить первыми?

Ответ

Во-первых, опечатка в ключе или значении: сравни с колонкой LABELS из --show-labels. Во-вторых, неверный namespace: без -n notes команда ищет в default, а там подов с такой меткой нет. Начинай с --show-labels: он показывает реальные метки, а не те, что ты ожидаешь.

Главное: --show-labels показывает реальные метки, -l отбирает по условию, а опечатка ломает связь молча.

Теперь можно прочитать целиком манифест Deployment, зная, что значат метки в нём.

Манифест Deployment построчно

Ты уже знаешь четыре верхних ключа манифеста из урока 5.1: apiVersion, kind, metadata, spec. Теперь посмотрим, как выглядит spec у Deployment. Это тот самый файл, который ты создашь в задании 1.

Разберём его по блокам (полный текст в практике):

apiVersion: apps/v1          # у Deployment группа apps, версия v1
kind: Deployment
metadata:
  name: notes                # имя Deployment
  namespace: notes           # в каком namespace создать (из урока 5.1)
  labels:
    app.kubernetes.io/name: notes   # метка на самом Deployment
spec:
  replicas: 1                # сколько подов держать
  selector:                  # как ReplicaSet находит «свои» поды
    matchLabels:
      app.kubernetes.io/name: notes
  template:                  # шаблон пода: как выглядит каждая копия
    metadata:
      labels:
        app.kubernetes.io/name: notes  # метка подов (должна совпадать с селектором)
    spec:                    # spec самого ПОДА (не Deployment)
      containers:
        - name: notes
          image: ghcr.io/<github-user>/notes:0.4.0   # образ с фиксированным тегом
          ports:
            - containerPort: 8080     # на каком порту слушает приложение
          env:                        # переменные окружения контейнера
            - name: STORE
              value: "file"

template это «под внутри Deployment»: внутри него снова metadata и spec, как у обычного пода. Ключ containerPort служит для документации: он не открывает порт наружу, а лишь сообщает читателю (и инструментам), что приложение слушает 8080. Переменная окружения (environment variable) это пара имя-значение, которую программа читает при старте; наше приложение берёт из них настройки (STORE, NOTES_DATA, HOST, PORT из урока 1.8).

kubectl apply -f файл объявляет желаемое: объекта нет, создаст, есть, приведёт к манифесту, и команду можно повторять сколько угодно раз (идемпотентность, idempotency). kubectl create -f только создаёт и падает с AlreadyExists, если объект уже есть. Для файлов в git используют apply. Перед применением полезны две проверки: kubectl diff -f файл показывает разницу между файлом и живым объектом (строки - убираются, + добавляются), а kubectl apply --dry-run=server -f файл прогоняет манифест через проверку на сервере, ничего не сохраняя. Режим client проверяет только формат на твоей стороне и не знает, что скажет сервер (например, что namespace не существует).

Прикинь сам: сколько раз в манифесте Deployment встречается ключ spec и чем они различаются?

Два раза. Верхний spec описывает сам Deployment (реплики, селектор), вложенный в template описывает под (контейнеры, тома).

Осторожно: не думай, что spec встречается один раз. У Deployment два spec: верхний (про Deployment: реплики, селектор) и вложенный в template (про под: контейнеры, тома). Ошибка в отступе легко переносит containers не на тот уровень.

Проверь понимание: чем kubectl apply отличается от kubectl create?

Ответ

create императивно создаёт объект и падает, если он уже есть. apply декларативно приводит объект к манифесту и безопасно запускается повторно, поэтому его используют в CI и при работе с файлами из git.

Главное: у Deployment два spec: верхний про Deployment, вложенный в template про под; apply повторяем, create падает на существующем.

Манифест описывает контейнер, но куда ему писать файлы, решают тома.

Тома: почему данные в поде временные

Файловая система контейнера исчезает вместе с ним (как в уроке 4.3). Чтобы приложению было куда писать файл, к поду подключают том.

Ящик в вагончике: рабочие могут класть туда бумаги, но когда вагончик увозят, ящик уезжает вместе с ним.

Том задаётся в двух местах: в volumes пода (что за том) и в volumeMounts контейнера (куда его подключить). У emptyDir («пустой каталог») жизнь такая: создаётся пустым при запуске пода на узле, исчезает при удалении пода. Если контейнер внутри пода просто перезапустился, файлы остаются, потому что пода не удаляли. Ключ sizeLimit ограничивает размер (при превышении под будет вытеснен).

У «Заметок» файл /data/notes.txt, а в образе (урок 4.7) каталог /data принадлежит пользователю 10001, от которого работает приложение. Мы подключаем emptyDir в /data. У Deployment три реплики, значит, три пода, значит, три отдельных emptyDir, три независимых файла. Заметка, записанная в одну реплику, не видна в двух других. Это ловушка, из-за которой пользователи скажут «заметки то пропадают, то появляются». Решение это общее хранилище, а не вынесенное в отдельную базу: PostgreSQL в уроке 5.5.

Прикинь сам: у «Заметок» три реплики с emptyDir в /data. Ты записал заметку через одну реплику. Увидишь ли ты её при следующем запросе?

Только если запрос попадёт в ту же реплику, то есть примерно в одном случае из трёх. У каждого пода свой emptyDir, поэтому заметки «то пропадают, то появляются».

Осторожно: не думай, что три реплики дают отказоустойчивость сами по себе. Реплики дают её только приложению, у которого нет собственного состояния (stateless). «Заметки» на файлах в каждом поде состояние хранят, поэтому три копии это три разные базы.

Проверь понимание: под удалили, а Deployment создал новый. Что с файлом в emptyDir старого пода?

Ответ

Файл пропал вместе со старым подом: emptyDir живёт, пока жив под. Новый под получает свой пустой каталог. А вот при перезапуске только контейнера внутри того же пода файл сохранился бы.

Главное: emptyDir живёт столько, сколько жив под, и у каждой реплики он свой; общее состояние лежит в отдельном хранилище.

Остался вопрос, откуда узел возьмёт образ, чтобы под вообще стартовал.

Образы в kind и политика загрузки

Самая частая причина, по которой первый Deployment не запускается: узел не может получить образ. Надо понимать, откуда он его берёт.

Узел kind это отдельный маленький компьютер со своим складом образов. То, что лежит у тебя на хосте в Docker, для него как на соседнем этаже: он туда не заходит.

Узлы kind это контейнеры Docker со своим containerd внутри (урок 5.1), поэтому образы с твоего хоста они сами не видят. Есть два пути. Первый: образ лежит в публичном реестре ghcr.io (он там с урока 4.7), и узлы скачивают его сами. Второй: kind load docker-image notes:0.4.0 --name notes копирует локальный образ внутрь каждого узла.

Поведение при старте пода задаёт imagePullPolicy:

Значение Что делает
IfNotPresent Тянуть, только если образа нет на узле
Always Проверять реестр при каждом запуске пода
Never Брать только локальный, а если нет, ошибка

Если поле не задано, Kubernetes выбирает сам: для тега latest или без тега это Always, для остальных IfNotPresent. Отсюда правило курса: тег latest не используем, иначе поведение зависит от неявных правил и невоспроизводимо. Всегда фиксированная версия.

Образ приватный (ghcr.io/<user>/notes:0.4.0 в закрытом пакете). Узел приходит в реестр без пароля и получает unauthorized. Пароль передают через Secret типа docker-registry (объект для хранения секретных данных, подробно в уроке 5.6), а в шаблоне пода указывают его в imagePullSecrets. Мы этого делать не будем, потому что пакет notes публичный, но команду увидишь в разборе поломок.

Прикинь сам: образ notes:0.4.0 есть в твоём локальном Docker, но ты не делал kind load и он не опубликован. Что покажет под?

Статус ErrImagePull, потом ImagePullBackOff: узел kind не видит образы хоста и ищет образ в реестре, где его нет.

Осторожно: не думай, что ImagePullBackOff значит «образ сломан». Чаще всего образ просто не найден (опечатка в теге) или закрыт паролем.

Проверь понимание: зачем kind load, если образ уже есть в локальном Docker?

Ответ

Кластер kind работает внутри своих контейнеров-узлов со своим хранилищем образов. Docker хоста для него не источник. Без kind load (или реестра) узел не найдёт образ и поды зависнут в ErrImagePull.

Главное: узлы kind берут образ из реестра или из kind load; тег latest не используем, чтобы не зависеть от неявного imagePullPolicy.

Теперь соберём всё вместе и посмотрим, как цепочка от apply до Running разворачивается шаг за шагом.

Что происходит между kubectl apply и работающим подом

Когда под не появился, надо понимать, на каком шаге цепочка порвалась. Иначе ты будешь смотреть не туда: например, искать ошибку в приложении, когда планировщик ещё даже не выбрал узел.

Заказ в интернет-магазине: оформил, склад собрал, курьер повёз, курьер вручил. На каждом этапе свой исполнитель и свой статус.

Цепочка из урока 5.1 на нашем примере выглядит так:

flowchart TD
    A["1. kubectl apply<br>читает файл, отправляет в API"] --> B["2. API-сервер<br>проверяет формат и права, сохраняет Deployment в etcd"]
    B --> C["3. контроллер Deployment<br>создаёт ReplicaSet"]
    C --> D["4. контроллер ReplicaSet<br>видит 0 подов из 3, создаёт 3 объекта Pod<br>без узла, статус Pending"]
    D --> E["5. планировщик<br>выбирает узел для каждого пода"]
    E --> F["6. kubelet на узле<br>просит containerd скачать образ<br>ContainerCreating, потом запуск"]
    F --> G["7. kubelet<br>докладывает в API: Running"]

Важно, что исполнители не разговаривают друг с другом напрямую. Каждый смотрит в API-сервер, находит «свою» работу и записывает результат обратно. Поэтому если один компонент на время остановился, остальные не ломаются: работа ждёт в API-сервере, пока исполнитель вернётся.

Хочешь увидеть эту цепочку своими глазами: после apply выполни kubectl -n notes get events --sort-by=.lastTimestamp. Ты увидишь события примерно такого вида (возраст и хэши у тебя будут другие):

Normal  ScalingReplicaSet  deployment-controller  Scaled up replica set notes-6d8c9b7f5 from 0 to 3
Normal  SuccessfulCreate   replicaset-controller  Created pod: notes-6d8c9b7f5-x2k7p
Normal  Scheduled          default-scheduler      Successfully assigned notes/notes-6d8c9b7f5-x2k7p to notes-worker
Normal  Pulled             kubelet                Container image "..." already present on machine
Normal  Created            kubelet                Created container notes
Normal  Started            kubelet                Started container notes

Колонка From называет исполнителя. Каждая строка соответствует шагу цепочки: deployment-controller это шаг 3, replicaset-controller шаг 4, default-scheduler шаг 5, kubelet шаги 6 и 7. Если последней строки нет, а под в ContainerCreating, беда на узле (образ, том). Если нет строки Scheduled, под висит в Pending, и виноват планировщик (мало ресурсов на узлах).

Прикинь сам: apply ответил created за долю секунды, а под запускается минуту. Почему так?

Команда лишь кладёт запись в API-сервер и сразу возвращается. Остальное делают контроллеры, планировщик и kubelet уже после ответа. Дождаться результата помогает kubectl rollout status.

Осторожно: не думай, что kubectl apply сам запускает контейнеры. Он только кладёт запись в API-сервер и сразу возвращается. Поэтому сообщение created приходит мгновенно, а под может запускаться ещё минуту. Чтобы дождаться результата, нужен rollout status.

Проверь понимание: под висит в Pending, а в событиях нет строки Scheduled. Какой исполнитель ещё не сделал свою работу?

Ответ

Планировщик: он не назначил поду узел. Причина почти всегда в ресурсах или ограничениях на узлах (describe pod в разделе Events назовёт её). Kubelet и образ тут ни при чём: до назначения узла они под даже не видят.

Главное: цепочка идёт через API-сервер: каждый исполнитель сам видит свою работу, а по колонке From в событиях видно, на каком шаге остановка.

Манифест, который мы применяем, это YAML, а в нём смысл задают пробелы.

YAML: отступы это структура

Манифест это YAML, а в YAML смысл задают пробелы. Ошибка в два пробела превращает рабочий файл в другой объект или в ошибку.

Оглавление книги с вложенными пунктами: подпункт с большим отступом принадлежит ближайшему пункту выше. Сдвинул строку, значит, переселил её в другую главу.

Три конструкции покрывают весь наш манифест. Словарь (пары ключ: значение) записывается строками с одним отступом. Список записывается строками с дефисом, каждый дефис начинает новый элемент. Вложенность это отступ на два пробела глубже. Табуляции запрещены, только пробелы. Значение можно взять в кавычки, и это важно для строк, которые YAML может принять за число или логическое значение: "0.4.0" и "file" в кавычках, а 8080 без кавычек, потому что это число.

Разобранный пример.

containers:            # ключ, значение которого список
  - name: notes        # первый элемент списка (дефис) и в нём ключ name
    image: notes:0.4.0 # тот же элемент: отступ выровнен с name, не с дефисом
    ports:
      - containerPort: 8080

Строка image выровнена с name (а не с дефисом), поэтому это второй ключ того же контейнера. Сдвинь её на два пробела влево, и получится, что дефис и image оказались на одном уровне, и разбор YAML остановится: apply вернёт ошибку разбора (did not find expected '-' indicator). Проверять отступы дёшево: kubectl apply --dry-run=server -f файл или kubeconform найдут такую ошибку до выката.

Прикинь сам: в списке containers ты сдвинул строку image на два пробела влево, к дефису. Что получится?

Разбор YAML остановится: apply вернёт ошибку разбора (did not find expected '-' indicator), до проверки схемы дело не дойдёт. Ловится заранее через --dry-run=server или kubeconform.

Осторожно: не думай, что дефис и ключ это разные элементы. Дефис только отмечает начало элемента списка, а первый ключ элемента пишется на той же строке после дефиса. Отсюда и выравнивание следующих ключей.

Проверь понимание: сколько контейнеров описано, если в списке containers два дефиса на одном уровне?

Ответ

Два: каждый дефис на этом уровне начинает новый элемент списка, то есть новый контейнер.

Главное: в YAML вложенность задают пробелы, дефис начинает элемент списка, первый ключ элемента пишется на той же строке.

Остался инструмент, без которого не диагностировать ни одну поломку: заглянуть внутрь пода.

kubectl logs, exec и port-forward: как заглянуть в под

Под работает где-то на узле, к которому ты не подключаешься по SSH. Нужны способы посмотреть его логи, зайти внутрь и обратиться к приложению.

Все три команды идут через API-сервер (напомню из урока 5.1: он единственная дверь в кластер), а тот передаёт их kubelet нужного узла:

  • kubectl logs <под> печатает то, что приложение пишет в вывод (тот же смысл, что у docker logs). Флаг --previous показывает логи предыдущего запуска упавшего контейнера, это главный инструмент при CrashLoopBackOff: текущий запуск ещё пуст.
  • kubectl exec <под> -- команда выполняет команду внутри контейнера (аналог docker exec). Двойной дефис -- отделяет флаги kubectl от команды.
  • kubectl port-forward <под> 18080:8080 открывает на твоей машине порт 18080 и туннелем ведёт его в порт 8080 пода. Балансировки нет: ты говоришь с одним конкретным подом. Это способ для отладки, а не для пользователей.

Обращаться можно не к имени пода, а к Deployment: deploy/notes. Тогда kubectl сам выберет один из его подов. Логи при этом берутся тоже из одного выбранного пода, а не со всех сразу.

Прикинь сам: под упал и перезапустился. Текущий kubectl logs пуст. Как увидеть причину падения?

С флагом --previous: он покажет логи прошлого запуска контейнера, где и записана причина.

Осторожно: не думай, что логи хранит под. На самом деле логи лежат на узле рядом с контейнером. Когда под удалён, логи пропали; чтобы они жили дольше, их собирают в централизованное хранилище (тема 8).

Главное: logs, exec и port-forward идут через API-сервер к kubelet; логи лежат на узле и пропадают вместе с подом.

Теперь можно запускать «Заметки» руками, и мы начнём с первого Deployment.

Практика

Все команды выполняются из ~/notes. Предполагается, что кластер из 5.1 запущен: kubectl config current-context должен вернуть kind-notes. Подставь свой логин GitHub вместо <github-user> (строчными буквами). Версии: kind v0.33.0, kubectl 1.37.1.

Задание 1. Первый Deployment с одной репликой

Цель: описать приложение манифестом и получить работающий под.

Предскажи: сколько объектов появится после apply одного Deployment (не считая самого Deployment) и как будут называться поды?

Ответ

Появятся ReplicaSet (один) и под (один по числу реплик). Имя ReplicaSet: notes-<хэш>, имя пода: notes-<хэш>-<5 символов>.

Шаги:

  1. Создай временный манифест (в проект он попадёт в задании 4 в окончательном виде). Здесь cat > файл <<'YAML' записывает всё до строки YAML в файл; кавычки вокруг YAML запрещают оболочке подставлять переменные внутри текста. Поэтому логин мы подставляем отдельной командой sed: export GH_USER=... задаёт переменную (как в уроке 4.7), а sed -i "s|<github-user>|$GH_USER|" заменяет в файле слово <github-user> на её значение (-i правит файл на месте, | служит разделителем вместо /).
export GH_USER="<твой-github-логин-строчными>"
mkdir -p ~/notes/k8s/base
cat > ~/notes/k8s/base/10-deployment.yaml <<'YAML'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notes
  namespace: notes
  labels:
    app.kubernetes.io/name: notes
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: notes
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notes
    spec:
      containers:
        - name: notes
          image: ghcr.io/<github-user>/notes:0.4.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          env:
            - name: STORE
              value: "file"
            - name: NOTES_DATA
              value: "/data/notes.txt"
            - name: APP_VERSION
              value: "0.4.0"
          volumeMounts:
            - name: data
              mountPath: /data
      volumes:
        - name: data
          emptyDir: {}
YAML
sed -i "s|<github-user>|$GH_USER|" ~/notes/k8s/base/10-deployment.yaml

Новое здесь: imagePullPolicy: IfNotPresent (см. теорию), APP_VERSION (версия, которую приложение показывает на /), STORE=file и NOTES_DATA (хранить заметки в файле, как в уроке 4.4), emptyDir: {} (пустые фигурные скобки значат «настроек нет»). Образ уже задаёт HOST=0.0.0.0 и PORT=8080, поэтому их не указываем.

  1. Проверь манифест сервером и примени. rollout status ждёт, пока выкатка закончится (--timeout ограничивает ожидание); deploy/notes это сокращённая запись «Deployment с именем notes»; get deploy,rs,pods выводит три типа объектов сразу; -o wide добавляет колонки.
cd ~/notes
kubectl apply --dry-run=server -f k8s/base/10-deployment.yaml
kubectl apply -f k8s/base/10-deployment.yaml
kubectl -n notes rollout status deploy/notes --timeout=120s
kubectl -n notes get deploy,rs,pods -o wide

Что должно получиться:

deployment.apps/notes created (server dry run)
deployment.apps/notes created
Waiting for deployment "notes" rollout to finish: 0 of 1 updated replicas are available...
deployment "notes" successfully rolled out
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES
deployment.apps/notes   1/1     1            1           12s   notes        ghcr.io/<github-user>/notes:0.4.0

NAME                              DESIRED   CURRENT   READY   AGE
replicaset.apps/notes-6d8c9b7f5   1         1         1       12s

NAME                        READY   STATUS    RESTARTS   AGE   IP           NODE
pod/notes-6d8c9b7f5-x2k7p   1/1     Running   0          12s   10.244.1.3   notes-worker

Вывод сокращён по колонкам. Хэш, имена и IP у тебя будут другие, а вместо <github-user> твой логин.

Как читать вывод: для Deployment READY 1/1 это «готово 1 из 1 нужных копий», UP-TO-DATE сколько копий уже на актуальном шаблоне, AVAILABLE сколько готово принимать запросы. Для ReplicaSet DESIRED желаемое, CURRENT существующее, READY готовое: три числа, которые должны сойтись. У пода RESTARTS 0 показывает, сколько раз перезапускался контейнер (если растёт, это сигнал), IP это адрес пода в сети кластера (диапазон 10.244.0.0/16 в kind), NODE узел, куда его назначил планировщик. Строка Waiting for deployment... может не появиться, если под успел стартовать быстро.

Объясни себе:

  • Откуда в имени пода два «хвоста» и что каждый означает?
  • Почему в манифесте emptyDir, а не том Docker, как в Compose?
  • Что такое --dry-run=server и чем он лучше client?

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

  • error: the namespace from the provided object does not match the namespace "default": неверный флаг -n или контекст. Убери -n (namespace уже в манифесте) или поправь значение.
  • Error from server (NotFound): namespaces "notes" not found: не создан namespace из урока 5.1. Выполни kubectl apply -f k8s/base/00-namespace.yaml.
  • error validating data: ValidationError(Deployment.spec): missing required field "selector": забыт селектор или сломаны отступы в YAML. Проверь, что selector и template на одном уровне под spec.
  • Под в ErrImagePull и ImagePullBackOff сразу после apply: в манифесте остался <github-user> или пакет на ghcr.io приватный. Проверь grep image k8s/base/10-deployment.yaml и видимость пакета в настройках GitHub.

Задание 2. Заглянуть в под: логи, exec, port-forward

Цель: убедиться, что приложение отвечает, и научиться смотреть внутрь пода.

Разбор команд: logs deploy/notes --tail=5 печатает последние 5 строк лога. port-forward deploy/notes 18080:8080 & открывает туннель порт-на-твоей-машине:порт-в-поде; & уводит команду в фон, чтобы вернулся приглашение; sleep 2 даёт туннелю секунду-две подняться; kill %1 останавливает первую фоновую задачу оболочки. curl -sS тихо ходит по адресу, но показывает ошибки; -X POST -d '...' отправляет POST с телом. Приложение принимает заметки только в JSON вида {"text":"..."}, иначе ответит 400. В exec ... -- sh -c 'id -u; ls -l /data' двойной дефис отделяет команду от флагов kubectl, sh -c '...' выполняет две команды подряд, id -u печатает номер пользователя, а ls -l /data показывает файлы каталога данных.

Предскажи: port-forward прокидывает порт с твоей машины в под. Что вернёт curl http://127.0.0.1:18080/healthz и пойдёт ли этот запрос через какой-либо балансировщик?

Ответ

Ответ ok с кодом 200. Балансировщика нет: kubectl открывает туннель к API-серверу, а тот к конкретному поду. Service мы ещё не создавали (урок 5.3).

Шаги:

kubectl -n notes logs deploy/notes --tail=5
# туннель в фоне на порт 18080, чтобы не мешать другим сервисам на 8080
kubectl -n notes port-forward deploy/notes 18080:8080 &
sleep 2
curl -sS http://127.0.0.1:18080/healthz
curl -sS -X POST -d '{"text":"первая заметка"}' http://127.0.0.1:18080/notes
curl -sS http://127.0.0.1:18080/notes
kill %1
# зайти в контейнер и посмотреть пользователя и файл данных
kubectl -n notes exec deploy/notes -- sh -c 'id -u; ls -l /data'

Что должно получиться:

2026-09-30 12:00:01,204 INFO started host=0.0.0.0 port=8080
Forwarding from 127.0.0.1:18080 -> 8080
Forwarding from [::1]:18080 -> 8080
ok
Handling connection for 18080
{"id": 1}
Handling connection for 18080
[{"id": 1, "text": "первая заметка", "created_at": "2026-09-30T12:00:20+00:00"}]
10001
total 4
-rw-r--r-- 1 notes notes 79 Sep 30 12:00 notes.txt

Как читать вывод: первая строка это лог приложения: время (в UTC, часовом поясе контейнера), уровень INFO и сообщение о старте на 0.0.0.0:8080. Строки Forwarding from печатает port-forward: туннель поднят для IPv4 и IPv6, а Handling connection for появляется на каждый запрос. Ответ ok это /healthz, {"id": 1} ответ на запись заметки (код 201), а следующий вывод это список заметок в JSON: created_at время создания в UTC. 10001 это номер пользователя, под которым работает процесс. В ls -l владелец файла notes (имя пользователя uid 10001 из образа), а размер и время у тебя будут другими. Строки лога о самих запросах приложение пишет тоже, но /healthz и /readyz из лога скрыты намеренно.

Объясни себе:

  • Почему id -u возвращает 10001, а не 0, и где это задано?
  • Что будет с файлом /data/notes.txt, если удалить под?
  • Почему logs deploy/notes работает, хотя логи хранит под?

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

  • error: unable to forward port because pod is not running. Current status=Pending: под ещё не запущен. Дождись rollout status.
  • Unable to listen on port 18080: Listeners failed to create with the following errors: [unable to create listener: Error listen tcp4 127.0.0.1:18080: bind: address already in use]: порт занят прошлым туннелем. Найди и останови: pkill -f 'port-forward'.
  • {"error": "нужен JSON {\"text\": \"...\"}"} в ответ на POST: тело отправлено не в JSON. Пиши -d '{"text":"..."}'.
  • error: Internal error occurred: error executing command in container: exec: "bash": executable file not found in $PATH: в минимальных образах bash может не быть. Используй sh.

Нейросеть хорошо переписывает вывод kubectl describe pod человеческим языком. Но вставляй в неё только свой вывод без токенов и паролей, а совет «удалить и создать заново» проверяй: так теряется след поломки.

Задание 3. Самовосстановление и масштабирование

Цель: увидеть, как контроллеры возвращают желаемое состояние.

Разбор команд: scale --replicas=3 императивно меняет число копий. POD=$(kubectl ... | head -1) кладёт в переменную имя первого пода из списка (-o name печатает имена в виде pod/notes-..., head -1 берёт первую строку). delete "$POD" --wait=false удаляет под и не ждёт завершения, чтобы ты успел увидеть Terminating. get pods -w (от watch) выводит изменения по мере появления, --request-timeout=10s сам оборвёт наблюдение через 10 секунд. -o custom-columns=ИМЯ:путь выводит свои колонки: .metadata.ownerReferences[0].name это имя первого владельца пода. sed -n '/Events/,$p' печатает часть вывода от строки со словом Events до конца.

Предскажи: ты удалишь под, у которого replicas: 3. Сколько подов будет через 2 секунды и будет ли у нового то же имя?

Ответ

Снова 3 (один будет в статусе ContainerCreating или Running), плюс старый в Terminating некоторое время. Имя другое: суффикс случаен, IP тоже новый. Под одноразовый, ReplicaSet создаёт замену, а не воскрешает старый.

Шаги:

# масштабирование императивно (для опыта; в манифесте потом поправим)
kubectl -n notes scale deploy/notes --replicas=3
kubectl -n notes get pods -o wide
# удаляем один под и смотрим за событиями
POD=$(kubectl -n notes get pods -o name | head -1)
kubectl -n notes delete "$POD" --wait=false
kubectl -n notes get pods -w --request-timeout=10s

Останови -w по Ctrl+C, если он не завершился сам.

Затем посмотри, кто чем владеет:

kubectl -n notes get pod -o custom-columns=NAME:.metadata.name,OWNER:.metadata.ownerReferences[0].name
kubectl -n notes describe deploy/notes | sed -n '/Events/,$p'

Что должно получиться:

NAME                    READY   STATUS              RESTARTS   AGE
notes-6d8c9b7f5-x2k7p   1/1     Terminating         0          3m
notes-6d8c9b7f5-q9m4d   0/1     ContainerCreating   0          1s
notes-6d8c9b7f5-a1b2c   1/1     Running             0          40s
notes-6d8c9b7f5-z3y4x   1/1     Running             0          40s

NAME                    OWNER
notes-6d8c9b7f5-a1b2c   notes-6d8c9b7f5
notes-6d8c9b7f5-q9m4d   notes-6d8c9b7f5
notes-6d8c9b7f5-z3y4x   notes-6d8c9b7f5

Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  1m    deployment-controller  Scaled up replica set notes-6d8c9b7f5 from 1 to 3

Имена и возраст у тебя будут другие; порядок строк в get pods тоже может отличаться.

Как читать вывод: в первой таблице виден весь путь замены: удаляемый под в Terminating, новый в ContainerCreating (образ уже на узле, поэтому создание быстрое), два нетронутых в Running. Второй вывод показывает, что у каждого пода владелец один и тот же ReplicaSet: по этому полю кластер понимает, чей под. Событие ScalingReplicaSet от deployment-controller подтверждает, что число копий менял именно контроллер Deployment, а не ты напрямую с подом.

Объясни себе:

  • Почему на короткое время подов «больше» трёх (один Terminating)?
  • Что вернёт ownerReferences у пода и как это связано со сборкой мусора при удалении Deployment?
  • Что произойдёт с kubectl scale, если ты потом применишь манифест с replicas: 1?

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

  • Error from server (NotFound): pods "notes-..." not found: под уже заменён, имя устарело. Возьми свежий из get pods.
  • error: unknown flag: --replica: опечатка, флаг называется --replicas.

Задание 4. Шаг проекта: Deployment notes на 3 реплики

Цель: зафиксировать окончательный манифест в проекте и выкатить его.

Предскажи: что покажет kubectl diff -f k8s/base/10-deployment.yaml, если ты уже вручную сделал scale --replicas=3, а в файле стоит replicas: 3?

Ответ

Про replicas ничего: желаемое состояние совпало с живым. Но в файле поменялся том (sizeLimit), поэтому дифф покажет эту добавленную строку, и шаблон пода изменится: пройдёт выкатка. Если в файле остаётся replicas: 1, дифф покажет - replicas: 3 и + replicas: 1. Служебные поля вроде generation тоже могут попасть в дифф.

Шаги:

  1. Обнови k8s/base/10-deployment.yaml целиком (три реплики, ограничение на данные sizeLimit, значение 64Mi это 64 мебибайта):
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notes
  namespace: notes
  labels:
    app.kubernetes.io/name: notes
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: notes
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notes
    spec:
      containers:
        - name: notes
          image: ghcr.io/<github-user>/notes:0.4.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          env:
            - name: STORE
              value: "file"
            - name: NOTES_DATA
              value: "/data/notes.txt"
            - name: APP_VERSION
              value: "0.4.0"
          volumeMounts:
            - name: data
              mountPath: /data
      volumes:
        # данные эфемерны: живут, пока жив под (долг, закроется в 5.5)
        - name: data
          emptyDir:
            sizeLimit: 64Mi

Не забудь снова заменить <github-user> на логин (sed из задания 1). Изменился шаблон пода (добавился sizeLimit), поэтому Deployment создаст новый ReplicaSet и заменит поды: старые заметки пропадут.

  1. Посмотри дифф, примени и проверь. kubectl diff завершается с кодом 1, если различия есть: это не ошибка. get rs покажет оба ReplicaSet: старый с нулём подов (запас для отката) и новый.
kubectl diff -f k8s/base/10-deployment.yaml
kubectl apply -f k8s/base/10-deployment.yaml
kubectl -n notes rollout status deploy/notes
kubectl -n notes get deploy notes
kubectl -n notes get rs,pods -o wide
  1. Проверь, что у каждой реплики свои данные. Сначала запиши одну заметку (port-forward попадёт в какой-то один под), потом посчитай строки в файле каждого пода. В цикле for p in $(...) переменная p по очереди принимает имя пода; wc -l < файл считает строки; { ...; } 2>/dev/null || echo 0 печатает 0, если файла ещё нет (в поде, куда заметок не попало):
kubectl -n notes port-forward deploy/notes 18080:8080 &
sleep 2
curl -sS -X POST -d '{"text":"заметка для одного пода"}' http://127.0.0.1:18080/notes
kill %1
for p in $(kubectl -n notes get pods -o name); do
  echo -n "$p: "
  kubectl -n notes exec "$p" -- sh -c '{ wc -l < /data/notes.txt; } 2>/dev/null || echo 0'
done
  1. Зафиксируй в git:
git add k8s/base/10-deployment.yaml
git commit -m "k8s: Deployment notes, 3 реплики, emptyDir"

Что должно получиться:

deployment.apps/notes configured
deployment "notes" successfully rolled out
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
notes   3/3     3            3            9m
{"id": 1}
pod/notes-7c54d8b9d-4hq2v: 0
pod/notes-7c54d8b9d-m8x5r: 1
pod/notes-7c54d8b9d-t6w9z: 0

Три пода Running на разных workers. Хэши и то, какой из подов получил заметку, у тебя будут другими: важно, что строка больше нуля только в одном.

Как читать вывод: configured значит, что объект существовал и apply изменил его. 3/3 и UP-TO-DATE 3 говорят, что все три копии на новом шаблоне. Счётчики строк 0, 1, 0 показывают главный урок задания: каждая реплика хранит заметку в своём emptyDir, поэтому три копии это три разные базы. Решается это общим хранилищем в 5.5.

Объясни себе:

  • Почему при трёх репликах и файловом хранилище «заметки то есть, то нет» и какой объект в 5.3-5.5 это исправит?
  • Что изменится в данных, если удалить один под?
  • Почему нельзя просто поставить replicas: 3 и считать приложение отказоустойчивым?

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

  • The Deployment "notes" is invalid: spec.selector: Invalid value: ... field is immutable: ты изменил matchLabels у существующего Deployment. Селектор менять нельзя: удали Deployment (kubectl delete -f ...) и создай заново.
  • Error from server (BadRequest): error when creating "k8s/base/10-deployment.yaml": Deployment in version "v1" cannot be handled as a Deployment: ... unknown field: опечатка в имени поля. Проверь его через kubectl explain deploy.spec.template.spec.containers.

Сломай и почини

Скачай скрипт поломки и запусти один сценарий (1, 2 или 3). Скрипт не читай: ломай, диагностируй, чини. Он работает без sudo, берёт твой k8s/base/10-deployment.yaml, применяет испорченную копию к Deployment notes (сам файл в проекте не меняется) и требует, чтобы задание 4 было выполнено.

curl -fsSL -o /tmp/break-5.2.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/5.2/break.sh
bash /tmp/break-5.2.sh 1    # или 2, или 3
kubectl -n notes get pods

Вернуть рабочее состояние: bash /tmp/break-5.2.sh fix (безопасно запускать повторно).

Симптом

Ты выкатил новую версию, а kubectl -n notes get pods показывает часть подов не в Running. Возможные картины (по одной за запуск): ErrImagePull и ImagePullBackOff, CrashLoopBackOff (перезапуски растут), либо Pending без узла. Старые поды при этом продолжают работать (выкатка идёт постепенно и останавливается на новом поде), но обновление не завершается: rollout status ждёт и не дожидается.

Гипотезы

  1. Образ не найден или недоступен: опечатка в теге, нет прав на приватный образ.
  2. Контейнер стартует и сразу падает: неверная команда, ошибка конфигурации.
  3. Под не может быть размещён: запрошено больше ресурсов, чем есть на узлах.

Проверки

Всегда начинай с событий и с точной причины, а не с догадок. <имя> замени именем проблемного пода из get pods. logs --previous показывает логи упавшего запуска, get events --sort-by=.lastTimestamp | tail -10 десять самых свежих событий по времени, describe nodes | grep -A6 'Allocated resources' сколько ресурсов узлов уже занято запросами.

kubectl -n notes get pods
kubectl -n notes describe pod "<имя>" | sed -n '/Events/,$p'
kubectl -n notes logs <имя> --previous      # логи упавшего запуска
kubectl -n notes get events --sort-by=.lastTimestamp | tail -10
kubectl describe nodes | grep -A6 'Allocated resources'

Исправление

Разбор сценариев

1. ImagePullBackOff (опечатка в теге). В describe в событиях: Failed to pull image "ghcr.io/<user>/notes:0.4.O": ... manifest unknown или not found. Кластер пытается снова с нарастающей паузой, отсюда BackOff. Это не «упало приложение», образа просто нет. Причины бывают такие: опечатка в теге или имени, образ не опубликован, приватный реестр без imagePullSecrets (текст unauthorized или denied), лимит запросов реестра. Исправление: kubectl -n notes set image deploy/notes notes=ghcr.io/<github-user>/notes:0.4.0 (и поправить файл, чтобы git совпадал с кластером). Для приватного образа:

kubectl -n notes create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io --docker-username=<github-user> \
  --docker-password=CHANGE_ME

и в шаблоне пода imagePullSecrets: [{name: ghcr-pull}].

2. CrashLoopBackOff. Статус означает: контейнер запускается, завершается с ошибкой, кластер перезапускает его с растущей паузой (10, 20, 40 секунд, потолок 5 минут). kubectl logs покажет уже новый пустой запуск, поэтому нужен --previous: там будет ошибка: PORT должен быть числом. В describe смотри Last State: Terminated, Exit Code: 1 или 2 (ошибка приложения, например неверная переменная, у нас 2), 127 (команда не найдена), 137 (убит, SIGKILL, часто из-за памяти). Исправление: вернуть корректные command и env в манифесте и применить его.

3. Pending (слишком большие requests). В describe: 0/3 nodes are available: 1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: }, 2 Insufficient cpu (или Insufficient memory; узел control-plane отсекает taint, поэтому «не хватает» у двух рабочих). Планировщик (scheduler) не нашёл узел, где хватит запрошенных ресурсов; requests это гарантия при размещении, а не фактическое потребление. Исправление: уменьшить resources.requests до разумных (для «Заметок» 50m и 64Mi, подробнее в 5.7) и применить.

Общий вывод: не гадай по STATUS, читай Events и --previous.

После починки проверь kubectl -n notes rollout status deploy/notes. Проще всего вернуть исходное состояние скриптом (fix) или заново применить файл: kubectl apply -f k8s/base/10-deployment.yaml.

ИИ в помощь

Нейросеть хорошо объясняет статусы подов и ошибки в YAML, но не видит твой кластер и может предложить устаревший формат манифеста. Общие правила: ИИ-помощник.

Задача: разобрать статус пода и события.

Я учу Kubernetes. Под в статусе <вставь STATUS и RESTARTS из kubectl get pods>.
Вот хвост kubectl describe pod (блок Events): <вставь строки>.
Объясни, какой компонент (планировщик, kubelet, containerd) написал каждую строку, на каком шаге
цепочки apply остановка и что проверить. Дай список из трёх команд от самой дешёвой.

Проверь ответ: сверь с таблицей статусов из теории и колонкой From в событиях. Типичная ошибка: нейросеть советует «пересоздать кластер» или путает ImagePullBackOff (образ не скачан) с CrashLoopBackOff (процесс падает).

Задача: найти ошибку в манифесте Deployment.

Вот манифест Deployment, kubectl apply отвечает: <вставь текст ошибки>.
<вставь манифест>
Найди ошибку, объясни, что в YAML не так (отступ, селектор, метки шаблона), и покажи исправленный
фрагмент. Не меняй имена и образ.

Проверь ответ: примени исправление с kubectl apply --dry-run=server -f и проверь, что селектор совпадает с метками в template. Типичная ошибка: нейросеть переписывает манифест целиком и подставляет тег latest или другое имя образа.

Задача: составить манифест с нуля.

Напиши манифест Deployment: имя notes, namespace notes, 3 реплики, образ ghcr.io/<github-user>/notes:0.4.0,
порт 8080, том emptyDir в /data. Добавь рекомендуемые метки app.kubernetes.io и комментарии на русском.

Проверь ответ: сравни с k8s/base/10-deployment.yaml из задания 4 и убедись, что selector.matchLabels совпадает с метками в template, а тег не latest.

Словарик урока

Термин Простыми словами
Под (Pod) Обёртка вокруг одного или нескольких контейнеров: один узел, один IP, общие тома
Sidecar Второй контейнер в поде, который работает рядом с основным (логи, прокси)
Init-контейнер Контейнер, который отрабатывает и завершается до старта основных
Контроллер (controller) Объект, который следит за состоянием и поддерживает желаемое
ReplicaSet Контроллер, который держит заданное число одинаковых подов
Deployment Контроллер над ReplicaSet: обновления и откаты
Реплика (replica) Одна из одинаковых копий приложения (один под)
Шаблон пода (template) Описание пода внутри Deployment: как выглядит каждая копия
Селектор (selector) Условие по меткам: какие объекты «свои»
Метка (label) Пара ключ: значение на объекте
Префикс ключа метки Часть до / (app.kubernetes.io): не даёт ключам разных авторов столкнуться
Рекомендуемые метки Общий договор об именах меток (app.kubernetes.io/name, instance, version, component, part-of, managed-by): их понимают Helm и другие инструменты
app.kubernetes.io/name Метка с именем приложения; в курсе везде notes
Хэш в имени Отпечаток шаблона пода; меняется, когда шаблон меняется
ownerReferences Поле «мой владелец» у объекта
Фаза (phase) Крупный этап жизни пода: Pending, Running и т.д.
ImagePullBackOff Образ не скачивается, кластер повторяет попытки с паузой
CrashLoopBackOff Контейнер стартует и падает, перезапуски с растущей паузой
Terminating Под удаляется
terminationGracePeriodSeconds Пауза (по умолчанию 30 с) между SIGTERM и SIGKILL
emptyDir Пустая папка, которая живёт, пока жив под
imagePullPolicy Правило: когда скачивать образ (IfNotPresent, Always, Never)
Переменная окружения (env) Пара «имя = значение», которую программа читает при старте
kubectl apply Привести объект к описанию в файле (повторяем сколько угодно)
kubectl port-forward Туннель с порта твоей машины в порт пода
Идемпотентность Повторный запуск не меняет результат
Stateless Приложение без собственного состояния: любую копию можно заменить

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Короткие вопросы с пометкой [на скорость] тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Чем отличаются Pod, ReplicaSet и Deployment?

Ответ

Под это один или несколько контейнеров с общим IP на одном узле, он одноразовый. ReplicaSet держит нужное число одинаковых подов по селектору. Deployment управляет ReplicaSet и умеет обновлять и откатывать. Руками я почти всегда создаю только Deployment.

Что хотят услышать: цепочка Deployment, ReplicaSet, Pod; поды заменяются, а не чинятся; обновления и откат через Deployment.

Красный флаг: «под и контейнер это одно и то же» или «ReplicaSet я создаю вручную».

2. [middle] [часто] Под в CrashLoopBackOff. Как разбираешься?

Ответ

Смотрю kubectl logs --previous, потому что текущий запуск ещё пуст. В describe читаю Exit Code и Reason: 1 или 2 это ошибка приложения, 127 нет команды, 137 SIGKILL (часто OOMKilled, нехватка памяти). Проверяю переменные окружения, монтирования, зависимости (БД недоступна). Пробы (проверки здоровья, урок 5.7) могут убивать здоровое, но медленное приложение.

Что хотят услышать: --previous, коды выхода, растущая пауза, отличие падения приложения от убийства пробой.

Красный флаг: «увеличу restartPolicy» или «удалю под».

3. [middle] [часто] Под висит в Pending. Что проверишь?

Ответ

describe pod, раздел Events: сообщение планировщика вроде 0/3 nodes are available: Insufficient memory, node(s) had untolerated taint (на узле стоит «запрет» для подов без специального разрешения), didn't match Pod's node affinity (под просил узел с определённым признаком), либо pod has unbound immediate PersistentVolumeClaims (не выдан диск). Далее describe nodes, раздел Allocated resources. Лечу requests, taints и tolerations, или добавляю узлы.

Что хотят услышать: планировщик решает по requests, а не по фактической нагрузке; taints, PVC.

Красный флаг: «Pending значит образ скачивается».

4. [middle] [на скорость] Я удалил под из Deployment. Что произойдёт и почему?

Ответ

ReplicaSet увидит, что подов меньше replicas, и создаст новый. У нового будет другое имя и IP. Старый перейдёт в Terminating: контейнеру пошлют SIGTERM, через terminationGracePeriodSeconds (по умолчанию 30 секунд) SIGKILL. Данные в emptyDir (временной папке пода) удалённого пода потеряются.

Что хотят услышать: reconcile-цикл, желаемое против фактического, grace period, эфемерность emptyDir.

Красный флаг: «под перезапустится сам с теми же данными и IP».

5. [middle] Под в статусе ImagePullBackOff. Твои действия?

Ответ

Начинаю с kubectl describe pod и читаю события: там точная ошибка. Проверяю тег и имя образа (опечатка), существует ли он в реестре, публичный ли, есть ли imagePullSecrets (пароль к реестру), доступна ли сеть у узла к реестру. Для kind проверяю, что образ загружен через kind load. Чиню set image или манифест.

Что хотят услышать: describe и Events, manifest unknown против unauthorized, imagePullSecrets, imagePullPolicy.

Красный флаг: «перезапущу под» без чтения событий.

6. [junior] Зачем метки и селекторы, и что будет, если селектор Deployment не совпадёт с метками шаблона?

Ответ

Метки это ключи-значения на объектах, селектор выбирает по ним поды. API не примет Deployment, у которого selector не совпадает с метками шаблона: ошибка валидации. Тем же способом Service (постоянный адрес группы подов) найдёт поды.

Что хотят услышать: matchLabels, неизменяемость селектора, связь со Service.

Красный флаг: «метки нужны только для красоты».

7. [junior] [на скорость] Чем kubectl apply отличается от kubectl create, и что делает diff?

Ответ

create создаёт и падает, если объект есть. apply декларативен и повторяем: приводит объект к файлу. kubectl diff показывает разницу до применения, --dry-run=server проверяет манифест на сервере без сохранения.

Что хотят услышать: декларативный против императивного подхода, идемпотентность, файлы из git.

Красный флаг: «всё равно, create быстрее».

8. [middle] Твой сервис использует тег latest в образе. Чем это плохо?

Ответ

Тег изменяем: сегодня и завтра под тем же именем разные образы. По умолчанию подразумевается imagePullPolicy: Always, и один узел может подтянуть новую версию, другой остаться на старой. Откат и разбор инцидента невоспроизводимы. Использую фиксированную версию и лучше digest (уникальный отпечаток образа, который нельзя перепривязать).

Что хотят услышать: воспроизводимость, разные версии на узлах, привязка к git-тегу, digest.

Красный флаг: «latest это всегда последняя стабильная версия».

9. [middle] Мы поставили replicas: 3, а пользователи жалуются, что заметки то пропадают, то появляются. Что происходит?

Ответ

Приложение хранит данные локально (файл или память), а не в общем хранилище. Запросы попадают на разные поды, у каждого свои данные, а при пересоздании пода они теряются. Реплики не сделали приложение отказоустойчивым: состояние надо вынести в БД или общий том. В нашем проекте это Postgres в 5.5 и 5.6.

Что хотят услышать: stateless против stateful, вынос состояния, emptyDir эфемерен.

Красный флаг: «увеличим реплики ещё».

10. [middle] [на скорость] Как посмотреть логи контейнера, который уже перезапустился и упал?

Ответ

kubectl logs <под> --previous покажет логи предыдущего запуска. Если в поде несколько контейнеров, добавляю -c <имя>. Если под уже пересоздан, логи потеряны: их нужно собирать централизованно (тема 8).

Что хотят услышать: --previous, -c, ограниченное время жизни логов на узле, централизованное логирование.

Красный флаг: «зайду в под через exec и посмотрю файл».

11. [middle] Как Deployment выкатывает новую версию и как откатить неудачную?

Ответ

По умолчанию стратегия RollingUpdate: Deployment создаёт новый ReplicaSet и постепенно переключает поды, параметры maxSurge и maxUnavailable (по умолчанию 25%) определяют, сколько подов можно добавить сверх и сколько временно потерять. Слежу за выкаткой: kubectl rollout status deploy/notes. Историю вижу через kubectl rollout history deploy/notes, откатываю kubectl rollout undo deploy/notes. При этом откат меняет кластер, а не git, поэтому манифест в git надо поправить тоже.

Что хотят услышать: RollingUpdate, maxSurge/maxUnavailable, rollout status/undo, git как источник правды.

Красный флаг: Удалять поды руками, чтобы «обновились».

12. [middle] Зачем в одном поде несколько контейнеров и что такое init-контейнер?

Ответ

Контейнеры одного пода делят сетевое пространство (общий localhost) и могут делить тома. Поэтому рядом с основным кладут sidecar-помощников: сбор логов, прокси. Обычно в поде один основной контейнер, лишнее не добавляю. Обычные init-контейнеры выполняются последовательно до основных и должны завершиться успешно, иначе под не стартует (нативный sidecar - это init-контейнер с restartPolicy: Always, он продолжает работать вместе с основными). Использую их для ожидания зависимости или подготовки данных. Масштабируется под целиком, а не отдельный контейнер.

Что хотят услышать: общий localhost и тома, sidecar, init выполняется до основных, масштабируется под целиком.

Красный флаг: Класть приложение и базу в один под.

13. [middle] Чем kubectl scale отличается от правки replicas в манифесте?

Ответ

kubectl scale deploy notes --replicas=5 меняет живой объект сразу, но в git остаётся старое число. Следующий kubectl apply манифеста вернёт прежнее значение, и изменение потеряется. Для временной реакции на нагрузку scale годится, но стабильное число правлю в манифесте и применяю через apply или CI. Если количество подов регулирует HPA, поле replicas из манифеста лучше убрать, чтобы apply не боролся с автоскейлером.

Что хотят услышать: императивная правка против декларативной, источник правды в git, HPA и replicas.

Красный флаг: Масштабировать только командой и забыть про манифест.

14. [junior] [часто] Зачем запускать несколько реплик приложения?

Ответ

Три причины. Отказоустойчивость: если под или узел упал, остальные продолжают отвечать, пока Kubernetes поднимает замену. Выкатки без простоя: RollingUpdate заменяет поды по одному. Масштаб: нагрузка делится между репликами через Service. Условия: приложение не хранит состояние в памяти и локальных файлах, реплики стоят на разных узлах (podAntiAffinity или topologySpreadConstraints), а PodDisruptionBudget не даёт выключить все сразу. Две реплики на одном узле при его падении пропадают обе.

Что хотят услышать: отказоустойчивость, выкатка, масштаб; условие stateless и расстановка по узлам.

Красный флаг: «реплики сами сделают сервис отказоустойчивым» без вопроса о состоянии и узлах.

Проверено на версиях

  • Ответы приложения (/healthz, POST и GET /notes, строка started, ошибка при PORT=abc с кодом выхода 2, ответ 400 на тело не в JSON) получены запуском project/notes/versions/v4.py (STORE=file) на Mac автора; Python там 3.9.6, в образе будет 3.13. Формат вывода kubectl взят из текущей редакции урока и знания версий, на кластере не прогонялся.
  • Манифест 10-deployment.yaml (обе редакции) и испорченные копии из break.sh проверены kubeconform -strict; сам break.sh проверен shellcheck и прогоном с подменённым kubectl, в живом кластере не запускался.
  • Команда { wc -l < файл; } 2>/dev/null || echo 0 проверена в контейнере alpine:3.22.
  • kind: v0.33.0
  • kubectl: 1.37.1
  • Kubernetes (образ узла kindest/node из 5.1): 1.37.x
  • Образ приложения: ghcr.io/<github-user>/notes:0.4.0 (app v4)
  • Python в образе: 3.13 (python:3.13-slim)
  • busybox: 1.37 (в примере пода с двумя контейнерами, не применялся)

Итог урока: ты умеешь

  • умею описать Deployment манифестом и применить его через kubectl apply
  • умею объяснить цепочку Deployment, ReplicaSet, Pod и роль меток и селекторов
  • умею смотреть логи, заходить в контейнер и открывать доступ через port-forward
  • умею показать самовосстановление: удалить под и масштабировать через scale
  • умею объяснить, что происходит с подом при удалении (SIGTERM, 30 секунд, SIGKILL)
  • умею отличить ImagePullBackOff, CrashLoopBackOff и Pending по событиям и --previous
  • умею загрузить локальный образ в kind и объяснить imagePullPolicy
  • умею объяснить, почему тег latest не используем, а emptyDir эфемерен

Дальше: Урок 5.3: Service и DNS кластера

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 5 урок 5.2 3.5 ч курс 0/0 ← → уроки