MAATRIX / Блог / Приватный реестр протух, и кластер не поднял ни один под

Приватный реестр протух, и кластер не поднял ни один под

MAATRIX

В четверг утром автообновление нод забрало новую версию 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 минут, остальное время ушло на то, чтобы он не повторился:

  1. Немедленный фикс. Создали новый 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
  1. Мониторинг истечения токена. Завели отдельный 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)"'
  1. Разделение ответственности секретов. Ушли от единого robot-аккаунта на всё сразу: отдельный аккаунт с правами только read-only для imagePullSecret кластера, отдельный — с правами push для CI. Так истечение одного токена не парализует сразу и деплой, и работу кластера одновременно.
  2. Синтетическая проверка 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →