Раздел 5 · К собеседованию — глава 5.3

Банк типовых вопросов

То, что спрашивают почти на каждом DevOps-собеседовании. Каждый вопрос разобран так же, как темы в основных главах: сначала объясняется, что вообще происходит внутри, и только потом — что из этого следует сказать. Читать это как шпаргалку не получится и не нужно: понятый механизм воспроизводится своими словами в любой формулировке, а заученная фраза разваливается от первого уточняющего вопроса.

Под ответами иногда есть строчка «добавь» — это то, что превращает нормальный ответ в сильный: деталь, которую называют люди, реально работавшие с темой руками.

§Linux

Место на диске кончилось, но du показывает, что данных мало

Чтобы понять этот случай, надо вспомнить, как в Linux вообще устроено удаление файла. Файл — это inode, в котором лежат метаданные и указатели на блоки данных. Имя файла хранится не в нём, а в каталоге, и является всего лишь ссылкой на номер inode. Когда ты делаешь rm, ядро удаляет ссылку из каталога и уменьшает счётчик ссылок. Блоки освобождаются только тогда, когда счётчик дошёл до нуля и файл никем не открыт.

Отсюда и берётся расхождение. Приложение открыло лог-файл и держит дескриптор. Кто-то (ротация, чья-то рука) удалил файл. Имени в каталоге больше нет — du обходит дерево каталогов и этот файл не находит, поэтому показывает мало. А блоки заняты, потому что дескриптор жив, — и df, который спрашивает файловую систему про свободные блоки, честно показывает, что места нет. Расхождение будет расти, пока процесс продолжает писать в уже удалённый файл.

Ищутся такие файлы через lsof +L1 — ключ отбирает открытые файлы с нулём ссылок, то есть ровно наш случай. Правильное лечение — перезапустить процесс: при завершении ядро закроет дескриптор и место вернётся мгновенно. Если перезапуск невозможен, можно обнулить файл через его дескриптор в procfs — : > /proc/PID/fd/5, — это освободит блоки, не трогая сам процесс.

Вторая возможная причина того же симптома — закончились не блоки, а inode. Их количество задаётся при создании файловой системы и конечно; миллионы мелких файлов в каталоге кеша или в очереди почты способны исчерпать их при куче свободных гигабайт. Диагноз — df -i, а лечение уже другое: удалять файлы или пересоздавать ФС с другими параметрами.

добавь Что смотришь df -h и df -i сразу вместе, до всякого du. Это одна команда, которая отсекает половину вариантов.

Load average 40, а процессор простаивает

Здесь всё держится на одной особенности Linux, о которой многие не знают. В классическом UNIX load average означал среднее число процессов, готовых исполняться, то есть буквально нагрузку на процессор. В Linux в этот счётчик дополнительно включены процессы в состоянии D — те, что застряли в непрерываемом ожидании ввода-вывода.

Поэтому в Linux load average — это не «загрузка процессора», а «сколько процессов чего-то ждут или работают». Сорок при простаивающем CPU означает буквально следующее: около сорока процессов висят в ожидании диска или сети до хранилища. Процессор при этом действительно нечем занять — ему нечего считать, все ждут данных.

Дальше смотреть надо не top, а подсистему ввода-вывода: iostat -x 1, где интересны колонка %util (насколько устройство занято) и await (сколько в среднем ждёт запрос). Плюс список застрявших процессов — ps -eo pid,stat,wchan:30,comm | awk '$2 ~ /D/', где wchan покажет, в какой функции ядра они спят. Типичные виновники: отвалившийся NFS-сервер, деградировавший RAID, перегруженный сетевой диск, умирающий SSD.

Формулировка для ответа: «упёрлись в ввод-вывод, а не в процессор — в Linux load average считает и процессы в D-состоянии». Подробнее про сами состояния — глава 2.1, §2.

Процесс не убивается даже через kill -9

Сигнал не доставляется процессу в произвольный момент. Ядро доставляет его на границе возврата из системного вызова в пользовательское пространство — то есть процесс должен сначала «вернуться» из ядра, и только тогда обработается сигнал. Обычный сон в ожидании события (состояние S) прерываемый: ядро разбудит процесс, вызов вернётся с ошибкой EINTR, сигнал отработает.

Но часть операций прерывать нельзя. Процесс уже передал ядру буферы, драйвер начал передачу данных напрямую в память, структуры файловой системы находятся в промежуточном состоянии. Разбудить процесс посреди этого — испортить данные. Такой сон помечается как непрерываемый, состояние D, и сигналы просто копятся в очереди до завершения операции. Включая SIGKILL, для которого нет исключения.

Обычно это длится микросекунды и не заметно. Устойчивое D означает, что операция ввода-вывода не завершается вообще: пропал NFS-сервер (особенно при жёстком монтировании), отвалился LUN, залип драйвер, умер диск. Процесс тут ни при чём и починить его нельзя — надо восстанавливать доступ к хранилищу, а в безнадёжном случае перезагружать узел.

Ответ на собесе стоит произносить именно так: «kill -9 здесь бесполезен не потому, что процесс защищён, а потому что он физически не в пользовательском пространстве — он внутри системного вызова, и сигнал ему не доставят, пока вызов не вернётся. Лечить надо хранилище».

Разница между SIGTERM, SIGKILL и SIGHUP

Разница не столько в самих сигналах, сколько в том, кто их обрабатывает. SIGTERM — это просьба завершиться, которую приложение может перехватить: закрыть соединения, дописать буферы, откатить или зафиксировать транзакцию и выйти самому. Именно его отправляют kill без аргументов, systemd при остановке юнита и все системы оркестрации. Если обработчика нет, действие по умолчанию — завершение процесса.

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

SIGHUP исторически означал «связь с терминалом разорвана» — его получал лидер сессии, когда исчезал управляющий терминал. Поскольку у демонов терминала нет и этот сигнал им никогда не приходит «по делу», сложилась традиция использовать его как «перечитай конфигурацию». Так работают nginx, HAProxy, rsyslog и многие другие.

добавь Что в контейнере SIGTERM без обработчика игнорируется, потому что приложение работает как PID 1, а к нему ядро не применяет действия по умолчанию. Отсюда и десятисекундная пауза при docker stop, и оборванные запросы при каждой выкатке — см. главу 2.1, §7. Это отличный переход к разговору про graceful shutdown.

Что такое зомби-процесс и чем он опасен

Когда процесс завершается, ядро освобождает его память, закрывает файлы, отпускает все ресурсы — но оставляет в таблице процессов запись с кодом возврата. Она нужна родителю: он имеет право узнать, чем закончился его ребёнок, вызвав wait(). Пока родитель этого не сделал, запись висит, и такой процесс называется зомби.

Короткоживущие зомби абсолютно нормальны — они существуют доли секунды между завершением ребёнка и wait() родителя. Проблема возникает, когда родитель написан плохо: плодит детей и никогда их не забирает. Тогда записи копятся.

Вреда от них меньше, чем принято думать: памяти они не занимают, процессор не едят. Расходуют они единственный ресурс — номера в таблице процессов, которых конечное число. Когда номера кончатся, система не сможет создать ни один новый процесс: даже залогиниться будет нечем, любая команда упрётся в ошибку fork: Resource temporarily unavailable.

И важная деталь: убить зомби нельзя. Он уже мёртв, убивать нечего, kill -9 по нему не делает ничего. Убирают через родителя — сначала SIGCHLD, вдруг обработчик просто не сработал, а если не помогло, то убивают самого родителя. Тогда зомби осиротеют, их усыновит init и немедленно приберёт.

Как посмотреть, что процесс делает прямо сейчас

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

Если процесс завис и непонятно на чём — strace -p PID покажет системные вызовы в реальном времени: сразу видно, ждёт он чтения из сокета, упирается в файл или крутится в цикле. Учитывай, что strace ощутимо замедляет процесс, на нагруженном проде им злоупотреблять не стоит. Если процесс висит в D, strace не поможет — там смотрят cat /proc/PID/stack, который покажет стек в ядре.

Если вопрос «с чем он работает» — lsof -p PID даст открытые файлы, сокеты и библиотеки, а ss -tanp | grep PID — сетевые соединения с состояниями. Каталог /proc/PID/ вообще стоит знать: cwd — текущий каталог, exe — какой бинарник запущен на самом деле, environ — переменные окружения, limits — лимиты, fd/ — все дескрипторы.

Если процесс ест процессор — perf top -p PID покажет, в каких функциях он проводит время. Для Java своё: jstack для стеков потоков, jcmd для всего остального.

Жёсткая и мягкая ссылка

Разница становится очевидной, если помнить, что имя файла и сам файл — разные вещи. Файл — это inode; имя — запись в каталоге, указывающая на номер inode.

Жёсткая ссылка — это просто вторая такая запись, указывающая на тот же inode. Оба имени полностью равноправны, «оригинала» среди них нет. Файл существует, пока счётчик ссылок больше нуля, поэтому удаление одного имени ничего не разрушает. Ограничения следуют из природы: inode существует в пределах одной файловой системы, значит, жёсткая ссылка не может вести на другой раздел; и на каталоги их не делают, чтобы не создавать циклы в дереве.

Мягкая (символическая) ссылка — это отдельный маленький файл, содержимое которого представляет собой путь к цели. Ядро при обращении читает этот путь и идёт по нему. Отсюда и гибкость (можно указывать куда угодно, включая другую ФС и несуществующий путь), и хрупкость: переименовали цель — ссылка сломалась и стала «висячей».

Практический пример, который хорошо звучит в ответе: ротация логов через жёсткую ссылку позволяет процессу продолжать писать в файл, пока ты работаешь со вторым именем; а /usr/bin/python3, ведущий на конкретную версию, — типичная символическая ссылка, потому что цель меняется.

Как ограничить процессу ресурсы

Механизм называется cgroups, и это тот самый механизм, на котором построены контейнеры и systemd. Процессы объединяются в группу, группе назначаются лимиты, ядро следит за их соблюдением. Ничего специфически «контейнерного» тут нет — это обычная возможность ядра, доступная любому процессу.

Для разовой задачи проще всего через systemd: systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% команда. Для постоянного сервиса те же параметры прописываются в юните. В контейнерном мире это resources.limits в манифесте — и превращается ровно в те же настройки cgroup.

А вот что действительно важно проговорить, потому что это спрашивают следом: лимит CPU и лимит памяти ведут себя принципиально по-разному. Процессорное время делится, поэтому превышение квоты приводит к троттлингу — процесс притормаживают, но он живёт и работает, просто медленнее. Память поделить нельзя: её либо дали, либо нет. Поэтому превышение лимита памяти означает мгновенное убийство процесса OOM-киллером внутри cgroup, и контейнер получает код возврата 137.

Отдельно стоит помнить про старый механизм ulimit — он ограничивает не группу, а отдельный процесс, и живёт в другой плоскости. Из практически важного там — лимит на число открытых файлов, который регулярно упирается в потолок у нагруженных сервисов.

Куда смотреть, если сервис не стартует под systemd

Последовательность стоит рассказывать как метод, а не как список команд. Сначала systemctl status name — он даст код возврата и последние строки лога, и уже по коду часто всё понятно. Дальше journalctl -u name -n 100 --no-pager — полный лог юнита, где обычно и лежит настоящая ошибка. Если непонятно, journalctl -xe покажет общую картину с пояснениями, включая сообщения от SELinux и других подсистем, которые могли заблокировать запуск.

Отдельно полезна systemd-analyze verify name.service — она проверяет сам юнит на ошибки, не запуская его.

Из частых причин, которые стоит назвать: неверный Type= — например, forking у процесса, который не форкается, и systemd вечно ждёт ухода родителя; относительные пути в ExecStart, которых systemd не понимает; отсутствие переменных окружения, которые были в интерактивном шелле, но не попали в юнит (систем­ные юниты не читают профиль пользователя); нехватка прав из-за User=; и запрет от SELinux, который в логах виден как AVC-сообщение.

§Сети

Соединение не устанавливается — как понять, где рвётся

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

Мгновенный connection refused означает, что твой SYN-пакет дошёл до целевой машины, ядро увидело, что на этом порту никто не слушает, и ответило пакетом с флагом RST. То есть сетевая связность есть, маршруты в порядке, файрвол пропустил — проблема в приложении: оно не запущено, упало, слушает другой порт или только на localhost. Проверяется на той стороне через ss -tulpn.

Долгий таймаут означает, что ответа не пришло вообще. Клиент несколько раз повторил SYN с растущими интервалами и сдался. Это уже сетевая история: пакет дропнули по дороге (файрвол, security group, сетевая политика), либо он дошёл, а ответ не вернулся (асимметричная маршрутизация, отсутствие обратного маршрута, NAT). Ключевой признак — тишина вместо ответа.

Дальше метод один: снимать дамп с обеих сторон одновременноtcpdump -ni any port 443 and host X. Это сразу показывает, что именно происходит: SYN не долетел (значит, теряется по пути от клиента), SYN долетел и ответ ушёл, но клиент его не увидел (значит, теряется обратный путь), или ответ пришёл с RST (значит, всё-таки приложение). Без парного дампа спор «у нас всё уходит» / «у нас ничего не приходит» может тянуться днями.

добавь Что параллельно проверяешь traceroute и таблицу маршрутов на обеих сторонах — асимметричный маршрут даёт ровно картину «запрос вижу, ответ не доходит».

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

Это одна из самых характерных сетевых проблем, и по такому описанию она узнаётся почти безошибочно. Причина в MTU — максимальном размере кадра, который можно отправить в интерфейс.

Современный TCP ставит в IP-заголовке флаг «не фрагментировать» и полагается на механизм обнаружения MTU по пути: если где-то встретился канал поуже, маршрутизатор обязан отбросить пакет и прислать обратно ICMP-сообщение с указанием допустимого размера. Отправитель его получит и уменьшит сегменты.

Ломается это, когда кто-то по дороге блокирует ICMP — обычно из соображений «это же пинг, а пинг небезопасен». Тогда получается издевательская картина: рукопожатие проходит (пакеты крошечные), короткие запросы работают, а первый большой ответ упирается в узкое место, отбрасывается, и обратной связи об этом нет. Отправитель бесконечно повторяет один и тот же слишком большой сегмент. Соединение висит и отваливается по таймауту.

Проверяется пингом с запретом фрагментации и подбором размера: ping -M do -s 1472 host — это ровно 1500 байт с заголовками. Если проходит 1472, но не проходит на большем, MTU полный; если нет — уменьшаешь, пока не найдёшь границу.

И обязательно скажи, почему это твоя тема: любой туннель откусывает от 1500 свои заголовки. VXLAN — около 50 байт, отсюда классический MTU 1450 в оверлейных сетях Kubernetes; WireGuard — около 60; IPsec — от 50 до 70. Стоит где-то ошибиться с MTU интерфейса или настройками CNI, и получаешь ровно эту мистику. Полный разбор — глава 1.1, §8.

