Приватный реестр протух, и кластер не поднял ни один под
В четверг утром автообновление нод забрало новую версию kubelet, ноды по очереди ушли в drain и вернулись — и с этого момента ни один под в кластере больше не поднялся. Не один сервис, не один namespace — вообще ни один под, который пытался стартовать заново. Разбираем, как самая скучная деталь инфраструктуры — срок действия учётки в приватном реестре — положила прод на два с половиной часа и почему её никто не заметил заранее.
Содержание
Что сломалось
Кластер — k3s на трёх нодах, приложения деплоятся через GitLab CI, образы лежат в приватном Harbor на отдельной VPS. Ночью прошло плановое обновление нод: cloud-init накатил патчи безопасности, ноды по очереди ушли в SchedulingDisabled, поды переехали на соседей, всё как обычно. Проблема появилась не сразу, а когда после ребута нода попыталась заново подтянуть образы для подов, которые на неё вернулись.
Утром дежурный увидел в мониторинге разом: часть подов в CrashLoopBackOff сменилась на ImagePullBackOff, деплой очередного релиза фронтенда завис на 0/3 Ready, а автоскейлинг не смог поднять дополнительные реплики под утренний трафик. kubectl get pods -A показывал десятки строк с одинаковым статусом — не для одного приложения, а вообще для любого пода, который в этот момент требовал новый pull образа.
kubectl get pods -n prod
NAME READY STATUS RESTARTS AGE
frontend-7d9f8c6b5-x2k9p 0/1 ImagePullBackOff 0 14m
api-gateway-6f4d8b9c7-vv2lq 0/1 ImagePullBackOff 0 12m
worker-queue-5c7f9d4b6-p8m3s 0/1 ErrImagePull 0 9m
Ключевая деталь, которая сразу отсекла версию «сломался конкретный сервис»: под с частым рестартом падал не из-за бага в коде, а потому что не мог даже стартовать контейнер — образ не скачивался.
Что было в логах и метриках
kubectl describe pod frontend-7d9f8c6b5-x2k9p дал первую зацепку:
Events:
Warning Failed kubelet Failed to pull image "registry.internal:5000/frontend:2026.08.27":
rpc error: code = Unknown desc = failed to authorize:
failed to fetch anonymous token: unexpected status: 401 Unauthorized
401, не 404 и не timeout. Это сразу переводило разговор из области «сеть» или «реестр упал» в область «реестр отвечает, но не пускает». Проверили доступность самого Harbor:
curl -sk https://registry.internal:5000/v2/
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}
Ответ пришёл мгновенно, TLS отработал, порт открыт — сам сервис жив. Заглянули в логи containerd на ноде:
journalctl -u k3s -n 100 | grep -i registry
level=error msg="PullImage failed" error="failed to authorize: 401 Unauthorized" image="registry.internal:5000/frontend:2026.08.27"
Дальше посмотрели логи самого Harbor (core-компонента) на сервере реестра:
docker logs harbor-core --tail 200 | grep -i "401\|robot\|unauth"
"error":"unauthorized: authentication required","request_uri":"/v2/frontend/manifests/2026.08.27","robot_name":"robot$ci-deploy"
Вот и первое имя — robot$ci-deploy. Это robot-аккаунт, который CI и все ноды кластера используют для pull через один и тот же imagePullSecret. Метрики Harbor (график harbor_core_http_request_total с лейблом code=401) показали, что 401 начали сыпаться скачком с 03:14 — задолго до утренних деплоев, ровно в момент ночного окна обновлений. То есть проблема не была вызвана самим обновлением нод напрямую — она уже существовала, просто никто не пытался тянуть новые образы до утра.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Первые полчаса разбора ушли на версии, которые казались логичнее «у токена вышел срок»:
- Сетевая проблема после обновления нод. Отбросили сразу —
curlдо registry с ноды отрабатывал, TLS handshake проходил, DNS резолвилregistry.internalв правильный IP. Если бы дело было в сети, ответ был бы timeout или connection refused, а не 401 от самого сервиса. - Реестр не выдержал нагрузки и деградировал. Проверили
docker statsна сервере Harbor — CPU и память в норме, диск под volume PostgreSQL и registry-storage не забит. Health-эндпоинт/api/v2.0/healthотвечал"healthy"по всем компонентам. - В образ закралась опечатка в теге, и его правда не существует. Проверили вручную через Harbor UI — тег
2026.08.27присутствовал в проекте, digest на месте, ничего не удалялось. - Кто-то поменял NetworkPolicy и обрубил egress к реестру. В k3s NetworkPolicy на namespace
prodне менялись минимум неделю (проверили по git-истории конфигов), да и 401 — это ответ уровня приложения, а не блокировка на уровне сети. - Проблема только у новых нод после обновления, у старых всё в порядке. Проверили руками на нодах, которые не перезагружались — под, который на них уже крутился, продолжал работать (образ уже был в локальном containerd-кеше), но ручной
crictl pullтого же образа с этой же ноды тоже упал с тем же 401. Значит, дело не в конкретной ноде и не в обновлении как таковом — оно просто заставило все ноды обратиться к реестру заново.
Каждая из этих версий отваливалась за 5–10 минут проверки, но именно последний пункт сузил круг: раз проблема воспроизводится с любой ноды и упирается именно в авторизацию, дальше нужно было смотреть не на инфраструктуру вокруг реестра, а на сам механизм аутентификации.
Ручная проверка добралась до правды
Секрет, из которого k3s берёт креды для pull, лежит в кластере как docker-registry secret:
kubectl get secret harbor-pull-secret -n prod -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d
{"auths":{"registry.internal:5000":{"username":"robot$ci-deploy","password":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...","auth":"..."}}}
Дальше — самая простая и самая показательная проверка инцидента: воспроизвести ровно то же самое, что делает kubelet, но руками.
docker login registry.internal:5000 -u 'robot$ci-deploy' -p 'eyJhbGci...'
Error response from daemon: Get "https://registry.internal:5000/v2/": unauthorized: authentication required
Тот же 401, что и в логах containerd. Значит, дело не в способе, которым k3s читает секрет, и не в формате .dockerconfigjson — сам пароль (JWT-токен robot-аккаунта) реестр не принимает. Раскодировали JWT из пароля через jwt.io (offline, без отправки токена куда-либо) и посмотрели поле exp:
"exp": 1756252800 → 2026-08-26 12:00:00 UTC
Инцидент случился 27 августа рано утром. Токен умер накануне днём. С этого момента любая попытка новой авторизации — pull на ноде, которая раньше не кешировала образ, или ручной pull после рестарта containerd — стабильно получала 401. Причина нашлась не через мониторинг и не через алерт, а через прямое сравнение даты истечения токена с датой инцидента.
Настоящая причина: просроченный robot-аккаунт Harbor
Robot-аккаунт robot$ci-deploy создавали полгода назад для CI и для imagePullSecret кластера — один аккаунт на всё, потому что «так проще настроить один раз». В форме создания robot-аккаунта в Harbor есть поле expiration — по умолчанию не «никогда», а конкретное число дней (в интерфейсе Harbor это Robot Account Expiration, задаётся при создании и может быть переопределено администратором проекта). Тогда поставили 180 дней, не подумав, что через полгода это аукнется, и не завели никакого напоминания.
Harbor не шлёт предупреждение о скором истечении robot-токена — ни на почту, ни в UI при следующем логине администратора. Единственный способ узнать заранее — самому смотреть список robot-аккаунтов в разделе Project → Robot Accounts и колонку Expiration, либо дёргать API /api/v2.0/projects/{project}/robots и парсить поле expires_at. Никто этого не делал, потому что аккаунт создавался один раз и после этого о нём просто забыли — типичная ситуация, когда «настроили и работает» со временем превращается в «настроили и никто не помнит, что там внутри».
Ключевой урок инцидента — не техническая тонкость JWT, а то, что один и тот же секрет обслуживал сразу всё: и CI-пайплайн, и все три ноды кластера через один imagePullSecret, скопированный во все namespace через kustomize. Когда токен умер, сломалось не «одно приложение», а сама возможность пода где-либо в кластере получить новый образ — вне зависимости от того, какой это namespace и какой сервис.
Почему рухнул весь кластер, а не одно приложение
Это стоит разобрать отдельно, потому что именно эта деталь дольше всего сбивала с толку на старте разбора. Уже запущенные контейнеры продолжали работать: containerd на ноде хранит слои образа локально и не обращается к реестру повторно, пока не нужно тянуть новый образ. Поэтому первые минуты после истечения токена всё выглядело идеально — сервисы отвечали, метрики зелёные.
Пробоина проявилась только там, где кластеру пришлось делать *новый* pull:
- под пересоздался на другой ноде после
SchedulingDisabledво время planned maintenance; - сработал rolling update — новый ReplicaSet требует новый под с тем же (или новым) образом;
- HPA попытался поднять дополнительную реплику под нагрузку;
- нода перезагрузилась и containerd потерял локальный кеш слоёв.
Ночное обновление нод оказалось не причиной, а спусковым крючком, который проявил уже существующую поломку. Если бы в эту ночь не было планового рестарта, тот же самый инцидент случился бы чуть позже — при первом же деплое, при первом автоскейлинге под нагрузку или просто при перезапуске пода после OOM. Токен истёк в 12:00 26 августа, а заметили последствия только в 07:40 27 августа — потому что до этого момента кластеру просто не требовалось ничего пересобирать.
Что изменили после инцидента
Правку сделали в трёх слоях — сам инцидент закрыли за 15 минут, остальное время ушло на то, чтобы он не повторился:
- Немедленный фикс. Создали новый robot-аккаунт с явным expiration в 3 года вместо расплывчатых 180 дней по умолчанию, обновили secret во всех namespace одной командой и перезапустили зависшие ReplicaSet:
kubectl create secret docker-registry harbor-pull-secret \
--docker-server=registry.internal:5000 \
--docker-username='robot$ci-deploy-2026' \
--docker-password="$NEW_ROBOT_TOKEN" \
-n prod --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment -n prod
- Мониторинг истечения токена. Завели отдельный cron-скрипт, который раз в сутки дёргает Harbor API, парсит
expires_atвсех robot-аккаунтов и шлёт предупреждение в чат за 30 и за 7 дней до истечения:
curl -s -u "$HARBOR_ADMIN" \
"https://registry.internal:5000/api/v2.0/projects/prod/robots" \
| jq -r '.[] | "\(.name) expires_at=\(.expires_at)"'
- Разделение ответственности секретов. Ушли от единого robot-аккаунта на всё сразу: отдельный аккаунт с правами только read-only для
imagePullSecretкластера, отдельный — с правами push для CI. Так истечение одного токена не парализует сразу и деплой, и работу кластера одновременно. - Синтетическая проверка pull. Добавили в общий healthcheck кластера отдельный шаг: раз в час поднимать тестовый под с минимальным образом из приватного реестра в служебном namespace и алертить, если pull не удался — это ловит проблему за часы до того, как она проявится на реальном деплое.
Отдельно пересмотрели процесс создания секретов вообще: подробнее об этом — в статье про управление паролями и секретами в Docker. Если у вас свой приватный registry на VPS (не обязательно Harbor), стоит свериться с типовыми граблями в статье про частые ошибки приватного Docker registry, а если именно Harbor — есть отдельный разбор частых ошибок Harbor на сервере. Если под не поднимается по другой причине, а не из-за реестра, стоит начать с общего чек-листа в статье почему под k3s не запускается.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстро проверить, не в токене ли дело, если под не может стартовать?
Смотрите kubectl describe pod — если в Events есть Failed to pull image с кодом 401 Unauthorized (а не timeout или 404), первым делом проверяйте именно авторизацию: раскодируйте .dockerconfigjson из secret и попробуйте docker login с теми же кредами руками.
Можно ли поставить robot-аккаунту в Harbor бессрочный срок действия, чтобы не думать об этом вообще?
Технически да, но это плохая практика с точки зрения безопасности — бессрочный токен, который утечёт, невозможно инвалидировать по времени. Разумнее — долгий срок (1–3 года) плюс мониторинг истечения, чем «навсегда» без контроля.
Почему уже запущенные поды не пострадали, если токен истёк раньше?
Потому что kubelet и containerd используют локально закешированные слои образа и не обращаются к реестру заново, пока не потребуется новый pull — при пересоздании пода, рестарте ноды или обновлении образа.
Нужно ли по отдельному robot-аккаунту на каждый namespace?
Не обязательно на каждый namespace, но точно стоит разделять по ролям: отдельно read-only для pull кластером, отдельно read-write для push из CI. Это ограничивает ущерб, если один из токенов скомпрометирован или истёк.
Как узнать срок действия существующего robot-аккаунта без доступа к UI Harbor?
Через API: GET /api/v2.0/projects/{project_name}/robots с базовой аутентификацией администратора — в ответе для каждого аккаунта есть поле expires_at в unix-времени.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →