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

Обновил ConfigMap, а в поде старый файл: почему subPath ломает hot reload и что с этим делать

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
Обновил ConfigMap, а в поде старый файл: почему subPath ломает hot reload и что с этим делать
Иллюстрация к статье «Обновил ConfigMap, а в поде старый файл: почему subPath ломает hot reload и что с этим делать».

Вы поправили ConfigMap, применили, GitOps отрапортовал Synced и Healthy. Прошло десять минут, полчаса, два часа — реплики продолжают ходить на старый адрес базы и работать со старыми лимитами. Файл в поде физически не изменился. Это не баг кластера и не подвисший kubelet: так работает subPath, и в Kubernetes 1.37 поведение ровно то же, что было пять лет назад. Ниже — почему так устроено, как за пять минут отличить эту проблему от «приложение не умеет перечитывать конфиг», и четыре схемы доставки настроек, которые я реально ставлю клиентам вместо магии с hot reload.

Симптом: конфиг новый везде, кроме того места, где он нужен

Сценарий узнаваемый до боли. Меняется адрес реплики PostgreSQL, правится один ключ в ConfigMap, коммит уезжает в Git, Argo CD подхватывает, ресурс в кластере обновлён — проверяется в одну команду и действительно обновлён. А приложение как ходило на старый хост, так и ходит. Разработчик идёт к вам с формулировкой «кластер не применил конфиг».

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

Проверяется гипотеза за пятнадцать секунд. Сравниваем то, что лежит в API, с тем, что видит контейнер:

kubectl get cm app-config -n prod -o jsonpath='{.data.config\.yaml}' | md5sum
kubectl exec -n prod deploy/api -c api -- md5sum /etc/app/config.yaml

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

И сразу второй индикатор, самый надёжный. Смотрим содержимое каталога, куда смонтирован ConfigMap:

kubectl exec -n prod deploy/api -c api -- ls -la /etc/app/

При нормальном монтировании ConfigMap как тома вы увидите служебный зоопарк: каталог вида ..2026_09_07_09_14_22.1274839561, симлинк ..data, указывающий на него, и симлинк config.yaml -> ..data/config.yaml. Если вместо этого лежит обычный одинокий файл — у вас subPath, и обновлений не будет.

Разошедшиеся md5 файла в контейнере и ключа в ConfigMap — это не «приложение не перечитало». Это значит, что kubelet и не пытался ничего перезаписать.

Механика: почему subPath не получает обновлений и не получит

Kubernetes проецирует ConfigMap в контейнер не построчной перезаписью файлов, а подменой симлинка. Внутри тома kubelet создаёт скрытый каталог с меткой времени, кладёт туда все ключи, затем атомарно переставляет симлинк ..data на новый каталог, а старый удаляет. Это описано прямо в комментарии к AtomicWriter в исходниках: видимые файлы тома — симлинки на файлы в каталоге данных, а сам каталог данных — симлинк, переключение которого и делает обновление атомарным. Ни один читатель никогда не увидит полуобновлённый набор файлов — либо весь старый, либо весь новый.

Теперь ключевой момент. subPath монтирует в контейнер не том, а конкретный путь внутри тома — по сути один-единственный файл, привязанный к inode на момент старта контейнера. Когда kubelet переставляет ..data, старый inode уходит вместе со старым каталогом, а bind-mount в контейнере продолжает указывать в никуда — точнее, на то, что было в момент запуска. Обновить его, не пересоздав контейнер, физически нечем.

Поэтому документация Kubernetes формулирует это без всяких «может быть»: контейнер, использующий ConfigMap как том, смонтированный через subPath, не получит обновлений при изменении этого ConfigMap (в оригинале: «A container using a ConfigMap as a subPath volume mount will not receive ConfigMap updates»). Аналогичные оговорки в документации есть для Secret и downward API. Ограничение сознательное и архитектурное, а не «пока не починили».

В Kubernetes 1.37 (релиз 26 августа 2026 года) поведение ровно то же. В changelog 1.37 действительно есть правки вокруг subPath — kubelet научился восстанавливаться из повреждённых точек монтирования subPath при рестарте контейнера вместо зависания в CreateContainerConfigError, и починен цикл ошибок при обрывах FUSE/GlusterFS, — но это про устойчивость монтирования, а не про доставку обновлений. Ждать, что «в следующей минорке починят», не надо: ключевой ишью kubernetes/kubernetes#22368 про раскатку ConfigMap («Facilitate ConfigMap rollouts / management») открыт со 2 марта 2016 года и на сентябрь 2026-го всё ещё не закрыт.

Если вы используете subPath только ради того, чтобы конфиг лежал рядом с файлами из образа, — это самая дорогая экономия в вашем кластере. Есть способы дешевле, они ниже.
Обновил ConfigMap, а в поде старый файл: почему subPath ломает hot reload и что с этим делать — схема
Схема к статье. Открыть схему в полном размере

Даже без subPath hot reload часто не срабатывает: ловушка inotify

Убрали subPath, смонтировали ConfigMap нормальным томом — и обнаружили, что hot reload всё равно молчит. Файл в контейнере обновляется (md5 сходится), а приложение продолжает работать со старыми значениями. Это уже вторая, независимая проблема, и путать её с первой очень легко.

Причина в том же симлинке. Библиотеки вроде fsnotify и всё, что на них построено, по умолчанию вешают watch на путь к файлу, а inotify следит за inode, а не за именем. При переключении ..data inode файла не модифицируется — он просто перестаёт быть доступен по прежнему имени. Событие IN_MODIFY не приходит никогда. Приходит IN_CREATE и IN_MOVED_TO на сам каталог монтирования — и вот их-то и надо слушать. В том же комментарии к AtomicWriter это сказано прямым текстом: потребителям каталога предлагается наблюдать за симлинком ..data через inotify или fanotify.

Практический вывод: чтобы hot reload по файлу работал, приложение должно следить за каталогом, а не за файлом, и перечитывать конфиг по событию на ..data. Если исходники не ваши и вы не можете это поменять — не героствуйте. Дешевле и надёжнее пойти путём перезапуска, о котором ниже.

И про задержку. Документация Kubernetes формулирует её точно: суммарная задержка от обновления ConfigMap до появления новых ключей в поде может достигать периода синхронизации kubelet плюс задержки распространения в его кеше. Дефолт syncFrequency1m. Тип кеша задаётся параметром configMapAndSecretChangeDetectionStrategy: Watch (по умолчанию, задержка равна задержке watch), Cache (TTL-кеш, к задержке добавляется TTL) и Get (kubelet каждый раз ходит в API-сервер, добавочной задержки нет, зато есть нагрузка). То есть в норме это секунды, но в регламентах закладывайтесь на минуту с лишним, а на стратегии Cache — ещё и на TTL.

Не крутите `--sync-frequency` вниз ради быстрого применения конфигов. Вы ускорите обновление на десятки секунд и заплатите нагрузкой на kubelet по всему парку нод. Задача решается на уровне схемы доставки, а не таймера.
Памятка: Даже без subPath hot reload часто не срабатывает: ловушка inotify — схема
Памятка: Даже без subPath hot reload часто не срабатывает: ловушка inotify. Открыть схему в полном размере

Переменные окружения: реплики с разными настройками в одном Deployment

Отдельная и, на мой взгляд, куда более опасная история — ключи ConfigMap, проброшенные через envFrom или valueFrom.configMapKeyRef. Они читаются ровно один раз, в момент создания контейнера, и не обновляются никогда. Тут даже нет механики симлинков — переменные окружения процесса в Linux после старта извне не переписываются. Документация про это говорит прямо: для применения изменений требуется замена подов.

Опасность в том, что это тихо. Файл в контейнере хотя бы можно сравнить с ConfigMap одной командой, а расхождение по env всплывает через недели. И самый неприятный сценарий такой: ConfigMap поменяли, поды не трогали, а потом HPA доскейлил Deployment вверх. Новые реплики стартуют уже с новыми переменными, старые продолжают жить со старыми. В одном Deployment, под одним Service, за одним ingress у вас одновременно два разных набора настроек. Запросы балансируются между ними случайно.

Дальше начинается диагностический ад: «ошибка воспроизводится примерно в трети случаев», «на стенде всё нормально», «повторный запрос проходит». Ищут гонки в коде, сетевые таймауты, кривой keep-alive. А это просто два поколения конфига в одной реплика-сете.

Проверяется в одну команду — по всем подам сразу:

for p in $(kubectl get po -n prod -l app=api -o name); do
  echo -n "$p "
  kubectl exec -n prod $p -c api -- printenv DB_HOST 2>/dev/null
done
kubectl get cm app-config -n prod -o jsonpath='{.data.DB_HOST}{"\n"}'

Если в выводе больше одного значения — поздравляю, вы нашли причину плавающих ошибок.

Если ключ прокинут через env — считайте, что он часть образа. Меняете значение — обязаны раскатать rollout. Иначе рано или поздно получите две версии конфига в одном Service.

Разбор из практики: магазин сумок «СумочныйРяд», 43 рабочих места

Клиент — магазин сумок «СумочныйРяд»: розничная точка, склад и интернет-магазин, 43 рабочих места. Мы сопровождаем у них офис, 1С и рабочие места, а сам интернет-магазин много лет назад заказали у веб-подрядчика, и он крутится у того в управляемом Kubernetes 1.37.0: три рабочие ноды, Argo CD, полтора десятка Deployment. Своего DevOps у магазина нет и не будет — при таком штате это нормально. Основной сервис — бэкенд каталога и корзины на Go, 6 реплик, HPA от 4 до 12. Конфиг — YAML-файл, приложение умеет перечитывать его по fsnotify, подрядчик этим гордится и прописал в README.

Директор пришёл к нам с формулировкой «после переезда базы часть заказов не оформляется, покупатели звонят, подрядчик говорит, что у них всё зелёное». За два дня до этого подрядчик переключил магазин на новую реплику PostgreSQL: поправили DB_HOST в ConfigMap app-config, Argo синхронизировался, дашборд зелёный. Ошибки начались не сразу, а часа через четыре — ровно когда вечерний пик заказов докинул реплик через HPA. С согласия директора подрядчик выдал нам доступ на чтение к namespace магазина, дальше работали вместе с их разработчиком.

Разбор занял двадцать минут. Первое: md5sum файла в поде и ключа в ConfigMap не совпали. Второе: ls -la /etc/app/ показал одинокий config.yaml без ..data — классический subPath. В манифесте было ровно то, чего я и ждал:

        volumeMounts:
        - name: cfg
          mountPath: /etc/app/config.yaml
          subPath: config.yaml

Причина, по которой так сделали, тоже типовая: в /etc/app/ уже лежал logging.yaml из образа, и монтирование тома целиком его затирало. subPath был выбран как «аккуратное» решение.

Третье и самое интересное: часть настроек дублировалась через envFrom. Прогон printenv DB_HOST по всем подам дал два разных значения — 6 старых реплик со старым хостом и 3 новые, поднятые HPA, с новым. Отсюда и «случайность»: старую реплику БД уже перевели в read-only, и падали ровно те запросы на оформление заказа, что попадали на старые поды и пытались писать. Каталог при этом открывался нормально — чтение-то работало. Доля неудачных оформлений гуляла вместе с работой автоскейлера — то около 40 %, то около 15 %.

Что сделал разработчик подрядчика по нашим рекомендациям. Убрали subPath: перенесли конфиг в отдельный каталог /etc/app/conf.d/, приложению передали путь флагом, logging.yaml из образа остался нетронутым. Перевели генерацию ConfigMap на configMapGenerator в Kustomize — он добавляет к имени хеш содержимого, поэтому любое изменение конфига меняет имя ресурса, а значит и спеку пода, и Argo сам делает честный rolling update. Из envFrom выкинули всё, что меняется в эксплуатации, оставив там только по-настоящему статичные вещи. Плюс поправили watcher в приложении, чтобы он слушал каталог, а не файл — иначе после ухода от subPath hot reload всё равно бы не завёлся.

Итог: изменение конфига стало доезжать до всех реплик за один rolling update — около 45 секунд на 6 реплик при maxUnavailable: 1, без 5xx благодаря нормальным readiness-пробам. Потерянные заказы ушли в тот же день. Неприятная деталь: почти двое суток магазин жил с расщеплённым конфигом, и узнали об этом по звонкам покупателей, потому что мониторинг подрядчика смотрел на агрегированный error rate, а не на разбивку по подам. Для небольшой компании вывод простой: если сайт у подрядчика в Kubernetes, в договоре должен быть доступ хотя бы на чтение и понятный регламент изменения конфигурации.

Если у вас есть HPA и есть ключи в `envFrom` — заведите себе привычку прогонять `printenv` по всем подам Deployment после каждого изменения ConfigMap. Одна строка в скрипте, а ловит она класс проблем, который иначе ищут неделями.
Цифры и версии: Разбор из практики: магазин сумок «СумочныйРяд», 43 рабочих места — схема
Цифры и версии: Разбор из практики: магазин сумок «СумочныйРяд», 43 рабочих места. Открыть схему в полном размере

Четыре рабочие схемы: что я ставлю вместо надежды на hot reload