Что такое ARP и когда он важен на практике

ARP решает узкую задачу: узнать канальный адрес того, кому нужно передать кадр прямо сейчас. Когда хост собирается отправить пакет, он по маске определяет, находится получатель в его сегменте или за маршрутизатором. В первом случае нужен MAC получателя, во втором — MAC шлюза. И там, и там за этим идут в ARP: широковещательный вопрос «у кого такой IP», unicast-ответ от владельца, запись в кеш на несколько минут.

Теоретическая часть на этом заканчивается, и дальше начинается то, ради чего вопрос вообще задают. Самое практически важное проявление ARP — переезд виртуального IP при файловере. Схема стандартная: два узла, между ними плавающий адрес под управлением keepalived, за ними HAProxy. Активный узел умер, адрес переехал на резервный.

Проблема в том, что IP тот же, а MAC за ним — другой, потому что это физически другая сетевая карта. У всех, кто общался с этим адресом, в ARP-кеше лежит старая пара, и они продолжают слать кадры на MAC мёртвого узла. Трафик уходит в никуда, пока записи не протухнут, — это минуты полного простоя при формально успешном переключении.

Решается это gratuitous ARP: новый владелец сразу после захвата адреса широковещательно объявляет «этот IP теперь у меня, вот мой MAC», хотя никто не спрашивал. Keepalived делает это автоматически и обычно несколько раз подряд. А если файловер прошёл, но трафик не поехал, — смотреть надо именно сюда: tcpdump -i eth0 arp и arping -U. Подробнее — глава 1.2, §5.

Чем NAT отличается от проксирования

Различие проходит по уровню, на котором работает механизм, и по числу соединений.

NAT живёт на третьем-четвёртом уровне и занимается подменой адресов и портов в проходящих пакетах. Соединение при этом одно, сквозное: клиент установил TCP-сессию с сервером, просто по дороге кто-то переписал в заголовках адрес источника или назначения. Содержимое пакетов NAT не видит и не трогает — для него это непрозрачные байты. Он запоминает трансляцию в таблице conntrack и разворачивает её обратно для ответов.

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

Практическое следствие, которое стоит назвать: через NAT проходит любой протокол, потому что он в него не вникает, а прокси должен понимать протокол, который проксирует. И обратное: NAT не может распределить нагрузку по URL или сохранить IP клиента в заголовке — он просто не умеет читать HTTP.

Клиенты периодически не могут подключиться, а сервер в порядке

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

Переполнилась таблица conntrack. Каждое соединение, проходящее через узел с NAT или stateful-файрволом, занимает в ней запись. Записей конечное число, и при переполнении ядро молча отбрасывает пакеты новых соединений — в логах приложения при этом нет ничего. Смотреть /proc/sys/net/netfilter/nf_conntrack_count против nf_conntrack_max и dmesg, где будет строчка «nf_conntrack: table full, dropping packet».

Кончились исходящие порты на клиенте. Если клиент открывает и закрывает соединения в цикле без keep-alive, у него накапливаются сокеты в TIME_WAIT, а диапазон эфемерных портов — около 28 тысяч по умолчанию. Симптом — ошибка «cannot assign requested address», проверка — ss -tan state time-wait | wc -l.

Переполнилась очередь на сервере. У слушающего сокета есть очередь установленных, но ещё не принятых приложением соединений. Если приложение не успевает их разбирать, очередь заполняется и новые соединения отбрасываются. Видно в ss -lnt (колонка Recv-Q против Send-Q, где Send-Q — это размер очереди) и в счётчиках netstat -s | grep -i listen.

Общий признак всех трёх — проблема плавающая и коррелирует с нагрузкой, а не с конкретным клиентом. Именно это отличает её от ошибки конфигурации.

Чем L4-балансировка отличается от L7

L4-балансировщик принимает решение один раз — на первом пакете соединения. Дальше поток зафиксирован: все пакеты этой сессии идут на выбранный бэкенд, потому что иначе TCP просто не соберётся. Он не понимает, что внутри, поэтому работает с любым протоколом и очень быстро — фактически это подмена адресов в ядре.

Отсюда его главное ограничение, которое и надо назвать: он балансирует соединения, а не запросы. Пока запрос равен соединению (старый HTTP/1.0), разницы нет. Но современный клиент открывает соединение один раз и гоняет по нему тысячи запросов часами — HTTP/2, gRPC, пул к базе. В результате десять реплик за таким балансировщиком получают нагрузку от двух клиентов, а не размазанную по всем.

L7-балансировщик разбирает протокол и работает с отдельными запросами. Он держит собственный пул соединений к бэкендам и распределяет по нему каждый запрос независимо, поэтому длинное клиентское соединение больше не проблема. Плюс появляются возможности, невозможные на L4: маршрутизация по домену и пути, повтор неудавшихся запросов, добавление заголовков, разделение трафика по весам для канареек.

Цена — производительность (надо парсить протокол) и необходимость обрывать TLS, чтобы вообще увидеть содержимое. В Kubernetes это ровно противопоставление «Service против Ingress-контроллера», и оно разобрано в главе 3.1, §7.

§Docker и образы

Если ответы ниже пока приходится заучивать, сначала пройди главу 2.2: там namespaces, cgroups, runtime, слои и PID 1 собраны в одну причинно-следственную картину.

Чем контейнер отличается от виртуальной машины

Виртуальная машина — это эмуляция железа. Гипервизор создаёт виртуальный компьютер, на него ставится полноценная гостевая операционная система со своим ядром, и она уже ничего не знает про хост. Изоляция получается очень сильной именно потому, что граница проходит по уровню железа.

Контейнер — это обычный процесс на ядре хоста, которому подсунули искажённую картину мира. Namespace'ы дают ему собственные пространства имён: свои PID (внутри он видит себя первым процессом), своя сеть с отдельными интерфейсами, свои точки монтирования, свой список пользователей. Cgroups ограничивают ресурсы. Но ядро одно на всех, и системные вызовы контейнера обрабатывает то же самое ядро хоста.

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

Практическое следствие, которое хорошо звучит: контейнер не может иметь ядро, отличное от хостового. Поэтому Windows-контейнеры на Linux-машине работают только внутри скрытой виртуалки, и поэтому же контейнер с приложением, требующим специфического модуля ядра, зависит от того, что установлено на узле.

Разница между ENTRYPOINT и CMD, shell- и exec-формой

Смысловое разделение простое: ENTRYPOINT — это то, что запускается всегда, а CMD — аргументы по умолчанию, которые легко заменить при запуске контейнера. Если задан только CMD, он и будет командой целиком. Если заданы оба, аргументы из CMD подставляются к ENTRYPOINT.

Но на собеседовании этот вопрос задают ради второй части — формы записи, потому что за ней стоит реальная продовая проблема. Shell-форма (CMD ./app --config x) превращается в /bin/sh -c "./app --config x". Это означает, что PID 1 внутри контейнера — это шелл, а твоё приложение — его дочерний процесс.

Последствие: когда Docker или Kubernetes отправляют контейнеру SIGTERM, сигнал приходит шеллу. Шелл его никуда не передаёт и сам обычно ничего не делает. Приложение вообще не узнаёт, что его останавливают, продолжает работать, и через grace period прилетает SIGKILL — оборванные запросы, незакрытые транзакции. И так при каждой выкатке.

Exec-форма (CMD ["./app", "--config", "x"]) запускает приложение напрямую, оно становится PID 1 и получает сигналы само. Если обёртка всё-таки нужна — например, надо подставить переменные окружения, — спасает exec в конце скрипта: exec ./app "$@" заменяет процесс шелла приложением, сохраняя PID 1.

добавь Что даже при exec-форме приложение должно уметь обрабатывать SIGTERM — иначе ядро его проигнорирует, потому что для PID 1 действия по умолчанию не применяются. Это уже глава 2.1, §7.

Как работают слои и кеш сборки

Каждая инструкция Dockerfile создаёт слой — набор изменений файловой системы относительно предыдущего состояния. Итоговый образ — это стопка таких слоёв, которую движок объединяет в единое дерево через объединяющую файловую систему. Слои неизменяемы и переиспользуются между образами: если два образа собраны на одной базе, эта база физически хранится один раз.

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

Отсюда единственное правило, которое реально влияет на скорость сборки: инструкции надо располагать от редко меняющихся к часто меняющимся. Если первым делом написать COPY . ., то любая правка любого файла в проекте инвалидирует кеш, и установка зависимостей будет выполняться заново каждый раз. Правильный порядок — сначала скопировать файл со списком зависимостей, установить их, и только потом копировать исходники.

И вторая важная особенность, которую спрашивают отдельно: слой нельзя «отменить» в следующем слое. Если ты добавил секрет, а потом удалил его командой rm, то в итоговом образе будет два слоя — в одном файл появился, в другом исчез. Файл физически лежит в образе, и достать его тривиально. Поэтому очистка кеша пакетного менеджера должна происходить в той же самой инструкции, что и установка, а секреты в сборку передаются через специальный механизм.

Как уменьшить размер образа

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

Дальше — выбор базового образа. Разница между полноценным дистрибутивом, его slim-вариантом и distroless (где нет вообще ничего, кроме рантайма) измеряется сотнями мегабайт. Учитывай компромисс: в distroless нет шелла, и kubectl exec в такой контейнер не сработает — отладка потребует эфемерных контейнеров.

Остальное — гигиена: очистка кеша пакетного менеджера в той же инструкции, где он был создан; .dockerignore, чтобы в контекст сборки не уезжали .git, node_modules и локальные артефакты; отсутствие лишних установленных пакетов «на всякий случай».

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

Почему контейнер сразу завершается

Контейнер живёт ровно столько, сколько живёт его главный процесс — тот, что стал PID 1. Как только этот процесс завершился, контейнер считается остановленным, независимо от того, что там ещё запускалось в фоне. Это не ошибка, а определение.

Отсюда самая частая причина: процесс ушёл в фон и вышел. Классика — nginx без daemon off: он форкается, родитель завершается, контейнер вместе с ним. Любой демон, который «правильно» демонизируется, в контейнере ведёт себя именно так, и это надо отключать.

Вторая причина — команда просто отработала. Если запустить контейнер с командой, которая печатает что-то и выходит, контейнер честно завершится. Третья — приложение упало на старте.

Диагностика: docker logs (в Kubernetes — kubectl logs --previous, чтобы увидеть лог упавшего запуска) и код возврата, который многое говорит сам по себе. 137 — это 128 + 9, то есть SIGKILL, и почти всегда означает превышение лимита памяти. 143 — это 128 + 15, SIGTERM, штатная остановка. 139 — segfault. 1 — приложение вышло с ошибкой само, причина будет в логе. 0 в цикле — процесс отработал и вышел успешно, то есть запускается вообще не то.

§Kubernetes

При неуверенной базе начни с карты компонентов и практического минимума, а уже затем проверяй себя вопросами.

Что происходит от kubectl apply до запущенного пода

Это вопрос на понимание архитектуры, и рассказывать его надо как цепочку с явным указанием, кто за что отвечает.

Манифест уходит в apiserver — единственную точку, через которую вообще что-либо происходит в кластере. Он аутентифицирует запрос, проверяет права через RBAC, прогоняет объект через admission-контроллеры (которые могут его изменить — подставить дефолты, добавить sidecar — или отклонить), валидирует схему и сохраняет в etcd. На этом kubectl apply возвращает управление: объект создан, но ещё ничего не запущено.

Дальше вступают контроллеры, каждый из которых просто следит за своим типом объектов и приводит мир в соответствие. Контроллер деплойментов видит новый Deployment и создаёт ReplicaSet. Контроллер ReplicaSet видит, что подов меньше, чем нужно, и создаёт объекты подов — пока без узла, поле nodeName пустое.

Планировщик видит поды без узла. Для каждого он сначала отфильтровывает узлы, на которых под в принципе не поместится (не хватает ресурсов под requests, не подходят nodeSelector и affinity, мешают taints, недоступен нужный том), а затем из оставшихся выбирает лучший по набору весов — обычно предпочитая менее загруженные и те, где образ уже скачан. Результат он записывает всё в тот же apiserver: проставляет nodeName.

Kubelet на этом узле видит под, назначенный ему. Он обращается к среде исполнения контейнеров через CRI: скачать образ, создать контейнеры. Параллельно CNI-плагин настраивает сеть и выдаёт поду IP, CSI-драйвер подключает тома. Когда контейнеры запущены, kubelet начинает выполнять пробы и отправляет статус обратно в apiserver.

добавь Что компоненты не общаются между собой напрямую — только через apiserver, наблюдая за интересующими их объектами. Это объясняет важное свойство: если apiserver недоступен, уже запущенные поды продолжают работать (kubelet ничего не сносит), но ничего нового создать нельзя и статусы не обновляются.

Разница между liveness, readiness и startup пробами

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

Readiness отвечает на вопрос «можно ли слать сюда трафик». Если проба не прошла, под убирают из endpoints сервиса, и трафик к нему перестаёт идти — но сам контейнер не трогают. Это правильное поведение для временных состояний: приложение прогревает кеш, потеряло связь с зависимостью, перегружено. Оно живо, просто сейчас не готово обслуживать.

Liveness отвечает на вопрос «жив ли процесс вообще». Если проба не прошла, kubelet перезапускает контейнер. Это лекарство от зависаний: дедлок, утечка, застрявший поток. Мера радикальная, и применять её надо только тогда, когда перезапуск действительно поможет.

Startup нужна медленно стартующим приложениям. Пока она не прошла, две другие пробы не выполняются вовсе. Это позволяет дать приложению щедрый стартовый таймаут (скажем, пять минут на прогрев JVM), не ослабляя при этом постоянные проверки — без неё пришлось бы ставить огромный initialDelaySeconds у liveness, и тогда зависшее в рантайме приложение перезапускалось бы с пятиминутной задержкой.

добавь главную ошибку, за которую этот вопрос и любят: вешать liveness на эндпоинт, который ходит в базу. База моргнула на тридцать секунд — liveness провалилась одновременно у всех реплик — Kubernetes дружно их перезапустил — они все разом полезли устанавливать соединения к едва живой базе и добили её окончательно. Liveness должна проверять только сам процесс; зависимости — это исключительно дело readiness.

Requests и limits — в чём разница и что такое QoS

Requests — это то, что резервируется. Планировщик складывает requests всех подов на узле и по этой сумме решает, влезет ли туда ещё один. Реальное потребление его не интересует вообще: узел может быть загружен на 10%, но если сумма requests уже равна ёмкости, новый под туда не поедет.

Limits — это потолок в рантайме, который следит cgroup. И вот здесь принципиальная асимметрия, которую надо проговорить. Процессорное время делимо: при превышении квоты процесс просто троттлится — ему дают меньше времени, он работает медленнее, но живёт. Память неделима: её нельзя выдать помедленнее. Поэтому превышение лимита памяти означает немедленное убийство контейнера, статус OOMKilled и код возврата 137.

Из соотношения requests и limits вытекает класс QoS, который определяет, кого убьют первым при нехватке памяти на узле. Если requests равны limits для всех контейнеров — это Guaranteed, такие поды трогают в последнюю очередь. Если requests меньше limits — Burstable. Если не указано ничего — BestEffort, и эти уходят первыми.

Практический вывод, который стоит добавить: для критичных сервисов имеет смысл ставить requests равными limits, чтобы получить Guaranteed. А вот CPU-лимиты вообще часто советуют не ставить — троттлинг при коротких всплесках вредит задержкам сильнее, чем помогает предсказуемость, тогда как requests при этом всё равно обеспечивают гарантированную долю.

Под в Pending — почему

Pending всегда означает одно: под создан, но планировщик не назначил ему узел. И самое ценное здесь то, что планировщик честно пишет причину — надо просто прочитать kubectl describe pod и посмотреть события внизу вывода. Ответ «читаю events» уже сам по себе правильный.

Что там обычно бывает. Первое — Insufficient cpu или Insufficient memory: не хватает свободных ресурсов под requests. Важно, что речь именно про сумму requests, а не про фактическую загрузку — узел может выглядеть свободным. Второе — под не проходит по ограничениям размещения: не совпал nodeSelector, не выполняется affinity, на узлах стоят taints, а у пода нет соответствующих toleration. Третье — проблема с томом: PVC не может быть создан, потому что нет подходящего StorageClass или закончилось место; либо PV уже существует, но находится в другой зоне доступности, чем свободные узлы.

Отдельный случай, который стоит назвать, потому что он неочевиден: под в Pending, а узлов с ресурсами вроде бы достаточно, но не выполняется podAntiAffinity — например, требование «не больше одной реплики на узел» при трёх репликах и двух узлах.

CrashLoopBackOff — что это и как разбирать

Само название описывает механику: контейнер стартует, падает, kubelet перезапускает его, он падает снова — и kubelet начинает увеличивать паузу между попытками, чтобы не молотить бесконечно. Пауза растёт с нескольких секунд до пяти минут. Это не диагноз, а лишь констатация: контейнер не может продержаться на ногах.

Ключевой инструмент здесь — kubectl logs pod --previous. Обычный logs покажет текущий, только что стартовавший экземпляр, в котором ещё ничего не произошло; флаг --previous отдаёт лог предыдущего, упавшего запуска, где и лежит причина. Без него можно долго смотреть в пустоту.

Дальше помогает код возврата из kubectl describe pod. 137 означает SIGKILL и почти всегда — превышение лимита памяти: смотри, растёт ли потребление со временем (утечка) или приложению просто мало выделено. 1 или другой ненулевой код — приложение вышло само, причина в логе: чаще всего недоступная зависимость, отсутствующая переменная окружения или неверная конфигурация. 0 в цикле — самый обманчивый вариант: приложение отработало успешно и завершилось, то есть в контейнере запускается не то, что нужно, или оно уходит в фон.

Если лог пуст, а контейнер падает мгновенно, — проблема, скорее всего, ещё до запуска приложения: не найден исполняемый файл, нет прав, не смонтировался нужный файл. Это видно в событиях пода.

Разница между ConfigMap и Secret

Функционально они устроены почти одинаково: оба хранят пары ключ-значение, оба можно смонтировать в под файлами или прокинуть переменными окружения. Разница — в отношении к содержимому.

Первое, что надо сказать и что проверяют этим вопросом: Secret хранится в base64, и это кодирование, а не шифрование. Любой, кто может прочитать объект, читает и значение — команда для этого умещается в одну строку. Реальная защита появляется, только если включить шифрование данных в etcd и аккуратно раздать права через RBAC: тип Secret позволяет ограничить доступ к секретам отдельно от остальных объектов, тогда как ConfigMap обычно доступен всем, кто вообще имеет доступ к namespace.

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

добавь различие в обновлении, потому что оно регулярно ест время на отладку: если ConfigMap или Secret смонтированы файлом, изменения подхватываются автоматически (с задержкой на синхронизацию kubelet). Если проброшены переменными окружения — не подхватываются никогда, переменные фиксируются при старте процесса, и нужен перезапуск пода. Отсюда классическое «я поменял конфиг, а ничего не изменилось».

Как ограничить трафик между подами

Механизм называется NetworkPolicy, и в нём есть две особенности, из-за которых он ведёт себя не так, как ожидают.

Первая: модель по умолчанию — «разрешено всё». Пока в namespace нет ни одной политики, все поды свободно ходят друг к другу и наружу. Как только появляется хотя бы одна политика с типом Ingress, для попавших под её селектор подов входящий трафик становится запрещённым по умолчанию, и разрешено ровно то, что описано. Причём политики складываются: несколько правил объединяются по «или», запрещающих правил не существует в принципе. Отсюда стандартный подход — сначала применить политику «запретить всё» на namespace, а потом точечно открывать нужное.

Вторая: NetworkPolicy реализует CNI, а не сам Kubernetes. Если плагин их не поддерживает, объекты спокойно создадутся, будут висеть в кластере и не делать ровным счётом ничего — без единой ошибки. Это одна из самых неприятных ловушек, потому что выглядит как работающая изоляция. Проверять надо документацией плагина и реальным тестом: зайти в под и попробовать достучаться туда, куда не должно.

Стоит также помнить, что политики оперируют метками подов и namespace, а не IP-адресами: правило «пусть ходят только поды с меткой role=backend» переживает пересоздание подов, тогда как правило по адресам развалилось бы мгновенно.

Как выкатить новую версию без простоя

Rolling update работает по умолчанию, но сам по себе бесшовности не даёт — нужны три условия, и каждое закрывает свою дыру.

Корректная readiness-проба. Без неё под попадает в endpoints сразу после старта контейнера, ещё не будучи готовым обслуживать, и первые запросы получают ошибку. Проба должна проверять реальную готовность, а не факт, что процесс запустился.

preStop-хук с паузой. Это неочевидная, но обязательная часть. Когда под удаляют, параллельно происходят две вещи: kubelet шлёт приложению SIGTERM, и контроллер убирает под из endpoints, откуда новость должна разъехаться по всем узлам и попасть в правила ядра. Вторая цепочка асинхронная и занимает сотни миллисекунд, а иногда секунды. В этот промежуток трафик всё ещё летит в уже закрывающийся под — отсюда 502 при каждой выкатке. Пауза в preStop (kubelet выполняет его до SIGTERM) даёт правилам время обновиться.

Обработка SIGTERM в приложении. Оно должно перестать принимать новые запросы, доработать текущие и выйти. Иначе через grace period прилетит SIGKILL и всё оборвётся посреди работы.

Если нужно надёжнее, чем rolling update, — это уже канареечная или сине-зелёная выкатка: два деплоймента одновременно и переключение трафика между ними, либо через сервис, либо через ingress с разделением по весам, что позволяет пустить на новую версию сначала 5% и посмотреть на метрики. Подробный разбор механики — глава 3.1, §10.

§CI/CD

Как устроен нормальный пайплайн

Стадии почти всегда одни и те же: сборка и юнит-тесты, статический анализ и проверка зависимостей на уязвимости, сборка образа и публикация в реестр, деплой в тестовую среду, интеграционные тесты, деплой в прод. Но перечислить стадии — это ещё не ответ; ценность в принципах, которые за ними стоят.

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

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

добавь про теги: latest в проде — источник невоспроизводимых инцидентов, потому что непонятно, что именно там запущено, и откатиться некуда. Тегировать надо хешем коммита или версией, а в манифестах в идеале пинить дайджест — тогда образ гарантированно тот самый, даже если тег кто-то перезаписал.

Что такое GitOps и чем он лучше push-деплоя

В классической схеме пайплайн после сборки сам идёт в кластер и применяет манифесты — это push. GitOps меняет направление: желаемое состояние описано в git, а внутри кластера работает агент (Flux, Argo CD), который постоянно сравнивает реальность с описанием и приводит первое ко второму.

Из смены направления следует несколько практических вещей. Пайплайну больше не нужен доступ в кластер — а значит, не нужно хранить в CI кубконфиги с широкими правами, что само по себе закрывает большой класс рисков. Ручные изменения автоматически откатываются: агент видит расхождение и возвращает описанное состояние, поэтому «поправил руками и забыл» перестаёт быть возможным. Откат — это git revert, обычная операция с историей, а не отдельная процедура. И в любой момент видно расхождение между тем, что описано, и тем, что есть на самом деле.

Обратная сторона тоже есть, и её честное упоминание производит хорошее впечатление. Появляется задержка между коммитом и применением. Отладка усложняется: когда что-то не поехало, разбираться надо в логах агента, а не в логе пайплайна. И главное — секреты: положить их в git нельзя, поэтому нужен SOPS, Sealed Secrets или внешнее хранилище с оператором. Этот вопрос почти всегда задают следующим, так что лучше поднять его самому.

Как выкатывать миграции базы вместе с приложением

Ключ к ответу — понимание, что во время rolling update старая и новая версии приложения работают одновременно. Это не редкий случай, а норма: пока катятся десять реплик, минуту-другую в кластере живут обе версии, и обе ходят в одну и ту же базу.

Значит, схема должна быть совместима с обеими. Отсюда принцип: миграции делаются обратно совместимыми и разбиваются на два релиза. Сначала только добавление — новая колонка (обязательно nullable или со значением по умолчанию), новая таблица, новый индекс — и выкатка кода, который умеет работать и по-старому, и по-новому. Потом, отдельным релизом, когда старой версии в кластере уже нет, — удаление лишнего.

Так, переименование колонки, которое кажется тривиальной операцией, разворачивается в три шага: добавить новую, писать в обе и читать из новой с откатом на старую, потом удалить старую. Долго, зато без простоя.

Технически миграцию запускают Job или init-контейнером, но обязательно с защитой от параллельного выполнения — иначе десять реплик одновременно попытаются применить одну и ту же миграцию. Большинство инструментов миграции берут для этого блокировку в самой базе; если нет — нужен отдельный Job, который отрабатывает до выкатки.

добавь Про блокировки: ALTER TABLE на большой таблице в PostgreSQL может взять блокировку и остановить приложение целиком. Добавление индекса делают через CREATE INDEX CONCURRENTLY, а тяжёлые изменения — отдельно от выкатки, в окно.

Чем version отличается от appVersion в Helm-чарте

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

appVersion — версия приложения, которое чарт устанавливает. На разрешение зависимостей она не влияет вообще, это информационное поле, которое обычно совпадает с тегом образа и подставляется в шаблон как значение по умолчанию. Её берут в кавычки, чтобы 1.10 не превратилось в число 1.1.

Практическая проверка понимания: поправил опечатку в шаблоне, не трогая образ, — растёт только version. Обновил приложение — растут оба.

Где Helm хранит состояние и что происходит при upgrade

Состояние живёт в самом кластере: в объектах Secret типа helm.sh/release.v1, в том же namespace, что и релиз, по одному секрету на каждую ревизию. Внутри — сжатый слепок манифестов и значений. Никакого внешнего хранилища и никакого серверного компонента нет: в третьей версии убрали Tiller, и теперь всё выполняется с правами того, кто запустил команду.

При обновлении Helm делает трёхстороннее сравнение: манифесты предыдущей ревизии, новые отрендеренные манифесты и фактическое состояние объектов в кластере. Третий участник нужен, чтобы не затирать чужие изменения — например, число реплик, выставленное автоскейлером, если этого поля нет в чарте. Если поле есть в чарте и изменилось, побеждает чарт; если поле было в прошлой ревизии и исчезло из новой — Helm его уберёт.

добавь Что в пайплайне пишешь helm upgrade --install с флагом --atomic: без него команда завершается успехом сразу после отправки манифестов, даже если приложение потом не поднимется, и пайплайн зеленеет при сломанной выкатке. --atomic ждёт готовности и откатывает при неудаче, не оставляя релиз в подвешенном состоянии. Подробно — глава 3.2, §11.

Зачем нужны хуки в Helm

Хук — это обычный ресурс, чаще Job, с аннотацией вроде helm.sh/hook: pre-upgrade. Helm запускает его в указанный момент и дожидается завершения, прежде чем идти дальше. Основное применение — миграции базы: если Job упал, обновление не состоится, и приложение не выкатится на несовместимую схему.

Неочевидная деталь, которую и проверяют: ресурсы хуков не входят в состав релиза. Helm их создаёт, но не отслеживает, и при helm uninstall они не удаляются. Поэтому нужен hook-delete-policy — иначе Job-ы копятся, а следующая установка падает с ошибкой «такой Job уже существует». Рабочее сочетание — before-hook-creation,hook-succeeded: перед созданием убрать прошлый, после успеха убрать за собой, а упавший оставить, чтобы прочитать лог.

§Terraform и инфраструктура как код

Базовый рабочий цикл Terraform и его граница с Ansible разобраны отдельно в главе 5.1.

Зачем нужен state и почему его нельзя держать локально

Terraform работает с реальными облачными ресурсами, у которых есть свои идентификаторы. State — это как раз таблица соответствия: вот этот блок в коде соответствует вот этому конкретному объекту у провайдера. Без неё Terraform не может понять, что уже создано, и при следующем запуске просто создаст всё заново.

Отсюда и ответ на вторую часть. Локальный файл означает, что управлять инфраструктурой может только один человек с одной машины: у второго state пустой, и его apply попробует создать дубликаты всего. Потеря файла превращает управляемую инфраструктуру в набор объектов, о которых Terraform ничего не знает, — восстанавливать придётся импортом каждого ресурса вручную.

Поэтому — удалённый backend с блокировкой. Блокировка здесь не менее важна, чем само хранение: без неё два одновременных apply могут перезаписать состояние друг друга, и результат будет расходиться с реальностью непредсказуемым образом.

добавь Что state содержит чувствительные данные в открытом виде — сгенерированные пароли от баз, ключи доступа, содержимое секретов. Поэтому шифрование хранилища и ограничение доступа к нему обязательны, а сам файл нельзя коммитить в git ни при каких обстоятельствах.

Чем Terraform отличается от Ansible

Различие проходит по тому, что именно инструмент считает своей зоной ответственности.

Terraform управляет жизненным циклом ресурсов: он знает полный список того, что должно существовать, и приводит реальность к этому списку — включая удаление того, чего в описании больше нет. Убрал блок из кода — ресурс будет уничтожен. Именно поэтому он подходит для создания инфраструктуры: сети, виртуалки, балансировщики, кластеры.

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

На практике их часто сочетают именно по этой границе: Terraform создаёт машины и сети, Ansible настраивает содержимое. Хотя в контейнерном мире вторая часть чаще заменяется образами, и Ansible остаётся для того, что живёт вне контейнеров.

Что делать, если кто-то поменял ресурс руками

Первым делом это увидит terraform plan: он читает реальное состояние ресурсов у провайдера, сравнивает с описанием и покажет расхождение. По умолчанию следующий apply вернёт всё как в коде — и в этом главный риск ситуации: изменение, которое кто-то внёс осознанно и по делу, будет молча затёрто.

Правильная реакция зависит от того, чем было изменение. Если оно верное — его надо перенести в код, а не оставлять в реальности; тогда plan перестанет показывать расхождение, и знание сохранится в репозитории. Если ресурс вообще создан вне Terraform, его затягивают через import, чтобы он попал в state и стал управляемым.

Но системное решение — не техническое, и об этом стоит сказать. Пока у людей есть права менять ресурсы руками, они будут это делать, особенно в инцидент. Значит, права на изменение надо ограничить так, чтобы единственным путём оставался код, а для аварийных случаев предусмотреть отдельную процедуру с обязательным последующим переносом изменений в репозиторий.

Как безопасно применять изменения в прод

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

Второе — раздельные state на окружения. Один state на всё означает, что ошибка в описании тестового контура способна затронуть прод в том же apply. Разделение по каталогам или бэкендам делает такую ошибку невозможной физически.

Третье — привычка читать итоговую строку плана. «3 to add, 1 to change, 7 to destroy» на изменении, где ты ожидал добавить одну запись, — это сигнал остановиться, а не подтверждать. Большинство громких историй про снесённый Terraform-ом прод начинались с непрочитанной этой строки. Для критичных ресурсов — баз, дисков с данными — дополнительно ставят lifecycle { prevent_destroy = true }, чтобы такой план вообще не мог быть применён.

§Мониторинг и логи

Почему Prometheus выбрал pull, а не push

Prometheus сам ходит по целям и забирает метрики с HTTP-эндпоинта. Это решение не техническая мелочь, а архитектурный выбор с понятными последствиями.

Плюсы. Система всегда знает, кто должен быть жив: список целей приходит из обнаружения сервисов, и если цель не ответила — это само по себе метрика up = 0, то есть недоступность обнаруживается автоматически, без отдельного механизма. При push такого нет: молчащий сервис неотличим от несуществующего. Отладка проще — эндпоинт с метриками можно открыть браузером и посмотреть глазами. И приложение не может случайно залить систему мониторинга: частоту опроса задаёт Prometheus, а не оно.

Минусы тоже реальные. Нужна сетевая доступность от Prometheus до каждой цели, что неудобно при NAT и в закрытых сегментах. И принципиально плохо работают короткоживущие задачи: job, который отработал за десять секунд, Prometheus просто не успеет опросить. Для них существует Pushgateway — промежуточное хранилище, куда задача складывает метрики перед смертью. Использовать его надо осторожно: он не удаляет метрики сам, и через полгода там копится мусор от давно исчезнувших задач.

Разница между типами метрик

Counter только растёт и сбрасывается лишь при перезапуске процесса — число обработанных запросов, количество ошибок, объём переданных байт. Само его значение бессмысленно: «за всё время было 4 миллиарда запросов» ничего не говорит. Смысл появляется от производной — rate(), которая даёт «запросов в секунду» и корректно обрабатывает сброс счётчика при рестарте.

Gauge может расти и падать — занятая память, длина очереди, число активных соединений, температура. Здесь как раз осмысленно текущее значение, а также минимум, максимум и среднее за период.

Histogram раскладывает наблюдения по заранее заданным корзинам: сколько запросов уложилось в 10 мс, сколько в 50, сколько в 100. Квантили считаются уже при запросе, на стороне Prometheus, и главное преимущество — их можно агрегировать по нескольким инстансам: сложив корзины десяти подов, получишь честный общий p99.

Summary считает квантили прямо в приложении и отдаёт готовыми. Дешевле по хранению, но квантили с разных инстансов складывать нельзя — среднее из десяти p99 не является общим p99 и вообще не имеет смысла. Поэтому для распределённых сервисов почти всегда выбирают histogram.

На что вообще стоит алертить

Правильный принцип — алертить на симптомы, которые чувствует пользователь, а не на внутренние показатели системы. «Доля ответов с ошибкой выше 1% в течение пяти минут» — хороший алерт: он означает, что людям сейчас плохо. «Загрузка процессора выше 80%» — плохой: она может держаться неделями при полностью здоровом сервисе, а может быть 30% при лежащем.

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

И главный практический критерий: если по алерту нельзя ничего сделать прямо сейчас — это не алерт, а дашборд. Оповещение должно подразумевать действие. Всё остальное — информация, которую смотрят днём.

добавь про усталость от алертов, это признак зрелости. В команде, где ночью приходит тридцать оповещений, перестают реагировать на все тридцать, включая то единственное настоящее. Сокращение количества алертов — это полноценная работа, а не лень, и её надо делать регулярно.

Почему смотреть на среднее время ответа — плохо

Среднее прячет хвост распределения, а именно в хвосте живут проблемы. Пример: 95% запросов отвечают за 50 мс, оставшиеся 5% — за 10 секунд. Среднее получится около 550 мс — выглядит вполне терпимо, никаких поводов для тревоги. Но каждый двадцатый пользователь при этом ждёт десять секунд и, скорее всего, уходит.

Поэтому смотреть надо квантили: p50 показывает типичный опыт, p95 и p99 — то, что реально болит. Причём чем крупнее клиент, тем важнее хвост: если страница делает двадцать запросов к бэкенду, то при p99 в 10 секунд почти каждая страница поймает хотя бы один медленный запрос.

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

Зачем нужны и логи, и метрики, и трейсы

Они отвечают на три разных вопроса, и ни один не заменяет другой.

Метрики отвечают на «что-то сломалось и насколько сильно». Они дёшевы, потому что агрегированы: миллион запросов превращается в несколько чисел. Хранить их можно долго, строить по ним алерты — легко. Но узнать из метрики, что именно случилось с конкретным запросом, невозможно.

Логи отвечают на «что именно произошло в этом конкретном случае». Максимум подробностей, но дорого: объём растёт линейно с нагрузкой, поиск по большим объёмам требует серьёзной инфраструктуры. Алертить по логам можно, но обычно это признак того, что нужной метрики просто не сделали.

Трейсы отвечают на «где в цепочке из пятнадцати сервисов потерялось время». Запрос получает идентификатор, который передаётся по всем вызовам, и в результате видно полную картину: этот сервис ответил за 5 мс, тот ждал базу 800 мс, а вот здесь было три последовательных вызова вместо одного. Ни логи, ни метрики этого не покажут, потому что каждый сервис по отдельности выглядит нормально.

§Секреты и доступы

Как приложение в кластере получает секрет из Vault

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

У каждого пода есть токен сервисного аккаунта, который kubelet монтирует автоматически. Приложение (или sidecar-агент) предъявляет этот токен Vault. Vault не верит ему на слово, а обращается к apiserver кластера и спрашивает: это действительно валидный токен, и какому сервисному аккаунту в каком namespace он принадлежит? Получив подтверждение, Vault сопоставляет эти данные со своей политикой и выдаёт короткоживущий токен доступа с ограниченными правами.

Дальше секрет попадает в приложение одним из способов: sidecar-агент кладёт его файлом в общий том и обновляет при ротации; либо оператор синхронизирует значение во внешне выглядящий обычным Secret, и приложение вообще не знает про Vault. У обоих подходов свои компромиссы — первый честнее, второй проще внедрять.

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

Что делать, если секрет утёк в git

Первым действием — отозвать и заменить, а не чистить историю. Это самое важное, и порядок здесь принципиален. Секрет, попавший в репозиторий, надо считать скомпрометированным навсегда: он есть в клонах у всех, кто делал pull, в кешах CI, в форках, в резервных копиях, возможно — в индексах поисковиков, если репозиторий публичный. Переписывание истории ничего из этого не отменяет.

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

Третий шаг — понять, что именно утекло и куда оно давало доступ, и проверить логи на предмет использования. Четвёртый — профилактика: сканер секретов в pre-commit хуке и в пайплайне, чтобы следующий раз не случился.

§Базы (коротко)

Чем репликация отличается от бэкапа

Это самый частый вопрос-ловушка по базам на DevOps-собеседовании, и ответ на него короткий, но требует объяснения механизма.

Репликация передаёт на реплику все изменения, происходящие на мастере, и делает это максимально быстро — в этом её смысл. Поэтому от отказа сервера она защищает отлично: узел сгорел, реплика становится мастером, потеряны в худшем случае последние транзакции.

Но ровно то же свойство делает её бесполезной против логических ошибок. Кто-то выполнил DELETE FROM users без WHERE — это обычная транзакция, и она добросовестно доедет до всех реплик за миллисекунды. Данных не станет везде одновременно. То же с ошибочной миграцией, с багом в приложении, с шифровальщиком.

От этого спасает только резервная копия с возможностью восстановления на момент времени: полный снимок плюс архив журналов, позволяющий откатиться на состояние «за минуту до рокового запроса». В PostgreSQL это pg_basebackup плюс архивирование WAL, на практике — pgBackRest или WAL-G. И обязательно надо добавить: бэкап, который ни разу не восстанавливали, бэкапом не является — регулярная проверка восстановления входит в определение.

Что такое split-brain и как его не допустить

Ситуация возникает при автоматическом файловере. Реплика перестала видеть мастера — но из этого факта невозможно понять причину: мастер действительно умер или просто порвалась сеть между ними. Изнутри реплики эти два случая выглядят абсолютно одинаково.

Если реплика решит, что мастер мёртв, и повысится, а на самом деле он жив и продолжает принимать запись от части клиентов, — получатся две базы, независимо принимающие изменения. Через час у них будут разные данные, и свести их обратно можно только вручную, разбирая журналы транзакций. Это худший сценарий из возможных, хуже простоя.

Защита строится на двух вещах. Первая — кворум: решение принимает не сама реплика, а внешнее распределённое хранилище с нечётным числом узлов (etcd, Consul). Узел, оказавшийся при разрыве сети в меньшинстве, знает об этом и мастером стать не может. Вторая — изоляция старого мастера перед повышением нового: отобрать у него виртуальный адрес, отключить от сети, погасить. Именно это и делает Patroni, держа в etcd ключ лидера с коротким сроком жизни, который надо постоянно продлевать.

Зачем нужен пулер соединений

PostgreSQL устроен так, что на каждое клиентское соединение создаётся отдельный процесс операционной системы. Это архитектурное решение, у него есть свои плюсы по надёжности, но масштабируется оно плохо: тысяча соединений означает тысячу процессов, каждый со своей памятью, и планировщик ОС начинает захлёбываться на переключениях контекста. При этом реально работают в каждый момент единицы.

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

PgBouncer решает это, держа небольшой пул реальных соединений к базе и раздавая их клиентам. В режиме transaction pooling соединение выдаётся на время транзакции и возвращается в пул — поэтому сотни клиентов обслуживаются десятком реальных соединений.

Ограничение назвать обязательно: в этом режиме не работают вещи, живущие в пределах сессии, — подготовленные выражения на уровне сессии, SET, advisory-блокировки, временные таблицы. Приложение должно быть к этому готово. Побочная польза: при файловере переподключается пулер, а не каждое приложение.

§Про работу и инциденты

Расскажите про самый сложный инцидент

Вопрос кажется про масштаб аварии, но на самом деле он про то, как ты думаешь под давлением. Собеседующему не важно, сколько было девяток и сколько денег потеряли, — ему важно услышать структуру рассуждения.

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

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

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

Прод лежит, ты дежурный. Порядок действий

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

Порядок примерно такой. Сначала оценить масштаб: лежит всё или часть, когда началось, что говорят метрики. Потом — посмотреть на последние изменения, потому что подавляющее большинство аварий начинается с чьего-то действия: выкатки, миграции, правки конфига, изменения в инфраструктуре. Если время совпадает — откатить, не разбираясь. Разбор будет после, на живом сервисе.

Параллельно — сообщить: в общий канал, коротко, что известно и когда будет следующее обновление. Это не бюрократия, а экономия времени: иначе половину инцидента съедят ответы на вопросы «а что там».

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

Разработчики просят доступ в прод

Вопрос проверяет, умеешь ли ты не превращаться в вахтёра, но и не раздавать всё подряд.

Разумная позиция: чтение — широко. Логи, метрики, состояние объектов, описание подов. Без этого разработчик не может отвечать за свой сервис и будет вынужден дёргать тебя по любому поводу — что плохо и для него, и для тебя. Закрытый доступ на чтение обычно означает, что вся диагностика идёт через одного человека, и он же становится узким местом в каждом инциденте.

Запись — через процесс. Штатный путь — пайплайн. Для нештатных ситуаций — временный доступ с обоснованием и аудитом, который автоматически истекает. Это не про недоверие, а про воспроизводимость: изменение, сделанное руками, не существует в истории и будет затёрто следующей выкаткой.

И встречный ход, который стоит озвучить: если доступ регулярно просят ради одной и той же операции — перезапустить, посмотреть, накатить конфиг, — надо сделать эту операцию самообслуживаемой, а не выдавать доступ снова и снова. Повторяющаяся заявка это симптом отсутствующего инструмента.

Чего вы не знаете из нашего стека

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

Работающая формула: «руками с этим не работал, но представляю архитектуру и понимаю, чем оно отличается от того, с чем работал» — и дальше действительно показать понимание. Признанный пробел с внятным пониманием смежной области вызывает больше доверия, чем размытый ответ на всё подряд, потому что демонстрирует способность честно оценивать свои знания. А это на дежурстве важнее, чем знание конкретного инструмента.

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

§Как отвечать вообще

Несколько приёмов, которые работают независимо от темы, — и которые заметно влияют на впечатление сильнее, чем объём знаний.

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

«Зависит от» — сильный ответ, если его продолжить. Само по себе «ну, зависит» выглядит как уклонение. «Зависит от того, в каком режиме kube-proxy: в iptables выбор случайный, в IPVS честный round robin» — выглядит как опыт. Разница в том, называешь ли ты, от чего именно зависит.

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

Не знаешь — рассуждай вслух. «Точно не помню, но по логике должно быть так, потому что…» — совершенно нормальный ответ, за который никто не снижает оценку; наоборот, видно, как человек думает. Уверенный выдуманный ответ снижает, потому что дальше почти наверняка проверят.

Задавай уточняющие вопросы. На «как бы вы сделали X» правильная реакция — сначала спросить про нагрузку, требования к доступности, что уже есть в инфраструктуре, какой бюджет на сложность. Это ровно то, что делает инженер перед реальной задачей, и собеседующий это видит и ценит.