Перейти к содержанию

Перенос Helm-чартов и контейнерных образов в закрытый контур

В данной инструкции описан порядок переноса программных артефактов Kubernetes из публичных репозиториев, доступных через сеть Интернет, в изолированный частный контур, не имеющий доступа к Интернету.

Рассматриваются следующие типы артефактов:

  • Helm Charts и их зависимости;
  • Kubernetes container images, в том числе multi-architecture;
  • OCI Helm Charts;
  • CRD и дополнительные Kubernetes-манифесты;
  • файлы конфигурации и metadata, необходимые для воспроизводимой установки;
  • контрольные суммы и списки артефактов;
  • перенос через промежуточный носитель, например USB Flash Drive.

Единица поставки — Application Release, а не Chart

Перенос одного Helm Chart без образов, зависимостей, CRD и манифестов практически бесполезен. Единица поставки в закрытый контур — весь набор артефактов приложения:

Application Release
├── Helm Chart
├── Helm dependencies
├── CRDs
├── Kubernetes manifests
├── Container images (amd64, arm64, ...)
├── Image digests
├── Configuration
└── Integrity metadata

Общая схема процесса:

                 INTERNET
             ┌───────▼────────┐
             │  Внешняя машина │
             │  с доступом     │
             │  к Интернету    │
             └───────┬────────┘
                     │  скачать / собрать все артефакты
             ┌───────▼────────┐
             │ Transfer Package│
             │ Charts / Images │
             │ Dependencies /  │
             │ Checksums /     │
             │ Manifest        │
             └───────┬────────┘
                     │  USB / иной контролируемый носитель
             ┌───────▼────────┐
             │  Закрытый контур│
             │ Private Registry│
             │ Helm Repository │
             └───────┬────────┘
                Kubernetes

Когда это пригодится

  • Организация поставки сторонних Helm Charts и образов в закрытый контур без доступа к Интернету.
  • Подготовка воспроизводимого набора артефактов для установки приложений в изолированном Kubernetes-кластере.
  • Регулярное обновление ПО в контуре, изолированном на физическом или сетевом уровне.

Краткий путь

  1. Подготовьте внешнюю машину с доступом в Интернет (Helm, Skopeo, Docker/Podman, jq, yq).
  2. Скачайте Chart нужной версии и все его зависимости (с Chart.lock).
  3. Отрендерьте Chart, соберите полный список образов и зафиксируйте их digest.
  4. Перенесите Chart и образы в закрытый контур: напрямую (Skopeo) или через USB-носитель.
  5. Импортируйте образы и Charts в private OCI registry (skopeo copy, helm push).
  6. Подготовьте values-airgap.yaml и проверьте отсутствие внешних references в отрендеренных манифестах.
  7. Установите Chart и проверьте события кластера (kubectl get events).

Предварительные требования

  • Внешняя машина (VM или сервер) с доступом к сети Интернет. Желательно не собирать артефакты с рабочего ноутбука администратора.
  • Закрытый контур с OCI-реестром. В закрытом контуре рекомендуется иметь собственный registry, поддерживающий OCI — в нём могут находиться как container images, так и Helm Charts.
  • Доступность private registry для всех будущих узлов кластера и АРМ администратора (DNS, TCP, TLS).
  • Достаточный объём дискового пространства на внешней машине и на машине закрытого контура.
  • Промежуточный носитель (USB Flash Drive) — если между контурами нет сетевой связи.

В командах используются следующие условные значения:

Плейсхолдер Значение
registry.example.local адрес приватного OCI-реестра в закрытом контуре
charts.example.com адрес внешнего Helm-репозитория
SOURCE_REPO имя внешнего Helm-репозитория в Helm
my-chart / my-application примеры имени чарта и приложения
my-release имя Helm-релиза
my-namespace namespace установки
SOURCE/image:tag произвольный образ-источник
USER / PASSWORD учётные данные реестра

Шаг 1. Подготовка внешней машины

Для скачивания артефактов используется отдельная машина, имеющая доступ к Интернету. Рекомендуется использовать специальную VM или сервер (например, airgap-staging).

1.1. Установка инструментов

На внешней машине устанавливаются:

  • Helm;
  • Docker или Podman;
  • Skopeo;
  • необходимые registry clients;
  • Git;
  • jq;
  • yq;
  • curl;
  • sha256sum.

Проверьте установку:

helm version
docker version
skopeo --version
jq --version
yq --version
git --version

1.2. Фиксация версий инструментов

Версии инструментов необходимо зафиксировать — это важно для последующего расследования проблем:

helm version > TOOL-VERSIONS.txt
docker version >> TOOL-VERSIONS.txt
skopeo --version >> TOOL-VERSIONS.txt
jq --version >> TOOL-VERSIONS.txt
yq --version >> TOOL-VERSIONS.txt

Шаг 2. Создание рабочего каталога

Создайте рабочий каталог:

export WORKDIR=/opt/airgap-transfer
mkdir -p "$WORKDIR"/{charts,images,manifests,checksums,logs,scripts}

Структура каталога:

/opt/airgap-transfer/
├── charts/
├── images/
├── manifests/
├── checksums/
├── logs/
└── scripts/

Все последующие операции выполняйте из этого каталога.

Шаг 3. Получение Helm Chart

3.1. Добавление внешнего Helm Repository

Классический Helm Repository имеет URL вида https://charts.example.com и содержит index.yaml.

Добавьте repository:

helm repo add SOURCE_REPO https://charts.example.com

Обновите индекс:

helm repo update

Проверьте:

helm search repo SOURCE_REPO

Получите список версий:

helm search repo SOURCE_REPO/my-chart --versions

Пример вывода:

NAME                  CHART VERSION
SOURCE_REPO/my-chart  1.5.0
SOURCE_REPO/my-chart  1.4.3
SOURCE_REPO/my-chart  1.4.2

3.2. Скачивание конкретной версии

Не используйте helm pull без указания версии

Для воспроизводимого набора всегда явно указывайте версию. Команда helm pull SOURCE_REPO/my-chart без --version скачает последнюю версию, что делает поставку непредсказуемой.

Скачайте нужную версию:

helm pull SOURCE_REPO/my-chart \
    --version 1.5.0 \
    --destination "$WORKDIR/charts"

Результат:

charts/
└── my-chart-1.5.0.tgz

Проверьте содержимое:

helm show chart "$WORKDIR/charts/my-chart-1.5.0.tgz"
helm show all "$WORKDIR/charts/my-chart-1.5.0.tgz"

3.3. Анализ Chart и его зависимостей

Для анализа Chart его удобно распаковать:

mkdir -p "$WORKDIR/chart-source"
tar -xzf \
    "$WORKDIR/charts/my-chart-1.5.0.tgz" \
    -C "$WORKDIR/chart-source"

Результат:

chart-source/
└── my-chart/
    ├── Chart.yaml
    ├── values.yaml
    ├── templates/
    ├── charts/
    └── ...

Особое внимание уделите Chart.yaml. Пример:

apiVersion: v2
name: my-chart
version: 1.5.0
dependencies:
  - name: redis
    version: 20.5.0
    repository: https://charts.example.com
  - name: postgresql
    version: 16.7.0
    repository: https://another.example.com

Перенос только основного Chart недостаточен

Самая распространённая ошибка — перенести только основной Chart. Если у Chart есть зависимости, простого переноса my-chart-1.5.0.tgz недостаточно — необходимо перенести и их:

my-application
├── redis
├── postgresql
├── nginx
└── prometheus

Если исходный Chart является локальным, проверьте дерево зависимостей:

cd "$WORKDIR/chart-source/my-chart"
helm dependency list .

Пример вывода:

NAME        VERSION   REPOSITORY
redis       20.5.0    https://charts.example.com
postgresql  16.7.0    https://another.example.com

3.4. Загрузка зависимостей и фиксация Chart.lock

Во внешнем контуре загрузите зависимости:

helm dependency update .

После этого в каталоге Chart появятся:

charts/
├── redis-20.5.0.tgz
└── postgresql-16.7.0.tgz

Проверьте:

ls -la

Должны присутствовать:

Chart.yaml
Chart.lock
charts/

Зачем переносить Chart.lock

Chart.yaml описывает зависимости, а lock-файл фиксирует конкретный набор версий зависимостей и их digest.

Без lock-файла при формально неизменном диапазоне версии может «уехать» версия зависимости:

Сегодня:      postgresql 16.7.0
Через полгода: postgresql 16.7.1

Для air-gapped поставки желательно фиксировать:

Chart version
+ Dependency version
+ Dependency digest
+ Container image digest

3.5. Упаковка Chart вместе с зависимостями

Если Chart поставляется с зависимостями внутри каталога charts/, их также необходимо перенести. В большинстве случаев достаточно упаковать итоговый Chart:

helm package .

Получится my-chart-1.5.0.tgz. Убедитесь, что зависимости действительно попали внутрь:

tar -tzf my-chart-1.5.0.tgz

Пример вывода:

my-chart/Chart.yaml
my-chart/Chart.lock
my-chart/values.yaml
my-chart/templates/...
my-chart/charts/redis-20.5.0.tgz
my-chart/charts/postgresql-16.7.0.tgz

Шаг 4. Поиск container images

Chart без образов бесполезен

Перенос Chart без образов практически бесполезен. Необходимо получить полный список image references.

4.1. Рендеринг Chart и сбор списка образов

Лучший способ найти образы — отрендерить Chart:

helm template my-release \
    "$WORKDIR/charts/my-chart-1.5.0.tgz" \
    > "$WORKDIR/manifests/rendered.yaml"

Найдите образы в отрендеренном манифесте:

grep -E '^[[:space:]]*image:' "$WORKDIR/manifests/rendered.yaml"

Пример вывода:

image: nginx:1.29.1
image: redis:7.4.1
image: quay.io/example/exporter:2.1.0

Получите уникальный список:

grep -E '^[[:space:]]*image:' \
    "$WORKDIR/manifests/rendered.yaml" \
    | sed 's/.*image:[[:space:]]*//' \
    | sort -u \
    > "$WORKDIR/manifests/images.txt"

Проверьте:

cat "$WORKDIR/manifests/images.txt"

Пример содержимого:

docker.io/library/nginx:1.29.1
docker.io/library/redis:7.4.1
quay.io/example/exporter:2.1.0
registry.example.com/myteam/application:3.7.2

Риск grep-зависимостей

Простой поиск image: через grep не понимает структуру Kubernetes-манифеста и шаблонов Helm. Образ может формироваться шаблоном:

image:
  registry: docker.io
  repository: nginx
  tag: "1.29"
image:
  repository: "{{ .Values.global.registry }}/my-app"
image: "{{ include "myapp.image" . }}"

Для production рекомендуется использовать полноценный YAML-парсер вместо обработки grep.

4.2. Почему одного helm template недостаточно

Не все образы обязательно присутствуют в результате стандартного рендеринга. Образ может использоваться в:

initContainer
Job
hook
test
optional component

Поэтому дополнительно проверьте:

values.yaml
Chart.yaml
templates/
README.md

и документацию самого продукта. Особое внимание:

initContainers
sidecars
webhooks
controllers
exporters
test images
migration jobs
hooks

4.3. Поиск образов в values.yaml и по всему Chart

Поиск в values.yaml:

grep -RniE \
    'image|repository|tag' \
    "$WORKDIR/chart-source/my-chart/values.yaml"

Можно использовать yq:

yq '.. | select(has("image"))' \
    "$WORKDIR/chart-source/my-chart/values.yaml"

Если структура известна, лучше использовать точечные запросы:

yq '.image' values.yaml

Грубая проверка по всему Chart (хорошо обнаруживает забытые references, но не является полноценным парсером):

grep -RniE \
    'docker\.io/|quay\.io/|ghcr\.io/|registry\.|image:' \
    "$WORKDIR/chart-source/my-chart"

4.4. Список образов как отдельный артефакт

Рекомендуемый формат manifests/images.txt:

docker.io/library/nginx:1.29.1
docker.io/library/redis:7.4.1
quay.io/example/exporter:2.1.0

Ещё лучше зафиксировать digest:

docker.io/library/nginx@sha256:xxxxxxxx
docker.io/library/redis@sha256:xxxxxxxx
quay.io/example/exporter@sha256:xxxxxxxx

Tag для человека, digest для целостности

Tag удобен для человека, но может измениться. Digest неизменяем и важен для воспроизводимости и контроля целостности.

4.5. Проверка digest

Для registry можно использовать Skopeo:

skopeo inspect \
    docker://docker.io/library/nginx:1.29.1

Пример вывода:

{
    "Name": "docker.io/library/nginx",
    "Digest": "sha256:..."
}

Для automation:

skopeo inspect \
    --format '{{.Digest}}' \
    docker://docker.io/library/nginx:1.29.1

Полная информация об образе (manifest, manifest list, platform, digest, media types):

skopeo inspect \
    --raw \
    docker://docker.io/library/nginx:1.29.1 \
    > nginx-manifest.json

4.6. Multi-architecture images

Multi-arch: обычный pull может сохранить только одну платформу

Образ nginx:1.29.1 может содержать несколько вариантов:

linux/amd64
linux/arm64
linux/arm/v7
linux/ppc64le
linux/s390x

Если выполнить обычный pull на amd64-машине, можно получить только amd64-вариант. Для закрытого Kubernetes-кластера это проблема, если в нём присутствуют разные архитектуры.

Для переноса полного образа используйте Skopeo с опцией --all (копирует image list вместе с вариантами для разных платформ):

skopeo copy \
    --all \
    docker://docker.io/library/nginx:1.29.1 \
    docker://registry.example.local/third-party/nginx:1.29.1

Для registry-to-registry переноса Skopeo предпочтительнее, чем пара docker pull + docker push.

Шаг 5. Перенос Helm Chart в закрытый контур

Если закрытый registry поддерживает OCI, это предпочтительный вариант. Современный Helm поддерживает OCI registry как хранилище Charts (начиная с Helm 3.8 OCI-поддержка включена по умолчанию). Helm позволяет выполнять pull, push, install, upgrade и операции с зависимостями через oci://.

Сначала выполните login:

helm registry login registry.example.local

Затем перенесите Chart:

helm push \
    "$WORKDIR/charts/my-chart-1.5.0.tgz" \
    oci://registry.example.local/helm

Helm сам использует имя Chart и его версию:

my-chart:1.5.0

Reference для helm push

При helm push в OCI reference должен указывать registry/repository без имени Chart и без tag. Имя и версия берутся из Chart metadata.

Проверьте перенос:

helm show chart \
    oci://registry.example.local/helm/my-chart \
    --version 1.5.0

Можно выполнить:

helm pull \
    oci://registry.example.local/helm/my-chart \
    --version 1.5.0

и проверить digest. Для особо критичных систем можно использовать установку по digest, поскольку digest является неизменяемым идентификатором содержимого Chart.

Если private repository не поддерживает OCI, можно использовать классический Helm Repository (например, https://helm.example.local):

Chart .tgz
Helm Repository
      ├── index.yaml
      ├── my-chart-1.5.0.tgz
      └── ...

Такой вариант до сих пор возможен, однако для нового решения предпочтительнее registry с OCI.

Шаг 6. Перенос container images

Если закрытый контур не требует промежуточного носителя, наиболее простой вариант:

Internet Registry
       │ Skopeo
Private Registry
skopeo copy \
    --all \
    docker://docker.io/library/nginx:1.29.1 \
    docker://registry.example.local/third-party/nginx:1.29.1

Для другого образа:

skopeo copy \
    --all \
    docker://quay.io/example/exporter:2.1.0 \
    docker://registry.example.local/third-party/exporter:2.1.0

Skopeo умеет копировать образы непосредственно между registry, а также работать с локальными dir, docker-archive и OCI transport.

Если между Internet-контуром и закрытым контуром физически нет сетевой связи, используется промежуточный носитель:

             INTERNET
          ┌──────────────┐
          │ Staging Host │
          └──────┬───────┘
         ┌───────────────┐
         │ USB / SSD     │
         │ Transfer Media│
         └───────┬───────┘
          ┌──────────────┐
          │ Airgap Host  │
          └──────┬───────┘
          Private Registry

Способы сохранения образов на носитель:

Для большого количества образов предпочтительны OCI archive или OCI image layout — они лучше соответствуют современной registry/OCI-модели и позволяют сохранять структуру image artifacts (в отличие от docker save).

mkdir -p "$WORKDIR/images"
skopeo copy \
    --all \
    docker://docker.io/library/nginx:1.29.1 \
    oci-archive:"$WORKDIR/images/nginx-1.29.1.tar":nginx:1.29.1

Аналогично:

skopeo copy \
    --all \
    docker://quay.io/example/exporter:2.1.0 \
    oci-archive:"$WORKDIR/images/exporter-2.1.0.tar":exporter:2.1.0

Самый простой вариант для отдельных образов:

skopeo copy \
    docker://docker.io/library/nginx:1.29.1 \
    docker-archive:"$WORKDIR/images/nginx-1.29.1.tar":nginx:1.29.1

Получаем images/nginx-1.29.1.tar — архив в формате, совместимом с docker load.

Ограничение docker-archive

Для multi-arch image необходимо внимательно контролировать, что именно переносится. Если требуется сохранить полный manifest list, предпочтительнее использовать OCI archive:

skopeo copy \
    --all \
    docker://docker.io/library/nginx:1.29.1 \
    oci-archive:"$WORKDIR/images/nginx-1.29.1.oci.tar":nginx:1.29.1

Можно сохранить image в каталог:

mkdir -p "$WORKDIR/images/nginx-1.29.1"
skopeo copy \
    --all \
    docker://docker.io/library/nginx:1.29.1 \
    dir:"$WORKDIR/images/nginx-1.29.1"

Получится структура с manifest и blobs. Этот вариант удобен, когда:

  • нужно инспектировать содержимое;
  • требуется повторное использование blobs;
  • архив будет создаваться позднее;
  • необходимо избежать большого количества отдельных tar-файлов.

Skopeo документирует dir: как локальное представление manifest/layers/signatures.

Skopeo поддерживает transports: oci:, oci-archive:, docker-archive:, dir:, docker: и другие.

6.1. Аутентификация Skopeo

Выполните login в исходные registry:

skopeo login docker.io

или:

skopeo login quay.io

Для private destination:

skopeo login registry.example.local

После этого:

skopeo copy \
    --all \
    docker://SOURCE/image:tag \
    docker://registry.example.local/namespace/image:tag

В automation можно указать credentials явно:

skopeo copy \
    --src-creds "${SRC_USER}:${SRC_PASSWORD}" \
    --dest-creds "${DST_USER}:${DST_PASSWORD}" \
    --all \
    docker://SOURCE/image:tag \
    docker://registry.example.local/image:tag

Не передавайте пароль в командной строке

Передача password непосредственно в командной строке нежелательна (попадает в историю и логи процессов). Лучше использовать skopeo login или отдельный authfile.

Шаг 7. Формирование Transfer Package

Для небольшого количества образов удобно собрать единый пакет:

airgap-package/
├── charts/
├── images/
├── manifests/
├── checksums/
├── tools/
└── README.txt
mkdir -p "$WORKDIR/package"

cp -r "$WORKDIR/charts" "$WORKDIR/package/"
cp -r "$WORKDIR/images" "$WORKDIR/package/"
cp -r "$WORKDIR/manifests" "$WORKDIR/package/"
cp -r "$WORKDIR/checksums" "$WORKDIR/package/"

7.1. Inventory

Очень желательно иметь файл manifest.txt. Например:

PACKAGE_NAME=my-application
PACKAGE_VERSION=1.5.0

HELM_CHART=my-chart
HELM_CHART_VERSION=1.5.0

IMAGES:
- docker.io/library/nginx:1.29.1
- docker.io/library/redis:7.4.1
- quay.io/example/exporter:2.1.0

Более формальный inventory:

artifact-type|name|version|source|destination
chart|my-chart|1.5.0|SOURCE_REPO/my-chart|registry.example.local/helm/my-chart:1.5.0
image|nginx|1.29.1|docker.io/library/nginx:1.29.1|registry.example.local/third-party/nginx:1.29.1
image|redis|7.4.1|docker.io/library/redis:7.4.1|registry.example.local/third-party/redis:7.4.1

Рекомендуемая структура каталога manifests/:

manifests/
├── charts.txt
├── images.txt
├── images-digests.txt
├── crds.txt
├── artifacts.txt
└── rendered.yaml

Примеры содержимого:

# charts.txt
my-chart|1.5.0
redis|20.5.0
postgresql|16.7.0
# images.txt
docker.io/library/nginx:1.29.1
docker.io/library/redis:7.4.1
quay.io/example/exporter:2.1.0
# images-digests.txt
docker.io/library/nginx@sha256:...
docker.io/library/redis@sha256:...
quay.io/example/exporter@sha256:...

7.2. Контрольные суммы

После завершения скачивания посчитайте SHA-256:

find "$WORKDIR/charts" "$WORKDIR/images" \
    -type f \
    -exec sha256sum {} \; \
    > "$WORKDIR/checksums/SHA256SUMS"

Проверка:

sha256sum -c \
    "$WORKDIR/checksums/SHA256SUMS"

Ожидаемый результат:

... nginx-1.29.1.tar: OK
... redis-7.4.1.tar: OK
... my-chart-1.5.0.tgz: OK

7.3. Общий архив и контрольная сумма пакета

Создайте общий архив:

tar \
    -czf \
    my-application-1.5.0-airgap.tar.gz \
    -C "$WORKDIR" \
    charts \
    images \
    manifests \
    checksums

Проверьте содержимое:

tar -tzf my-application-1.5.0-airgap.tar.gz

Посчитайте контрольную сумму самого пакета:

sha256sum \
    my-application-1.5.0-airgap.tar.gz \
    > my-application-1.5.0-airgap.tar.gz.sha256

Теперь на USB переносится:

my-application-1.5.0-airgap.tar.gz
my-application-1.5.0-airgap.tar.gz.sha256

7.4. Запись на USB

Допустим, USB смонтирован в /media/usb. Копируем:

cp \
    my-application-1.5.0-airgap.tar.gz \
    /media/usb/

cp \
    my-application-1.5.0-airgap.tar.gz.sha256 \
    /media/usb/

Затем обязательно:

sync

После чего безопасно размонтируйте носитель:

umount /media/usb

Шаг 8. Импорт в закрытом контуре

8.1. Проверка носителя и распаковка

Обязательная проверка checksum перед импортом

На машине закрытого контура сначала проверьте контрольную сумму пакета и только после успешной проверки распаковывайте архив:

sha256sum \
    -c \
    my-application-1.5.0-airgap.tar.gz.sha256

Ожидаемый результат:

my-application-1.5.0-airgap.tar.gz: OK

Распаковка:

mkdir -p /opt/airgap-import

tar \
    -xzf \
    my-application-1.5.0-airgap.tar.gz \
    -C /opt/airgap-import

Результат:

/opt/airgap-import/
├── charts/
├── images/
├── manifests/
└── checksums/

Проверьте checksum после распаковки:

cd /opt/airgap-import
sha256sum -c checksums/SHA256SUMS

Все файлы должны иметь OK.

При любой ошибке импорт необходимо остановить

Если любой файл получил FAILED — импорт необходимо остановить и выяснить причину повреждения.

8.2. Импорт container images

Для OCI archive:

skopeo copy \
    oci-archive:images/nginx-1.29.1.tar \
    docker://registry.example.local/third-party/nginx:1.29.1

Для multi-arch archive:

skopeo copy \
    --all \
    oci-archive:images/nginx-1.29.1.tar \
    docker://registry.example.local/third-party/nginx:1.29.1

Если используется docker archive:

docker load \
    -i images/nginx-1.29.1.tar

Затем:

docker tag \
    nginx:1.29.1 \
    registry.example.local/third-party/nginx:1.29.1

И:

docker push \
    registry.example.local/third-party/nginx:1.29.1

Для массовой миграции Skopeo обычно удобнее, поскольку позволяет работать непосредственно с registry и различными локальными transport.

8.3. Массовый импорт images

Если имеется:

images/
├── nginx-1.29.1.tar
├── redis-7.4.1.tar
├── exporter-2.1.0.tar
└── app-3.7.2.tar

можно сделать скрипт:

#!/usr/bin/env bash
set -euo pipefail

REGISTRY="registry.example.local"

skopeo copy \
    oci-archive:images/nginx-1.29.1.tar \
    docker://${REGISTRY}/third-party/nginx:1.29.1

skopeo copy \
    oci-archive:images/redis-7.4.1.tar \
    docker://${REGISTRY}/third-party/redis:7.4.1

skopeo copy \
    oci-archive:images/exporter-2.1.0.tar \
    docker://${REGISTRY}/third-party/exporter:2.1.0

Для большого количества images лучше не писать такие команды вручную, а хранить mapping:

source|archive|destination
docker.io/library/nginx:1.29.1|nginx-1.29.1.tar|registry.example.local/third-party/nginx:1.29.1
docker.io/library/redis:7.4.1|redis-7.4.1.tar|registry.example.local/third-party/redis:7.4.1

и обрабатывать его скриптом.

8.4. Импорт Helm Chart

Выполните login и перенесите Charts:

skopeo login registry.example.local
helm registry login registry.example.local
helm push \
    charts/my-chart-1.5.0.tgz \
    oci://registry.example.local/helm

Проверьте импорт:

helm show chart \
    oci://registry.example.local/helm/my-chart \
    --version 1.5.0

Шаг 9. Настройка установки в закрытом контуре

9.1. Переписывание image references

После переноса в private registry Kubernetes больше не должен пытаться обращаться к внешним registry:

docker.io
quay.io
ghcr.io

Например, было:

image:
  repository: docker.io/library/nginx
  tag: "1.29.1"

должно стать:

image:
  repository: registry.example.local/third-party/nginx
  tag: "1.29.1"

Лучше не изменять сам Chart

Если Chart предоставляет параметр global.imageRegistry, используйте его вместо ручного изменения Chart:

helm install my-release \
    ./my-chart-1.5.0.tgz \
    --set global.imageRegistry=registry.example.local

Если Chart поддерживает:

image:
  registry:
  repository:
  tag:

можно передать:

--set image.registry=registry.example.local

9.2. values-airgap.yaml

Для production-системы рекомендуется создать отдельный файл values-airgap.yaml. Например:

global:
  imageRegistry: registry.example.local

image:
  registry: registry.example.local
  repository: third-party/my-application
  tag: "1.5.0"

После этого:

helm upgrade --install my-release \
    oci://registry.example.local/helm/my-chart \
    --version 1.5.0 \
    -f values-airgap.yaml

Это намного лучше, чем вручную менять Chart.

9.3. Проверка отрендеренных манифестов

До реального deployment выполните:

helm template my-release \
    ./my-chart-1.5.0.tgz \
    -f values-airgap.yaml \
    > rendered-airgap.yaml

Затем:

grep -E '^[[:space:]]*image:' \
    rendered-airgap.yaml

В выводе не должно быть:

docker.io/
quay.io/
ghcr.io/
registry-1.docker.io/

если эти registry недоступны в закрытом контуре.

Проверка:

grep -E \
    'docker\.io|quay\.io|ghcr\.io|registry-1\.docker\.io' \
    rendered-airgap.yaml

Команда должна вернуть пустой результат.

Получите полный список references:

grep -E '^[[:space:]]*image:' rendered-airgap.yaml \
    | sed 's/.*image:[[:space:]]*//' \
    | sort -u

Пример вывода:

registry.example.local/third-party/nginx:1.29.1
registry.example.local/third-party/redis:7.4.1
registry.example.local/third-party/exporter:2.1.0
registry.example.local/application/my-app:3.7.2

Этот список должен совпадать с inventory.

9.4. Доступ Kubernetes к private registry (imagePullSecrets)

Если private registry требует authentication, Kubernetes должен иметь credentials:

kubectl create secret docker-registry registry-credentials \
    --docker-server=registry.example.local \
    --docker-username=USER \
    --docker-password='PASSWORD' \
    --namespace=my-namespace

После этого укажите secret в манифестах:

imagePullSecrets:
  - name: registry-credentials

Или настройте secret на ServiceAccount.

9.5. Проверка доступности registry (DNS, TLS, crictl)

Из Kubernetes node проверьте доступность registry:

curl -k https://registry.example.local/v2/

В зависимости от конфигурации registry можно получить 401 Unauthorized — это нормально, если registry требует authentication. Важно, чтобы сам registry был доступен.

Проверьте DNS:

getent hosts registry.example.local

Проверьте TCP:

nc -vz registry.example.local 443

Проверка TLS. Если registry использует собственный CA:

registry.example.local
        └── internal CA

Kubernetes nodes/container runtime должны доверять этому CA. Проверка:

openssl s_client \
    -connect registry.example.local:443 \
    -servername registry.example.local

Установка CA

Если используется self-signed/internal certificate, необходимо корректно установить CA на:

  • Kubernetes nodes;
  • containerd/CRI-O;
  • registry clients;
  • при необходимости Helm/Skopeo host.

Если установлен crictl, более полезная проверка, чем docker pull:

crictl pull \
    registry.example.local/third-party/nginx:1.29.1

Если pull проходит (Image is up to date ...), можно переходить к Helm deployment.

Шаг 10. CRD, webhooks и дополнительные артефакты

10.1. CRD внутри Chart

Некоторые Helm Charts устанавливают CRD:

crds/
├── example.io_widgets.yaml
└── example.io_clusters.yaml

Их необходимо учитывать отдельно. Проверьте:

tar -tzf my-chart-1.5.0.tgz | grep '^my-chart/crds/'

Также полезно:

helm show all my-chart-1.5.0.tgz

10.2. CRD и манифесты вне Chart

Если документация продукта требует, например:

kubectl apply -f https://example.com/crd.yaml

то в air-gapped среде такая команда работать не будет. Нужно заранее скачать:

curl -LO https://example.com/crd.yaml

и включить файл в transfer package:

manifests/
└── crd.yaml

То же самое относится к манифестам, поставляемым отдельно:

MutatingWebhookConfiguration
ValidatingWebhookConfiguration
ServiceAccount
ClusterRole
ClusterRoleBinding
ConfigMap
Secret
Namespace

Особенно внимательно проверять файлы:

install.yaml
operator.yaml
bundle.yaml
deploy.yaml

10.3. Operators, hooks, tests

Для Operator-поставок схема становится сложнее:

Operator
├── Controller image
├── Webhook image
├── Init image
├── Sidecar image
└── Operand images

Кроме самого Operator image необходимо переносить images управляемого приложения. Например:

postgres-operator
       ├── operator image
       └── PostgreSQL image

Operator без operand-образа не заработает

Если перенести только operator image, а PostgreSQL image оставить на docker.io/postgres:... — air-gapped установка не заработает.

Helm hooks. Обязательно учитывайте:

annotations:
  "helm.sh/hook": pre-install

Такие hooks могут запускать Job, Pod, migration, init process со своими images. Поэтому поиск images необходимо выполнять по полностью отрендеренным manifests, включая hooks.

Helm tests. Chart может иметь templates/tests/:

helm.sh/hook: test

Если тесты запускаются в закрытом контуре, их images также должны быть доступны.

Проверка результата

Проверка целостности и digests

  • Контрольная сумма пакета: sha256sum -c my-application-1.5.0-airgap.tar.gz.sha256 — должен вернуть OK.
  • Контрольная сумма файлов после распаковки: sha256sum -c checksums/SHA256SUMS — все файлы OK.

Сравните digest после импорта:

skopeo inspect \
    docker://registry.example.local/third-party/nginx:1.29.1

Пример вывода:

Digest: sha256:...

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

SOURCE:  sha256:AAA...
PRIVATE: sha256:AAA...

Digest отличается — выясните причину

Если digest после импорта отличается от зафиксированного при загрузке — необходимо выяснить причину и не использовать такой образ.

Не полагайтесь только на tag:

nginx:latest      # плохо
nginx:1.29.1      # лучше
nginx@sha256:...  # ещё лучше для production

Причина — tag может измениться, digest — нет.

Проверка наличия каждого image в private registry

skopeo inspect \
    docker://registry.example.local/third-party/nginx:1.29.1

Для каждого image должна возвращаться metadata. Можно автоматизировать:

while read -r image; do
    echo "Checking $image"
    skopeo inspect "docker://$image" >/dev/null
done < manifests/private-images.txt

При ошибке (set -e) скрипт остановится.

Проверка манифестов

  • helm template с -f values-airgap.yaml не должен содержать docker.io/, quay.io/, ghcr.io/, registry-1.docker.io/.
  • Список references из отрендеренного манифеста должен совпадать с inventory.

Проверка deployment

helm lint ./my-chart
helm template my-release \
    ./my-chart-1.5.0.tgz \
    -f values-airgap.yaml

Установка:

helm upgrade --install \
    my-release \
    ./my-chart-1.5.0.tgz \
    --namespace my-namespace \
    --create-namespace \
    -f values-airgap.yaml

Если Chart находится в OCI registry:

helm upgrade --install \
    my-release \
    oci://registry.example.local/helm/my-chart \
    --version 1.5.0 \
    --namespace my-namespace \
    --create-namespace \
    -f values-airgap.yaml

Проверьте Pod'ы и события:

kubectl get pods -n my-namespace
kubectl get events \
    -n my-namespace \
    --sort-by=.lastTimestamp

Особенно обращать внимание на:

ImagePullBackOff
ErrImagePull
Failed
Back-off pulling image

Если Pod пытается выйти в Интернет — проверьте, на какой image ссылается Pod:

kubectl get pods \
    -n my-namespace \
    -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
kubectl describe pod POD_NAME \
    -n my-namespace

Смотреть поле Image: — все images должны ссылаться на private registry.

Частые ошибки

Симптом Вероятная причина Решение
ImagePullBackOff / ErrImagePull Образ не перенесён или указан неверный reference Проверьте images.txt, наличие образа в registry (skopeo inspect), сверьте с inventory
Chart установился, но зависимость не развернулась Перенесён только основной Chart без dependencies Выполните helm dependency update во внешнем контуре, перенесите charts/ и Chart.lock, упакуйте helm package
Pod тянет образ из docker.io / quay.io Не переписаны references / не применён values-airgap.yaml Отрендерьте helm template -f values-airgap.yaml, проверьте grep -E 'docker\.io|quay\.io|ghcr\.io', настройте global.imageRegistry
sha256sum -c вернул FAILED Повреждение носителя или архива при переносе Пересоздайте пакет, повторите перенос; не распаковывайте повреждённый архив
exec format error при запуске Pod Перенесён только amd64-вариант multi-arch образа Копируйте с skopeo copy --all, проверьте сохранение manifest list
Registry недоступен с узлов кластера Ошибка DNS, TCP или TLS/CA getent hosts, nc -vz, openssl s_client, установите внутренний CA на nodes/containerd/CRI-O
Версия артефакта «уехала» при повторной поставке Использование latest или отсутствие Chart.lock Фиксируйте версии и digest, переносите Chart.lock
Часть образов не обнаружена (initContainer, hook, test) grep не понимает структуру манифестов и шаблонов Рендерьте Chart, анализируйте values.yaml, Chart.yaml, templates/, README, документацию продукта
Установка Operator не работает Перенесён только operator image, operand-образ остался во внешнем registry Перенесите все operand images (PostgreSQL и т.п.)
Chart установлен, но CRD отсутствуют CRD устанавливаются отдельным манифестом Скачайте crd.yaml заранее, включите в transfer package, примените вручную

Приложения

A. Рекомендуемая структура Transfer Package

application-1.5.0/
├── README.md
├── manifest.yaml
├── charts/
│   ├── my-chart-1.5.0.tgz
│   ├── redis-20.5.0.tgz
│   └── postgresql-16.7.0.tgz
├── images/
│   ├── nginx-1.29.1.oci.tar
│   ├── redis-7.4.1.oci.tar
│   └── exporter-2.1.0.oci.tar
├── manifests/
│   ├── rendered.yaml
│   ├── images.txt
│   ├── images-digests.txt
│   ├── charts.txt
│   └── crds/
├── values/
│   └── values-airgap.yaml
├── checksums/
│   └── SHA256SUMS
└── scripts/
    ├── import-images.sh
    ├── import-charts.sh
    └── verify.sh

B. manifest.yaml

Полезно иметь машиночитаемый manifest. Например:

package:
  name: my-application
  version: 1.5.0
  created: 2026-08-31

helm:
  charts:
    - name: my-chart
      version: 1.5.0
      source: https://SOURCE-URL
      destination: registry.example.local/helm/my-chart

images:
  - source: docker.io/library/nginx:1.29.1
    destination: registry.example.local/third-party/nginx:1.29.1
    digest: sha256:...

  - source: docker.io/library/redis:7.4.1
    destination: registry.example.local/third-party/redis:7.4.1
    digest: sha256:...

  - source: quay.io/example/exporter:2.1.0
    destination: registry.example.local/third-party/exporter:2.1.0
    digest: sha256:...

Это уже практически полноценная SBOM-like inventory для поставки.

C. Скрипты автоматизации

Сбор списка образов из отрендеренного Chart:

helm template my-release ./my-chart \
    | grep -E '^[[:space:]]*image:' \
    | sed 's/.*image:[[:space:]]*//' \
    | sort -u \
    > images.txt

Риск grep-зависимостей

Для production рекомендуется использовать более полноценный parser YAML, потому что простая обработка grep не понимает структуру Kubernetes manifest.

Автоматизация переноса images по списку:

#!/usr/bin/env bash
set -euo pipefail

DEST_REGISTRY="registry.example.local"

while read -r IMAGE; do
    [ -z "$IMAGE" ] && continue

    echo "Processing: $IMAGE"

    # Здесь необходимо определить destination path
    # в соответствии с принятой политикой naming.
done < images.txt

На практике лучше заранее определить правила mapping. Например:

docker.io/library/nginx:1.29.1
registry.example.local/docker.io/library/nginx:1.29.1

quay.io/example/exporter:2.1.0
registry.example.local/quay.io/example/exporter:2.1.0

Это позволяет сохранить исходную структуру registry.

Проверка transfer package:

#!/usr/bin/env bash
set -euo pipefail

echo "Checking checksums..."
sha256sum -c checksums/SHA256SUMS

echo
echo "Checking required files..."
test -f manifest.yaml
test -f manifests/images.txt
test -f manifests/images-digests.txt

echo
echo "Package verification successful."

Проверка private registry:

#!/usr/bin/env bash
set -euo pipefail

REGISTRY="registry.example.local"

while read -r IMAGE; do
    [ -z "$IMAGE" ] && continue

    echo "Checking: $IMAGE"
    skopeo inspect \
        "docker://${REGISTRY}/${IMAGE}"
done < manifests/private-images.txt

Рекомендуемая автоматизация для регулярных поставок:

Application release
Helm Chart
       ├── resolve dependencies
       ├── render
       ├── discover images
       ├── resolve digests
       ├── download images
       ├── download charts
       ├── generate SBOM
       ├── generate manifest
       ├── calculate SHA256
Transfer Package

D. Рекомендуемая политика naming

Лучше не делать:

registry.example.local/nginx
registry.example.local/redis
registry.example.local/exporter

потому что через год станет непонятно, откуда это приехало. Лучше:

registry.example.local/docker.io/library/nginx
registry.example.local/docker.io/library/redis
registry.example.local/quay.io/example/exporter

или:

registry.example.local/third-party/docker.io/library/nginx
registry.example.local/third-party/quay.io/example/exporter

Это значительно упрощает аудит.

Пример конечной структуры private registry:

registry.example.local/
├── third-party/
│   ├── docker.io/
│   │   └── library/
│   │       ├── nginx
│   │       ├── redis
│   │       └── busybox
│   │
│   └── quay.io/
│       └── example/
│           └── exporter
├── internal/
│   ├── application-a
│   └── application-b
└── helm/
    ├── application-a
    ├── application-b
    └── dependencies

E. Политики для air-gapped поставок

Для промышленной эксплуатации рекомендуется установить следующие правила:

  1. Правило 1. Не использовать latest.
  2. Правило 2. Все версии фиксируются: Chart version, Image tag, Image digest.
  3. Правило 3. Каждая поставка получает уникальный ID (например, application-1.5.0).
  4. Правило 4. Для каждого transfer package существует: SHA256, manifest, inventory.
  5. Правило 5. Нельзя устанавливать Chart, если он содержит неизвестный внешний image.
  6. Правило 6. Нельзя разрешать Kubernetes nodes выходить в Интернет только для скачивания image.
  7. Правило 7. Весь набор dependencies должен быть известен до начала импорта.

F. Что часто забывают

При переносе Kubernetes-приложений в air-gapped среду чаще всего забывают:

1.  Helm dependencies
2.  initContainer images
3.  sidecar images
4.  webhook images
5.  operator images
6.  CRD
7.  Helm hooks
8.  Helm test images
9.  migration Job images
10. multi-arch variants
11. imagePullSecrets
12. internal CA
13. дополнительные manifests
14. images, задаваемые через values
15. images, которые формируются шаблонами
16. отдельные registry credentials
17. admission webhooks
18. дополнительные charts операторов

G. Чек-лист перед передачей USB

  • Helm Chart скачан
  • Версия Chart зафиксирована
  • Chart.lock присутствует
  • Все Helm dependencies скачаны
  • Все dependencies имеют фиксированные версии
  • Chart успешно упакован
  • Chart успешно проходит helm lint
  • Chart успешно render'ится
  • Все images обнаружены
  • InitContainer images обнаружены
  • Sidecar images обнаружены
  • Hook images обнаружены
  • Test images обнаружены
  • Operator images обнаружены
  • CRD обнаружены
  • Дополнительные manifests обнаружены
  • Multi-arch images проверены
  • Image digest сохранены
  • Все images скачаны
  • Все OCI archives проверены
  • Private registry mapping сформирован
  • SHA256 сформированы
  • Transfer package сформирован
  • SHA256 самого package сформирован
  • Package успешно проверен

H. Чек-лист после переноса

В закрытом контуре:

  • SHA256 package совпадает
  • SHA256 всех файлов совпадает
  • Все Charts импортированы
  • Все dependencies импортированы
  • Все images импортированы
  • Image digests совпадают
  • Multi-arch manifests сохранены
  • CRD установлены
  • Private registry доступен с Kubernetes nodes
  • TLS работает
  • CA доверен
  • imagePullSecrets настроены
  • helm template не содержит Internet registry
  • crictl pull работает
  • Helm deployment проходит
  • Pods не имеют ImagePullBackOff
  • Kubernetes не требует Internet access

I. Подписи и SBOM (рекомендации на будущее)

Рекомендация, а не штатная функция

Подписи и SBOM — это возможности для усиления защищённых контуров «на будущее». На текущий момент это рекомендации по процессу, а не штатная функция платформы «Боцман» или описанного здесь инструментария.

Современная инфраструктура может использовать registry не только для container images и Helm Charts. В registry могут находиться:

Helm Charts
Container Images
SBOM
Signatures
Cosign artifacts
OCI artifacts

В защищённых контурах желательно переносить не только image, но и:

signature
SBOM
provenance
image
 ├── manifest
 ├── layers
 ├── signature
 └── SBOM

Если используется Cosign/Notation или другая система подписи, подписи должны быть перенесены и проверены уже внутри закрытого контура. При использовании supply-chain security необходимо определить полный перечень OCI artifacts.