Скажу сразу свою позицию, она не всем нравится. Я не считаю hot reload конфигов целью. Цель — чтобы изменение доезжало до ВСЕХ реплик детерминированно и наблюдаемо. Честный rolling restart это обеспечивает, а hot reload — только если приложение написано под него аккуратно и вы это проверяли. Поэтому по умолчанию я иду через перезапуск и включаю hot reload только там, где рестарт реально дорог: например, у приложения долгий прогрев кеша или тяжёлые долгоживущие соединения.

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

      volumes:
      - name: cfg
        configMap:
          name: app-config
          items:
          - key: config.yaml
            path: config.yaml
      containers:
      - name: api
        args: ["--config=/etc/app/conf.d/config.yaml"]
        volumeMounts:
        - name: cfg
          mountPath: /etc/app/conf.d
          readOnly: true

Схема вторая, для Kustomize и Argo CD — configMapGenerator. Он по умолчанию дописывает к имени ConfigMap хеш содержимого, ссылки в манифестах подменяются автоматически, и любое изменение данных превращается в изменение спеки пода. Дальше Kubernetes сам делает то, что умеет лучше всего, — rolling update. Это мой любимый вариант: никаких внешних контроллеров, никаких аннотаций руками.

# kustomization.yaml
configMapGenerator:
- name: app-config
  files:
  - config.yaml
generatorOptions:
  disableNameSuffixHash: false

Схема третья, для Helm — аннотация с контрольной суммой на шаблоне пода. Официальный приём из документации Helm, раздел Automatically Roll Deployments:

kind: Deployment
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

Меняется конфиг — меняется аннотация — меняется спека пода — идёт rollout. Работает и для Secret, и для случая с envFrom, что особенно ценно.

Схема четвёртая, когда манифесты трогать нельзя: контроллер Reloader от Stakater. Вешаете на Deployment аннотацию reloader.stakater.com/auto: "true", контроллер следит за связанными ConfigMap и Secret и сам дёргает rollout restart. Проект живой: на момент написания последние релизы — v1.4.22 и chart-v2.2.17 от 9 сентября 2026 года. Минус честный: это ещё один контроллер с правами на изменение ваших рабочих нагрузок в кластере, и его надо мониторить наравне с остальным. Если у вас нормальный GitOps — обходитесь схемами два и три.

И пятое, не схема, а гигиена: immutable: true на ConfigMap. Поле появилось в 1.19, стабильным стало в 1.21. У неизменяемого ConfigMap нельзя ни поменять data/binaryData, ни снять флаг обратно — только удалить и создать заново; документация отдельно рекомендует после этого пересоздать поды, потому что они держат точку монтирования удалённого объекта. На практике это означает дисциплину «новый конфиг — новое имя», что автоматически превращает изменение конфига в изменение спеки пода. Бонус — меньше нагрузки на kube-apiserver: watch'и для неизменяемых объектов закрываются. Отлично сочетается с генераторами имён по хешу.

Не бойтесь `kubectl rollout restart`. При адекватных readiness-пробах, `maxUnavailable: 1` и настроенном PodDisruptionBudget это скучная штатная операция. Риск здесь сильно преувеличен, а вот риск жить с расщеплённым конфигом — сильно недооценён.

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

Порядок действий, когда прилетело «конфиг не применился». Сначала подтверждаем, что ConfigMap в API действительно новый, — бывает, что Argo зелёный, а изменение вообще не в той ветке. Дальше сравниваем содержимое в контейнере, потом смотрим на ..data, потом на env. Четыре шага, никаких гаданий.

Полезно сразу прогнать аудит по всему кластеру и найти все места, где ConfigMap или Secret смонтированы через subPath. Один раз потратите десять минут, зато получите список мин, разложенных до вас:

kubectl get pods -A -o json | jq -r '
  .items[] as $p
  | (($p.spec.volumes // []) | map(select(.configMap or .secret) | .name)) as $cfg
  | $p.spec.containers[]
  | .volumeMounts[]?
  | select(.subPath or .subPathExpr)
  | select(.name as $n | $cfg | index($n))
  | "\($p.metadata.namespace)\t\($p.metadata.name)\t\(.mountPath)"
' | sort -u

Второй аудит — по переменным окружения. Ищем нагрузки, которые тянут ключи через envFrom или configMapKeyRef, и помечаем их как требующие обязательного rollout при изменении конфига:

kubectl get deploy -A -o json | jq -r '
  .items[]
  | select([.spec.template.spec.containers[]
      | (.envFrom // [])[] , ((.env // [])[] | .valueFrom // {})
      | select(.configMapRef or .configMapKeyRef)] | length > 0)
  | "\(.metadata.namespace)/\(.metadata.name)"'

Про приоритеты. В первую очередь чините связку envFrom + HPA — это то, что даёт разное поведение реплик и плавающие ошибки на проде. Во вторую — subPath на конфигах, которые реально меняются в эксплуатации. А вот subPath на файлах, которые не менялись с момента внедрения и меняться не будут (какой-нибудь timezone или статичный robots.txt), можно спокойно оставить в покое до ближайшего планового рефакторинга. Не всё, что технически неидеально, стоит трогать сегодня.

Мониторинг должен уметь показывать метрики в разбивке по подам, а не только агрегат по сервису. Расщеплённый конфиг видно именно на разбивке — на общем графике error rate он выглядит как «немного шумит».
Порядок действий: Чек-лист: пять минут на диагностику и аудит всего кластера — схема
Порядок действий: Чек-лист: пять минут на диагностику и аудит всего кластера. Открыть схему в полном размере

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

Можно ли как-то заставить subPath получать обновления ConfigMap?

Нет. Это архитектурное ограничение: subPath привязывает bind-mount к inode на момент старта контейнера, а kubelet обновляет том переключением симлинка ..data. Никакие флаги kubelet, аннотации или настройки тома это не меняют. Единственный способ обновить файл под subPath — пересоздать контейнер. Если файл меняется в эксплуатации, от subPath надо уходить.

Через сколько времени обновляется ConfigMap, смонтированный обычным томом?

Документация Kubernetes: суммарная задержка может достигать периода синхронизации kubelet плюс задержки распространения в его кеше. Дефолт syncFrequency — 1 минута, стратегия кеша по умолчанию — Watch, поэтому на практике это чаще секунды; на стратегии Cache добавляется TTL. В регламентах закладывайтесь на минуту с запасом, а факт применения проверяйте сравнением md5, а не по часам.

Почему приложение с fsnotify не видит изменения, хотя файл в поде обновился?

Потому что inotify следит за inode, а не за именем файла. Kubernetes не переписывает файл, он переставляет симлинк ..data на новый каталог с данными. События IN_MODIFY на пути к конфигу не будет никогда. Watcher должен слушать каталог монтирования и реагировать на IN_CREATE и IN_MOVED_TO. Это прямо рекомендовано в комментарии к AtomicWriter в исходниках Kubernetes.

Что делать с ключами, проброшенными через envFrom?

Считать их частью образа. Переменные окружения читаются один раз при создании контейнера и не обновляются никогда. После любого изменения ConfigMap нужен rollout: либо через хеш в имени (configMapGenerator в Kustomize), либо через аннотацию checksum/config в Helm, либо через Reloader. Особенно опасно сочетание envFrom и HPA — новые реплики поедут с новыми значениями, старые останутся со старыми.

Как быстро понять, что реплики работают с разными настройками?

Прогнать printenv по всем подам Deployment одной строкой в цикле и сравнить вывод с текущим значением в ConfigMap. Если значений больше одного — конфиг расщеплён. Симптом на проде обычно выглядит как ошибка, воспроизводящаяся «через раз» и не ловящаяся на стенде. На агрегированном графике error rate это почти не видно, нужна разбивка по подам.

Стоит ли ставить Reloader или лучше обойтись без него?

Если у вас Kustomize или Helm и вы контролируете манифесты — обойдитесь без него: хеш в имени ConfigMap или аннотация checksum/config решают задачу без лишнего контроллера. Reloader оправдан там, где манифесты править нельзя или их слишком много и разных. Помните, что это контроллер с правами на изменение рабочих нагрузок в кластере — его надо мониторить и обновлять как любой другой компонент периметра.

Интернет-магазин у подрядчика в Kubernetes — что нам, небольшой компании, стоит проверить?

Три вещи. Первое: есть ли у вас доступ хотя бы на чтение к namespace и к репозиторию манифестов, иначе любой разбор превращается в пересказ со слов подрядчика. Второе: как у них доставляются изменения конфигурации — хеш в имени ConfigMap, checksum-аннотация или Reloader, а не «поправили ConfigMap руками». Третье: смотрит ли мониторинг ошибки в разбивке по подам. Если на все три вопроса ответ «нет», проблема расщеплённого конфига — вопрос времени.

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

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

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

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

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

Источники

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