АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Перезаписал тег со статикой, а image volume показывает старое: как устроен его жизненный цикл в Kubernetes 1.36

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
Перезаписал тег со статикой, а image volume показывает старое: как устроен его жизненный цикл в Kubernetes 1.36
Иллюстрация к статье «Перезаписал тег со статикой, а image volume показывает старое: как устроен его жизненный цикл в Kubernetes 1.36».

CI собрал новый бандл фронта, запушил его в реестр под тем же тегом, вы сделали rollout restart — и часть пользователей всё равно получает прошлую версию. Белый экран, 404 на main.<hash>.js, «у меня всё работает» у половины команды. Разбираю на своём стенде, как на самом деле устроен жизненный цикл image volume в Kubernetes 1.36, где именно тег вас обманывает, почему запись в том не спасёт и что я ставлю в pod template, чтобы это не повторялось.

Том разрешается один раз — на старте пода

Начну с главного, потому что почти вся путаница растёт отсюда. Image volume — это не сетевой каталог, не PVC и не bind-mount живой директории с узла. Это OCI-объект (образ или артефакт), который kubelet вместе с рантаймом разворачивает на хосте и подкладывает в контейнер как каталог. В документации формулировка прямая: том разрешается при запуске Pod, монтируется только для чтения и «gets re-resolved if the Pod gets deleted and recreated» — переразрешается, когда под удалён и создан заново.

Отсюда следствие первое, безобидное: пока под жив, содержимое тома не изменится. Никогда. Вы можете сколько угодно раз перезаписать тег в реестре — под, который уже стартовал, продолжит отдавать ровно те слои, что он смонтировал при старте. Даже если контейнер внутри пода упал по liveness-пробе и был перезапущен рантаймом: под-то не пересоздавался, sandbox тот же, том тот же. Это ровно то поведение, которого люди не ждут, когда мысленно приравнивают image volume к «каталогу».

Следствие второе, коварное: пересоздание пода — условие необходимое, но не достаточное. Пересоздали под — kubelet заново разрешает ссылку. А вот что значит «разрешить», решает pullPolicy. Если политика говорит «бери из локального кэша, если он есть», то узел, на котором нужный тег уже лежит, никуда не пойдёт и смонтирует старые слои. Тег в реестре давно указывает на другой дайджест — узлу всё равно, он про это не спрашивал.

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

Четвёртый пункт — источник самого противного класса багов: узлы кластера расходятся по содержимому локального кэша образов, и при pullPolicy: IfNotPresent один и тот же тег на разных узлах разворачивается в разные дайджесты.

Разбор со стенда: шесть реплик, четыре из них с прошлым релизом

Возьму реальный случай, компанию назову условно — туристический магазин «РюкзакЛавка»: 30 рабочих мест в офисе и двух точках продаж, плюс интернет-магазин снаряжения. Сам магазин крутится не у них, а у подрядчика-интегратора в небольшом кластере: три рабочих узла, Kubernetes 1.36, containerd 2.1, приватный Harbor внутри периметра подрядчика. Нас позвали как вторую пару глаз, когда после очередного релиза посыпались жалобы покупателей. Фронт подрядчик вынес из основного образа именно так, как это принято рекомендовать: nginx-контейнер отдельно, статика отдельно, статика приезжает как image volume. Тем же способом в соседний Deployment раздаются справочники (размерные сетки, каталог брендов) и небольшая модель рекомендаций «с этим товаром берут». Идея хорошая — базовый nginx перестал пересобираться на каждый релиз фронта, слой со статикой весит 40 МБ вместо полутора гигабайт вместе с nginx-обвязкой.

Deployment web, шесть реплик (перед сезоном походов подрядчик поднял их с трёх), контейнер nginx:1.29-alpine, том — образ registry.example.com/shop/static:prod, смонтированный в /usr/share/nginx/html. pullPolicy в манифесте не указан вообще. Релизный пайплайн простой: собрать бандл, запушить в тот же тег prod, дальше kubectl rollout restart deployment/web. Работало полгода. Точнее — работало ровно до того момента, когда менеджер магазина сама не попала на белый экран при оформлении заказа.

