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

Как убрать gitRepo из старого Helm chart до Kubernetes 1.36

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~17 мин чтения
Как убрать gitRepo из старого Helm chart до Kubernetes 1.36
Иллюстрация к статье «Как убрать gitRepo из старого Helm chart до Kubernetes 1.36».

Старый Pod работает, Helm-релиз не менялся несколько лет, а после обновления узла приложение внезапно остаётся в ожидании тома. Я видел этот сценарий не раз. Причина проста: `gitRepo` давно устарел, с Kubernetes 1.33 выключен по умолчанию, а в 1.36 его уже нельзя вернуть feature gate. Я, Семёнов Евгений Сергеевич, в таких случаях сначала переношу клонирование в непривилегированный initContainer, проверяю точный commit и только потом разрешаю обновление кластера. Ниже — схема, которую можно внедрить без переделки самого приложения.

Что именно сломается в Kubernetes 1.36

gitRepo объявили устаревшим ещё в Kubernetes 1.11. В версиях 1.33–1.35 встроенный драйвер выключен по умолчанию, но администратор мог временно запустить kubelet с GitRepoVolumeDriver=true. В Kubernetes 1.36 gate зафиксирован в состоянии false: старое включение больше не помогает. Важно понимать тонкость реализации. Поле volumes[].gitRepo пока остаётся в API, поэтому API server способен принять Pod, а helm upgrade --dry-run=server не обязан показать проблему. Ошибка возникает позже на узле: kubelet не запускает Pod с таким томом.

Отсюда самый неприятный обман. Уже работающий Pod продолжает выглядеть здоровым, потому что том был подготовлен раньше. Но после drain узла, изменения Deployment, аварии VM или обычного пересоздания реплики новый Pod не поднимется. Я не принимаю зелёный kubectl get pods за доказательство готовности к обновлению. Мне нужно успешно создать Pod заново без gitRepo.

Причина удаления не косметическая. CVE-2024-10220 позволяла пользователю, способному создать Pod с gitRepo, использовать hooks в целевом репозитории для выполнения команд за границей контейнера. Уязвимости присвоили оценку High, CVSS 8.1. Исправленные патчи ограничили конкретный способ эксплуатации, но древний код всё равно выполнял Git-операции из kubelet. Я считаю правильным убрать эту ответственность с узла и перенести её в обычный ограниченный контейнер.

Главная ловушка: манифест с `gitRepo` может пройти API-проверку в Kubernetes 1.36, но Pod не будет запущен kubelet. Одного server-side dry-run недостаточно.
Памятка: Что именно сломается в Kubernetes 1.36 — схема
Памятка: Что именно сломается в Kubernetes 1.36. Открыть схему в полном размере

Сначала нахожу зависимость и фиксирую старую семантику

Начинаю с живых Pod и исходников chart. Первый запрос — вариант команды из KEP-5040 (я только вывожу результат через @tsv), он показывает активные экземпляры. Второй поиск нужен обязательно: CronJob с редким расписанием или Deployment с нулём реплик в списке Pod не появится.

kubectl get pods -A -o json \
  | jq -r '.items[] | select(.spec.volumes[]? | has("gitRepo")) | [.metadata.namespace,.metadata.name] | @tsv'

rg -n --glob '*.yaml' --glob '*.tpl' 'gitRepo:' charts/ environments/
helm get manifest importer -n shop | rg -n 'gitRepo:'

Дальше смотрю не только на URL и revision. Chart, который подрядчик «ЦветДома» написал под интернет-магазин, монтировал volume в /opt/import, а gitRepo.directory: rules создавал каталог /opt/import/rules. Если после миграции смонтировать сам репозиторий прямо в /opt/import, приложение получит другую структуру путей. Из раза в раз ломаются именно такие мелочи: вложенный каталог, права, наличие .git, симлинки и файл, который приложение ожидает увидеть до старта.

Я письменно фиксирую контракт: куда попадают файлы, обновляются ли они только при создании Pod, может ли приложение писать в каталог и что считается правильной версией данных. Обычный gitRepo не обеспечивал постоянную синхронизацию: он готовил содержимое при создании Pod. Значит, emptyDir плюс завершающийся initContainer сохраняет его полезную семантику. При рестарте только основного контейнера данные остаются, при пересоздании Pod загружаются заново.

Не удаляйте старый Helm-релиз до проверки пересоздания. Работающая реплика — ваш самый дешёвый временный откат.
Как убрать gitRepo из старого Helm chart до Kubernetes 1.36 — схема
Схема к статье. Открыть схему в полном размере

Почему я выбираю initContainer и emptyDir

Для файлов, которые должны быть неизменны в течение жизни Pod, я выбираю один initContainer и дисковый emptyDir. Это скучная архитектура, а в эксплуатации скука полезна. initContainer обязан завершиться успешно до запуска приложения; ошибка DNS, авторизации, TLS или несовпадение SHA останавливает Pod до того, как он начнёт обрабатывать неправильные данные. Основной контейнер получает тот же volume только для чтения.

git-sync в режиме sidecar я использую лишь тогда, когда приложение действительно должно видеть изменения без rollout. Он переключает рабочую копию атомарно через симлинк, но приложение должно читать правильный путь и уметь безопасно перезагружать данные. Добавлять бесконечную синхронизацию вместо старого одноразового gitRepo — лишняя новая семантика. Для критичных статических файлов ещё надёжнее собрать отдельный OCI-артефакт или включить их в образ, однако это уже изменение CI/CD, а не быстрый ремонт старого chart.

Отдельного железа для initContainer не нужно. Нужно место на локальном ephemeral storage узла: рабочая копия, временный pack Git, writable layer и логи. Я измеряю пиковое потребление и оставляю запас минимум в два-три раза. emptyDir.sizeLimit ограничивает сам том, а request ephemeral-storage участвует в планировании. При отказе узла emptyDir пропадает — для этой задачи это нормально, потому что Git остаётся источником данных.

Если Git-сервер недоступен, существующие Pod продолжат работать, но новые не стартуют. Для приложения, которое обязано восстанавливаться без Git, содержимое следует доставлять образом или OCI-артефактом.
Порядок действий: Почему я выбираю initContainer и emptyDir — схема
Порядок действий: Почему я выбираю initContainer и emptyDir. Открыть схему в полном размере

Рабочий фрагмент Helm chart

В values я храню URL, неизменяемый tag/ref и ожидаемый commit. Секрет создаёт отдельный защищённый процесс доставки; самого пароля в values нет. Имена registry и Git-сервера в примере нейтральные; у клиента tag в registry был immutable. В другом окружении я дополнительно фиксирую образ по digest.

image:
  repository: registry.example.com/apps/importer
  tag: "4.8.2"

gitContent:
  image: registry.example.com/base/alpine-git:2.51.0-r1
  repository: https://git.example.com/content/shop-catalog.git
  ref: refs/tags/rules-2026.08.4
  commit: 6a3d907ed04ecdb6f2d51796258c1665b60f18e1
  authSecret: git-content-ro
  sizeLimit: 256Mi

Сам шаблон сохраняет прежний путь /opt/import/rules. Askpass не подставляет пароль в URL или аргументы процесса, set -x намеренно не используется. После checkout я удаляю .git: приложению история не нужна, а токены и служебные настройки не должны путешествовать дальше initContainer.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "importer.fullname" . }}-git-askpass
data:
  git-askpass.sh: |
    #!/bin/sh
    case "$1" in
      *Username*) printf '%s\n' "$GIT_USERNAME" ;;
      *Password*) printf '%s\n' "$GIT_PASSWORD" ;;
      *) exit 1 ;;
    esac
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "importer.fullname" . }}
spec:
  replicas: 2
  strategy:
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      securityContext:
        fsGroup: 2000
        fsGroupChangePolicy: OnRootMismatch
        seccompProfile:
          type: RuntimeDefault
      initContainers:
        - name: fetch-rules
          image: {{ required "gitContent.image is required" .Values.gitContent.image | quote }}
          imagePullPolicy: IfNotPresent
          command: ["/bin/sh", "-ec"]
          args:
            - |
              set -eu
              umask 022
              git init /work/rules
              git -C /work/rules remote add origin "$GIT_REPOSITORY"
              git -C /work/rules fetch --depth=1 --no-tags origin "$GIT_REF"
              git -C /work/rules checkout --detach FETCH_HEAD
              test "$(git -C /work/rules rev-parse HEAD)" = "$GIT_COMMIT"
              rm -rf /work/rules/.git
              printf '%s\n' "$GIT_COMMIT" > /work/rules/.content-commit
              test -s /work/rules/catalog/rules.json
          env:
            - name: HOME
              value: /tmp
            - name: GIT_TERMINAL_PROMPT
              value: "0"
            - name: GIT_ASKPASS
              value: /git-auth/git-askpass.sh
            - name: GIT_REPOSITORY
              value: {{ required "gitContent.repository is required" .Values.gitContent.repository | quote }}
            - name: GIT_REF
              value: {{ required "gitContent.ref is required" .Values.gitContent.ref | quote }}
            - name: GIT_COMMIT
              value: {{ required "gitContent.commit is required" .Values.gitContent.commit | quote }}
            - name: GIT_USERNAME
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.gitContent.authSecret }}
                  key: username
            - name: GIT_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.gitContent.authSecret }}
                  key: password
          securityContext:
            runAsNonRoot: true
            runAsUser: 65532
            runAsGroup: 2000
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            requests:
              cpu: 25m
              memory: 32Mi
              ephemeral-storage: 64Mi
            limits:
              cpu: 300m
              memory: 128Mi
              ephemeral-storage: 384Mi
          volumeMounts:
            - name: content
              mountPath: /work
            - name: git-askpass
              mountPath: /git-auth
              readOnly: true
            - name: tmp
              mountPath: /tmp
      containers:
        - name: importer
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          securityContext:
            runAsNonRoot: true
            runAsUser: 10001
            runAsGroup: 2000
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: content
              mountPath: /opt/import
              readOnly: true
      volumes:
        - name: content
          emptyDir:
            sizeLimit: {{ .Values.gitContent.sizeLimit }}
        - name: git-askpass
          configMap:
            name: {{ include "importer.fullname" . }}-git-askpass
            defaultMode: 0555
        - name: tmp
          emptyDir:
            sizeLimit: 32Mi

У этой конфигурации есть сознательный компромисс: username и password становятся переменными окружения initContainer. Они не раскрываются в PodSpec благодаря secretKeyRef и не попадают в командную строку, но процесс внутри контейнера их видит. Поэтому токен только read-only, только для одного репозитория и с короткой ротацией. Для внутреннего удостоверяющего центра я добавляю CA в образ или отдельный read-only volume и задаю GIT_SSL_CAINFO; http.sslVerify=false не использую даже временно.

Обратите внимание на readOnlyRootFilesystem: true в обоих контейнерах. Git пишет конфиг и временные файлы в $HOME, поэтому я задаю HOME=/tmp и монтирую туда отдельный маленький emptyDir; без этого initContainer падает с ошибкой записи ещё до fetch. Основной контейнер получает runAsGroup: 2000, совпадающий с fsGroup, и читает файлы с правами umask 022. Если приложению подрядчика нужен свой writable-каталог для кэша, я добавляю ещё один emptyDir, а не снимаю запрет на запись в корневую ФС. Перед выкатом рендерю шаблон и проверяю, что Namespace проходит профиль Restricted Pod Security Standards:

helm template importer ./charts/importer -n shop \
  -f environments/prod/values.yaml > /tmp/rendered.yaml
kubectl label --dry-run=server --overwrite ns shop \
  pod-security.kubernetes.io/enforce=restricted
kubectl apply --dry-run=server -n shop -f /tmp/rendered.yaml

Команда kubectl label --dry-run=server выводит предупреждения для уже запущенных Pod, которые нарушили бы профиль, — удобная проверка без реального включения enforce.

Не печатайте окружение и не включайте `set -x` в initContainer. Ротация Secret не обновит уже загруженные файлы: для новой версии нужен rollout.

Что получилось у цветочного магазина «ЦветДом»

Приведу реальный по структуре, но обезличенный кейс. «ЦветДом» — цветочный магазин на 40 рабочих мест: салоны, флористы, курьерская служба и интернет-магазин. Сам интернет-магазин несколько лет назад развернул в Kubernetes сторонний подрядчик и с тех пор почти не трогал; нас позвали подготовить обновление кластера, у которого заканчивалась поддержка. Импорт цен и остатков от оптовых поставщиков цветов обслуживали две реплики сервиса importer, а правила наценок и карточки букетов лежали в Git. Кластер — три worker-VM по 4 vCPU, 8 ГБ RAM и 80 ГБ локального диска, исходная версия 1.32.8, Helm 3.18.6. План — последовательное обновление minor-версий до Kubernetes 1.36.4. В chart 0.9.6 был gitRepo.directory: rules, а kubelet после перехода на 1.33 подрядчик «починил» временным GitRepoVolumeDriver=true, не оставив об этом ни строчки в документации.

Рабочее дерево (правила, фото-превью букетов и JSON-каталог) содержало 412 файлов и занимало 14 МиБ, но накопленная .git разрослась до 540 МиБ. Старое полное клонирование занимало от 45 до 70 секунд. После перехода на shallow fetch конкретного tag, проверки полного SHA и удаления .git пиковое заполнение emptyDir составило 38 МиБ. В 30 последовательных пересозданиях Pod время initContainer было 4,2–7,1 секунды, p95 — 6,8 секунды; пик памяти — 41 МиБ. Поэтому лимитов 128 МиБ RAM и 256 МиБ на content volume оказалось достаточно с нормальным запасом.

Мы отдельно подставили неверный SHA и отозванный токен. В обоих случаях приложение не запустилось, старые две реплики остались Ready благодаря maxUnavailable: 0, а причина читалась в логе fetch-rules. После успешного rollout каждая реплика показала один и тот же .content-commit. Затем кластер прошёл последовательное обновление до 1.36.4 без зависимости от feature gate и без простоя импорта: в пиковую неделю перед праздником цены на сайте обновлялись по расписанию, а покупатели не заметили работ. Самый полезный результат здесь не семь секунд экономии, а предсказуемый отказ: неправильные файлы больше не доходят до приложения.

Цифры стенда нужны как ориентир, а не как универсальные requests и limits. Измерьте свой pack, checkout и потребление памяти: репозиторий с крупными бинарными файлами поведёт себя иначе.
Цифры и версии: Что получилось у цветочного магазина «ЦветДом» — схема
Цифры и версии: Что получилось у цветочного магазина «ЦветДом». Открыть схему в полном размере

Порядок rollout и защита от возврата gitRepo

Я выкатываю новый chart ещё на старой версии Kubernetes. Сначала lint и рендер, затем server-side dry-run, после него реальный rollout и пересоздание всех реплик. Dry-run проверяет шаблон и admission, но только запуск Pod проверяет kubelet, DNS, сертификат, Secret и права на emptyDir.

helm lint ./charts/importer -f environments/prod/values.yaml
helm template importer ./charts/importer -n shop \
  -f environments/prod/values.yaml | rg -n 'gitRepo:'
helm upgrade importer ./charts/importer -n shop \
  -f environments/prod/values.yaml --dry-run=server --hide-secret
helm upgrade importer ./charts/importer -n shop \
  -f environments/prod/values.yaml --wait --timeout 5m
kubectl rollout status deployment/importer -n shop --timeout=5m
kubectl exec -n shop deployment/importer -- \
  cat /opt/import/rules/.content-commit

Команда с rg должна вернуть пустой результат (и код выхода 1 — в CI я инвертирую условие). После миграции снова запускаю cluster-wide запрос по Pod и ставлю ValidatingAdmissionPolicy по образцу из KEP-5040. Учтите: политика из KEP и выражение из документации проверяют только объекты Pod. Deployment или CronJob с gitRepo в шаблоне API server примет, а отказ случится при создании Pod контроллером — поэтому для шаблонов я оставляю проверку в CI. Сама политика без binding ничего не блокирует:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: no-gitrepo-volumes
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "!has(object.spec.volumes) || !object.spec.volumes.exists(v, has(v.gitRepo))"
      message: "gitRepo volumes are not allowed."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: no-gitrepo-volumes
spec:
  policyName: no-gitrepo-volumes
  validationActions: ["Deny"]

Политику нельзя включать раньше миграции: иначе давно забытый CronJob или пересоздание старой реплики будет отклонено ещё до того, как вы успеете его исправить. Для мягкого старта можно начать с validationActions: ["Warn", "Audit"].

Откат тоже планирую заранее. До обновления кластера технически можно вернуть старый release, хотя я этим пользуюсь только как аварийной мерой. После перехода на 1.36 откат к chart с gitRepo уже не является рабочим вариантом. Базой отката должен быть предыдущий chart, в котором уже есть initContainer. Перед drain проверяю доступ Git и registry со всех node pool, наличие образа и свободный ephemeral storage. Всё остальное — косметика, её можно отложить.

Helm rollback на версию chart с `gitRepo` после обновления Kubernetes 1.36 не восстановит сервис. Храните отдельную проверенную версию нового chart как аварийную.

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

Можно ли просто снова включить GitRepoVolumeDriver?

В Kubernetes 1.33–1.35 это было временно возможно на kubelet, хотя небезопасно. В 1.36 gate зафиксирован в `false`, поэтому включение больше не работает.

Почему Helm dry-run не обнаруживает проблему?

KEP-5040 сохраняет поле `gitRepo` в Kubernetes API. API server принимает объект, а отказ происходит позже: kubelet не запускает Pod с таким томом и пишет ошибку в события Pod.

Когда вместо initContainer нужен git-sync?

Только если файлы должны обновляться без пересоздания Pod и приложение умеет безопасно перечитывать атомарно переключаемый каталог. Для снимка данных на момент старта initContainer проще.

Нужен ли PersistentVolume вместо emptyDir?

Обычно нет: Git уже является источником, а старый gitRepo также подготавливал данные для конкретного Pod. PVC добавит очистку, конкуренцию и риск получить устаревшую копию.

Что произойдёт при недоступности Git-сервера?

Работающие Pod сохранят файлы, но новые застрянут на initContainer. Если это неприемлемо, доставляйте содержимое через заранее загруженный образ или OCI-артефакт.

Можно ли клонировать ветку без проверки SHA?

Технически можно, но я так не делаю в production. Ветка изменяема: две реплики, созданные в разное время, могут получить разное содержимое под одной версией Helm-релиза.

В какой версии gitRepo окончательно исчезнет из кода?

По KEP-5040 драйвер выключен по умолчанию с 1.33, feature gate зафиксирован в `false` в 1.36, а удаление gate и кода драйвера запланировано на 1.39. Документация Kubernetes прямо называет 1.35 последней версией, где драйвер можно было включить.

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

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

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

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

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

Источники

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