Симптом: после релиза примерно 60 % запросов к /assets/main.<hash>.js уходили в 404, у части пользователей белый экран. Не у всех и не всегда — балансировка размазывала. Смотрим поды: четыре из шести отдают index.html прошлой сборки, со ссылками на бандл, которого в новом релизе уже нет. Все четыре — на двух конкретных узлах. На третьем узле обе реплики свежие.

Разгадка ровно та, что описана выше. Тег prod — не latest, значит, дефолтом включился IfNotPresent. На двух узлах образ shop/static:prod уже лежал в кэше с прошлого раза — kubelet его и взял, в реестр не ходил. На третьем узле образ незадолго до этого выкинул image GC (диск подпёр верхний порог), поэтому там пул произошёл честно и приехал свежий дайджест. Никакой мистики: сколько узлов, столько и версий статики. Причём rollout restart отработал штатно, все шесть подов действительно пересоздались — просто пересоздание само по себе ничего не гарантирует.

Отдельно отмечу, чем это НЕ было. Не кэшем nginx, не CDN, не Service, не «залипшими» keep-alive-соединениями. Прежде чем копать сеть, снимите контрольную сумму файла внутри подов — это дешевле всего и сразу отсекает половину гипотез.
Перезаписал тег со статикой, а image volume показывает старое: как устроен его жизненный цикл в Kubernetes 1.36 — схема
Схема к статье. Открыть схему в полном размере

pullPolicy: три значения и дефолт, который вас подставит

Семантика pullPolicy у image volume ровно та же, что у обычного контейнерного образа, и это отдельная ловушка: люди «знают» её по контейнерам, но не переносят знание на тома. Формулировки из документации 1.36 стоит прочитать буквально. Always — kubelet всегда пытается стянуть ссылку, и если пул не удался, под уходит в Failed. Never — kubelet никогда не тянет и использует только локальный образ или артефакт; под уходит в Failed, если хоть каких-то слоёв нет локально или если манифест этого образа не закэширован. IfNotPresent — kubelet тянет, только если ссылки ещё нет на диске; под уходит в Failed, если ссылки нет и пул не удался.

Дефолт: если pullPolicy не задан, для тега :latest подставляется Always, во всех остальных случаях — IfNotPresent. То есть «нормальные» релизные теги вроде prod, stable, v2 или staging по умолчанию получают самую кэширующую политику. Именно на этом и погорел подрядчик «РюкзакЛавки». Обратите внимание: правило про :latest — про суффикс тега, а не про «свежесть»; тег prod ничем не привилегирован.

Соблазн очевиден: поставить pullPolicy: Always и забыть. Я так делаю только как временную заплатку на время разбора инцидента, и вот почему это не решение. Во-первых, реестр становится жёсткой зависимостью старта пода: реестр недоступен — под в Failed, и это касается не только выкатки, но и любого перепланирования в три часа ночи. Во-вторых, вы платите обращением к реестру за каждый старт каждого пода: у «РюкзакЛавки» при шести репликах и Always выкатка растянулась с 10 до примерно 30 секунд. В-третьих, Always не даёт воспроизводимости: два пода, стартовавшие с разницей в минуту вокруг момента пуша, всё равно получат разные дайджесты. Гонку вы сузили, но не убрали.

Про Never скажу коротко: он честно полезен ровно в одном сценарии — изолированный контур, где образы раскладываются по узлам заранее (пре-пул через DaemonSet или образ в кэше ноды). Во всех остальных случаях это способ получить Failed на пустом месте после того, как image GC подчистил диск.

Чего точно не надо делать — переезжать на тег :latest ради автоматического Always. Вы получите ту же непредсказуемость плюс потеряете возможность внятного отката: непонятно, на что откатываться, если каждый релиз перезаписывает одну и ту же ссылку.
Памятка: pullPolicy: три значения и дефолт, который вас подставит — схема
Памятка: pullPolicy: три значения и дефолт, который вас подставит. Открыть схему в полном размере

Как я это чиню: дайджест в pod template

Правильный ответ на «какая версия статики смонтирована» должен быть виден в самом манифесте, а не зависеть от того, что успел закэшировать конкретный узел. Поэтому в проде я ссылаюсь на образ по дайджесту, а не по тегу. Поле reference ведёт себя ровно как pod.spec.containers[*].image, значит форму registry/repo@sha256:... оно принимает без всяких оговорок.

Дальше приятная механика: reference — часть pod template, значит его изменение меняет хэш шаблона, Deployment создаёт новую ReplicaSet и честно выкатывает новые поды. Никаких rollout restart и никаких «а вдруг не пересоздалось». А pullPolicy при дайджесте можно смело оставить IfNotPresent: дайджест уникален, кэш узла либо содержит ровно эти слои, либо не содержит вовсе — подмены не бывает по построению. У «РюкзакЛавки» после перехода на дайджест выкатка вернулась к 12 секундам и расхождения между узлами исчезли полностью.

Сам фрагмент pod template после правки — ссылка по дайджесту и явная политика:

volumes:
- name: static
  image:
    reference: registry.example.com/shop/static@sha256:<дайджест из реестра>
    pullPolicy: IfNotPresent

У справочников и модели рекомендаций сделали то же самое: каждый их релиз теперь виден в истории Deployment как отдельная ревизия, и откат на прошлый каталог размеров — это kubectl rollout undo, а не поиск нужного слоя в Harbor.

Единственная неприятность — kubectl set image умеет только контейнеры и про тома ничего не знает. Подстановку дайджеста в пайплайне я делаю yq или kustomize; дайджест беру у реестра через crane digest (можно skopeo inspect). Тег при этом никуда не девается — он остаётся человекочитаемым указателем «что сейчас в проде», просто в кластер едет не он. Шаг пайплайна у подрядчика после нашей правки выглядит так:

DIGEST=$(crane digest registry.example.com/shop/static:prod)
yq -i '(.spec.template.spec.volumes[] | select(.name == "static") | .image.reference) = "registry.example.com/shop/static@'"$DIGEST"'"' deploy/web.yaml
kubectl apply -f deploy/web.yaml
kubectl rollout status deployment/web

И один кластерный ремень безопасности, про который часто забывают: admission-плагин AlwaysPullImages работает и для image volume — он принудительно выставляет Always всем подам. Это не замена дайджестам, а страховка от чужих манифестов с IfNotPresent, и заодно защита от того, что под чужого namespace смонтирует приватный образ, уже лежащий на узле, без предъявления pull-секретов. Если у вас мультитенантный кластер — включайте.

Практический вывод: тег — это подпись человека, дайджест — это идентификатор содержимого. В кластер должен ехать идентификатор.

Read-only: «сейчас просто поправлю файл в томе» не сработает

Второй по частоте сценарий после «отдаёт старое» — попытка починить расхождение руками: зайти в под и перезаписать index.html или подложить недостающий бандл. Не выйдет. OCI-объект монтируется в единственный каталог (mountPath) и только для чтения. Любая запись вернёт EROFS, «Read-only file system». Это не настройка, которую можно выключить: readOnly здесь свойство самого типа тома, а не флаг монтирования, который вы забыли снять.

Заодно закрою пару вопросов по мелким свойствам, вокруг которых ходят устаревшие статьи. Требование монтировать файлы как noexec было в альфе 1.31 и ещё действовало в бете 1.33 (об этом прямо пишет блог релиза), но в июне 2025 года, когда KEP-4639 перенацелили на бету в 1.34, его из KEP убрали — в актуальных версиях noexec больше не является обязательным свойством тома, и если исполняемые файлы вам там не нужны, запрет запуска стоит обеспечивать политиками допуска и securityContext. Поле spec.securityContext.fsGroupChangePolicy на этот тип тома не влияет никак, так что менять владельца файлов через fsGroup бессмысленно: подгоняйте UID/GID прямо при сборке образа. subPath и subPathExpr поддерживаются, но только начиная с 1.33 — если у вас в парке остались более старые кластеры, манифест туда не переедет.

Если писать всё-таки нужно — а нужно это чаще, чем кажется: кто-то генерирует sitemap, кто-то подставляет runtime-конфиг в index.html — берите нормальный паттерн. Image volume монтируется в init-контейнер, init-контейнер копирует содержимое в emptyDir, рабочий контейнер монтирует emptyDir и пишет туда. Стоит это одного лишнего копирования на старте пода и полной ясности с правами.

И про доступ к приватному реестру: pull-секреты для тома собираются точно так же, как для контейнерного образа — из учётных данных узла, imagePullSecrets сервис-аккаунта и imagePullSecrets в спеке пода. Отдельного места для секретов у image volume нет, и это хорошая новость: ничего нового настраивать не надо.

Не пытайтесь «дожать» релиз правкой файлов в подах. Даже если бы том был записываемым, вы получили бы девять экземпляров, которые расходятся по содержимому, и полностью потеряли бы воспроизводимость. Лечится это только на уровне манифеста.
Памятка: Read-only: «сейчас просто поправлю файл в томе» не сработает — схема
Памятка: Read-only: «сейчас просто поправлю файл в томе» не сработает. Открыть схему в полном размере

Диагностика: за пять минут понять, что реально смонтировано

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

kubectl get pod -l app=web -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName
for p in $(kubectl get pod -l app=web -o name); do
  echo "$p $(kubectl exec "$p" -c nginx -- sha256sum /usr/share/nginx/html/index.html)"
done

Более цивилизованный способ появился в 1.35 в виде feature gate ImageVolumeWithDigest: при его включении kubelet записывает дайджест смонтированного образа прямо в статус пода — в containerStatuses[*].volumeMounts[*].imageRef. Штука ровно для нашей задачи, но честно предупрежу: гейт по состоянию на 1.36 остаётся альфой и по умолчанию выключен. На управляемом кластере вы его, скорее всего, включить не сможете, на своём — включайте на свой страх и риск и не стройте на нём мониторинг. Если гейт включён, дайджест читается одной командой:

kubectl get pod -l app=web -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[*].volumeMounts[*].imageRef}{"\n"}{end}'

Если есть доступ на узел, картину даёт crictl: посмотреть, какой дайджест соответствует тегу в локальном кэше конкретной ноды, и сравнить с тем, что отдаёт реестр:

crictl images --digests | grep shop/static
crane digest registry.example.com/shop/static:prod

Именно так подтверждается гипотеза «на двух нодах старый слой». И, наконец, у kubelet есть три счётчика по этой фиче: kubelet_image_volume_requested_total, kubelet_image_volume_mounted_succeed_total и kubelet_image_volume_mounted_errors_total. Их я советую завести в дашборд сразу: рост errors_total — это ваши поды, которые не стартуют из-за недоступного реестра или опечатки в reference, и увидеть это лучше на графике, чем в тикете от поддержки.

Про сами ошибки: сбой разрешения или пула образа на старте блокирует запуск контейнеров, повторяется по обычному volume backoff и отражается в reason и message пода. То есть под будет висеть не в CrashLoopBackOff, а именно в состоянии, где контейнеры ещё не стартовали — смотрите kubectl describe pod, а не логи приложения, логов там пока просто нет.

Ошибки монтирования image volume не видны в логах приложения: контейнер до логов не доходит. Смотрите события пода через kubectl describe и счётчик _mounted_errors_total на узле.

Когда image volume вообще стоит брать, а когда не стоит

Фича в 1.36 доведена до stable, гейт ImageVolume включён по умолчанию и специально ничего включать не надо — путь был долгий: альфа в 1.31, бета с выключенным по умолчанию гейтом с 1.33–1.34, бета по умолчанию включённая в 1.35, stable в 1.36. Единственное реальное требование — рантайм на узлах должен уметь монтировать OCI-объекты. CRI-O умеет с 1.31, containerd — с 2.1.0 (в release notes: «Add OCI/Image Volume Source support»), так что узлы на containerd 1.7 или 2.0 не подойдут; если у вас зоопарк узлов с разными рантаймами, проверьте это до того, как переписывать манифесты, иначе поды на «неумеющих» узлах просто не поднимутся.

Где image volume действительно хорош. Первое — ровно наш кейс: отделить статику фронта от веб-сервера, чтобы не пересобирать полуторагигабайтный образ ради изменения одного JS-файла. Второе — большие бинарные наборы данных: веса моделей, базы сигнатур антивируса, справочники — как каталог размеров и модель рекомендаций у «РюкзакЛавки», которые не хочется и часто нельзя запекать в образ приложения. Третье — конфиги и наборы файлов, которые надо шарить между несколькими контейнерами одного пода, не раздувая основной образ.

Где не стоит. Если файл маленький и текстовый — это ConfigMap или Secret, они проще, версионируются вместе с остальными манифестами и, в отличие от image volume, умеют обновляться в живом поде без пересоздания (с задержкой на синхронизацию kubelet). Помните только про потолок: объект в etcd ограничен примерно мегабайтом, и утрамбовывать туда бандл фронта — плохая идея, как раз ради таких случаев image volume и появился. Если данные должны меняться в рантайме и переживать перезапуск — вам нужен PVC, а не read-only-каталог. А если статики немного и релизный цикл у неё общий с приложением — не усложняйте, запекайте в образ приложения и живите спокойно.

Короткий чек-лист, который я прогоняю перед выкаткой любого манифеста с image volume:

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

Частые вопросы

Нужно ли в Kubernetes 1.36 включать feature gate ImageVolume?

Нет. С 1.36 фича в статусе stable, гейт включён по умолчанию и на kube-apiserver, и на kubelet. Единственное, что стоит проверить, — поддержку монтирования OCI-объектов контейнерным рантаймом на всех узлах: CRI-O умеет это с версии 1.31, containerd — с 2.1.0. Если в кластере есть узлы со старым рантаймом, поды с image volume на них не поднимутся.

Я сделал kubectl rollout restart, поды пересоздались — почему статика всё равно старая?

Потому что пересоздание пода только запускает повторное разрешение тома, а решение «идти в реестр или взять из кэша» принимает pullPolicy. Если тег не :latest и политика не указана явно, действует IfNotPresent: узел, на котором образ с этим тегом уже лежит, в реестр не пойдёт и смонтирует старые слои. Ссылайтесь на образ по дайджесту — тогда rollout restart вообще не понадобится, смена reference сама создаст новую ReplicaSet.

Можно ли записать файл в image volume или смонтировать его на запись?

Нельзя. OCI-объект монтируется в один каталог строго read-only, любая запись вернёт EROFS «Read-only file system», и отключить это нельзя — так устроен сам тип тома. Если запись нужна, смонтируйте image volume в init-контейнер, скопируйте содержимое в emptyDir и работайте с ним из основного контейнера.

Как узнать, какой именно дайджест смонтирован в конкретном поде?

Универсальный способ без включения чего-либо — снять контрольную сумму файла внутри пода через kubectl exec и сравнить между репликами; расхождение сразу укажет на кэш узлов. Более аккуратный вариант — feature gate ImageVolumeWithDigest, который с 1.35 пишет дайджест в .status.containerStatuses[*].volumeMounts[*].imageRef, но он по-прежнему в альфе и по умолчанию выключен. На самом узле дайджест видно через crictl images.

Что произойдёт, если реестр недоступен в момент старта пода?

При pullPolicy: Always kubelet попытается стянуть ссылку, и при неудаче под уйдёт в Failed. При IfNotPresent под стартует, если слои уже есть на узле, и упадёт, если их нет и пул не удался. Ошибка при разрешении образа блокирует запуск контейнеров, повторяется по обычному volume backoff и отражается в reason и message пода — смотрите kubectl describe pod, логов приложения там ещё не будет. Именно поэтому в проде я предпочитаю дайджест плюс IfNotPresent: реестр перестаёт быть зависимостью каждого старта.

Image volume заменяет ConfigMap?

Нет, у них разные ниши. ConfigMap проще, версионируется вместе с манифестами и умеет обновляться в живом поде без его пересоздания, но объект в etcd ограничен примерно мегабайтом. Image volume, наоборот, рассчитан на десятки и сотни мегабайт — бандлы фронта, веса моделей, справочники, — но обновляется только через пересоздание пода. Маленький текстовый конфиг — ConfigMap, большой неизменяемый набор файлов — image volume, изменяемые данные — PVC.

Подходит ли image volume для раздачи справочников и небольших ML-моделей, если магазин живёт у подрядчика?

Да, это одна из заявленных целей фичи: веса модели или выгрузка справочника упаковываются в отдельный OCI-образ и монтируются в под только для чтения, не раздувая образ приложения. Условия те же, что и для статики: ссылка по дайджесту, явный pullPolicy и рантайм с поддержкой на всех узлах. Если кластер ведёт подрядчик, попросите у него в договоре или регламенте фиксацию дайджестов в манифестах и доступ на чтение к истории Deployment — тогда вы сами видите, какая версия каталога сейчас в проде.

Столкнулись с похожей задачей? Обращайтесь — решим

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

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи