
Привет! Меня зовут Георг Гаал. Я CTO в AEnix, и мы разработали платформу cozystack на базе технологий Talos Linux и Kubernetes. Она позволяет легко и просто запустить своё частное или даже публичное облако. У нас уже есть множество клиентов, в том числе и среди хостинговых компаний, и у них регулярно возникает вопрос: «Можно ли запустить систему в air-gapped режиме?» Ответ будет универсальным для любого дистрибутива kubernetes. Частности будут в названии образов. Давайте разберёмся как же можно этого добиться, но начнём с определений.
Air gap — это способ размещения информационных систем, когда они отрезаны от интернета. В английском «air gap» означает воздушную прослойку, что отражает суть этого способа изоляции. То есть в режиме air gap режиме наши сервера полностью автономны от внешней сети Интернет и должны быть способны работать в случае отключения или отказа Интернета. Это приводит к дополнительным накладным расходам при настройке серверов, так как уже нельзя напрямую зайти на них по ssh и что-то настроить. А ещё необходимо специальным образом настраивать внутренние репозитории, чтобы была возможность устанавливать пакеты на сервера, обновлять их и производить прочие поддерживающие мероприятия.
Можно сказать, что бывает частичный air gap. Когда сервера имеют доступ в Интернет, но очень ограниченный — через какие-либо прокси, шлюзы или попросту по разрешённому списку доменов или IP-адресов. В этом случае всё равно возникает необходимость дополнительных настроек и организации внутренних репозиториев.
Первоочерёдным элементом для построения своей air gap установки является запуск своего собственного контейнерного репозитория. Наверное, наиболее известным репозиторием является docker distribution. К сожалению, он очень упрощённый, поэтому вряд ли будет хорошим выбором. Хорошим выбором могло быть использование комбайнов вроде Nexus или JFrog Artifactory. Одно или другое решение уже присутствуют во многих технологических компаниях, поэтому есть шанс, что не придётся притаскивать какое-то новое решение в ИТ-ландшафт.
Из доклада «Постигая реестры Docker-контейнеров: от архитектуры до безопасности» с буквально недавно прошедшей в Сколково DevOpsConf 2025 я с интересом узнал, что GitLab внесли очень много изменений в ванильный docker distribution и, по сути, сделали свой форк. Возможно, кому-то он понравится. Лично я же предпочитаю полностью open source решения, и при выборе технологии опираюсь на карту технологического ландшафта от фонда CNCF.

В нём можно найти решение Harbor. Изначально разработанный vmware, сейчас этот репозиторий образов разрабатывается сообществом. Более того, он принят в фонд CNCF и уже достиг уровня зрелости Graduated. Это означает, что это достаточно стабильное и популярное решение, которое будет и в дальнейшем развиваться и на него можно положиться. Именно с ним мы и будем дальше работать.
Развертывание harbor очень простое. Так как мы хотим использовать его для хранения базовых образов, из которых будем запускать kubernetes кластер, то очевидно, что нам нужен отдельный сервер. Хоть Harbor может быть запущен и в kubernetes кластере внутри. Существует превосходный helm чарт, который поможет это сделать. Но есть нюанс. Он заключается в том, что этот кластер нужно каким-то образом поднять в том же окружении. То есть мы упираемся в классическую проблему курицы и яйца. А даже если бы нам удалось каким-то образом поднять kubernetes кластер без harbor в закрытом окружении, потом установить в него harbor, переложить туда образы, то потом потенциально в случае отказа мы могли бы попасть в ситуацию, когда восстановление осложнено. Все-таки kubernetes с хранением данных - это совершенно другой уровень ответственности и другой уровень необходимых знаний для поддержки. Поэтому идем самым простым и логичным путем: берем отдельный сервер.
Далее на этот сервер необходимо установить docker демон и утилиты docker compose. Это не должно быть проблемой. Как я уже сказал, обычно в закрытом окружении уже есть репозитории пакетов операционной системы (иначе как вы настраиваете сервера?), поэтому должно быть легко воспользоваться штатным пакетным менеджером и мы этот вопрос разбирать не будем в настоящей статье.
К нашему счастью, у Harbor уже имеется подробная инструкция по так называемой offline установке, которую можно найти тут.
Ничего сложного тут нет, я проходил этот путь неоднократно. Скачиваем архив (по состоянию на 22 апреля 2025 года актуальный файл harbor-offline-installer-v2.13.0.tgz), переносим любым удобным способом на сервер, распаковываем. При этом получается следующая структура файлов:
root@ubuntu-16gb-hel1-1:~/harbor# pwd
/root/harbor
root@ubuntu-16gb-hel1-1:~/harbor# tree
.
├── LICENSE
├── common.sh
├── harbor.v2.13.0.tar.gz
├── harbor.yml.tmpl
├── install.sh
└── prepare
1 directory, 6 files
Следующий этап — подготовка конфигурационного файла для запуска. Все настройки описаны тут. По сути, нам нужно только указать доменное имя, которое вы заранее завели в DNS сервере внутри сети. А ещё хорошая идея — сразу заказать сертификат для этого доменного имени у любого известного удостоверяющего центра (например, Let's Encrypt или thawte). Если же сгенерировать самоподписанный сертификат, потом будут большие сложности в работе с контейнерным реестром, так как во все потребители образов надо будет этот сертификат добавлять как доверенный. Процедура не очень приятная, проще с этим не связываться.

Измените необходимые конфигурационные значения в файле
harbor.yml.tmpl
и сохраните его как
harbor.yml
Следующий этап запуск скрипта установки install.sh. Скрипт генерирует необходимые конфигурационные файлы и запускает все компоненты хранилища образов Harbor.
root@ubuntu-16gb-hel1-1:~/harbor# ./install.sh
[Step 0]: checking if docker is installed ...
Note: docker version: 28.1.1
[Step 1]: checking docker-compose is installed ...
Note: Docker Compose version v2.35.1
[Step 2]: loading Harbor images ...
874b37071853: Loading layer [==================================================>] 40.92MB/40.92MB
7824f74aeb34: Loading layer [==================================================>] 16.73MB/16.73MB
6409b9df8a24: Loading layer [==================================================>] 175.4MB/175.4MB
be0b74c61638: Loading layer [==================================================>] 26.55MB/26.55MB
fa43dc6ea88a: Loading layer [==================================================>] 18.8MB/18.8MB
129629e46413: Loading layer [==================================================>] 5.12kB/5.12kB
0e73caa4009a: Loading layer [==================================================>] 6.144kB/6.144kB
489c5f744457: Loading layer [==================================================>] 3.072kB/3.072kB
651edf4d986c: Loading layer [==================================================>] 2.048kB/2.048kB
7188338bd824: Loading layer [==================================================>] 2.56kB/2.56kB
43fa9ff6ed1b: Loading layer [==================================================>] 14.85kB/14.85kB
Loaded image: goharbor/harbor-db:v2.13.0
47e634084453: Loading layer [==================================================>] 11.62MB/11.62MB
3a3567e1c917: Loading layer [==================================================>] 3.584kB/3.584kB
c6d913de9882: Loading layer [==================================================>] 2.56kB/2.56kB
9bd0e4a44d4e: Loading layer [==================================================>] 61.26MB/61.26MB
25999a94a919: Loading layer [==================================================>] 62.05MB/62.05MB
Loaded image: goharbor/harbor-jobservice:v2.13.0
26c6fb84ca6c: Loading layer [==================================================>] 8.665MB/8.665MB
4a3bd93bafd4: Loading layer [==================================================>] 4.096kB/4.096kB
410c3a1ef2e9: Loading layer [==================================================>] 18.22MB/18.22MB
53b60af6945a: Loading layer [==================================================>] 3.072kB/3.072kB
4818b20f0cba: Loading layer [==================================================>] 37.94MB/37.94MB
e4df98227fc6: Loading layer [==================================================>] 56.95MB/56.95MB
Loaded image: goharbor/harbor-registryctl:v2.13.0
9b35d04891ad: Loading layer [==================================================>] 16.73MB/16.73MB
1c869aa72f2e: Loading layer [==================================================>] 110.6MB/110.6MB
3d97927b9035: Loading layer [==================================================>] 3.072kB/3.072kB
fdb6e6094f6f: Loading layer [==================================================>] 59.9kB/59.9kB
2eab092d6e79: Loading layer [==================================================>] 61.95kB/61.95kB
Loaded image: goharbor/redis-photon:v2.13.0
a2ce174bdb10: Loading layer [==================================================>] 9.158MB/9.158MB
4adc2eaaef28: Loading layer [==================================================>] 4.096kB/4.096kB
5cf35bad98bc: Loading layer [==================================================>] 3.072kB/3.072kB
6aa111c0e634: Loading layer [==================================================>] 150.2MB/150.2MB
9b35ed6f2a08: Loading layer [==================================================>] 15.56MB/15.56MB
32034b7661fe: Loading layer [==================================================>] 166.6MB/166.6MB
Loaded image: goharbor/trivy-adapter-photon:v2.13.0
db88ef857466: Loading layer [==================================================>] 112.5MB/112.5MB
Loaded image: goharbor/nginx-photon:v2.13.0
6e268cafc739: Loading layer [==================================================>] 8.665MB/8.665MB
a2e97a249fd0: Loading layer [==================================================>] 4.096kB/4.096kB
0901ab91bc55: Loading layer [==================================================>] 3.072kB/3.072kB
50f38dcb8483: Loading layer [==================================================>] 18.22MB/18.22MB
ee1edf422bf9: Loading layer [==================================================>] 19.01MB/19.01MB
Loaded image: goharbor/registry-photon:v2.13.0
72fbfac481fb: Loading layer [==================================================>] 103.4MB/103.4MB
3fe27a92af0d: Loading layer [==================================================>] 48.34MB/48.34MB
64517d360781: Loading layer [==================================================>] 14.22MB/14.22MB
f3f3183409fd: Loading layer [==================================================>] 66.05kB/66.05kB
c08b9ad8fb5f: Loading layer [==================================================>] 2.56kB/2.56kB
eb80e088a551: Loading layer [==================================================>] 1.536kB/1.536kB
7baa5e1526a1: Loading layer [==================================================>] 12.29kB/12.29kB
8ee5ec881429: Loading layer [==================================================>] 3.412MB/3.412MB
1a7a322af2a1: Loading layer [==================================================>] 561.7kB/561.7kB
Loaded image: goharbor/prepare:v2.13.0
f07eaa0d2fe4: Loading layer [==================================================>] 112.5MB/112.5MB
d6cc50e9d17d: Loading layer [==================================================>] 6.835MB/6.835MB
56cce4852462: Loading layer [==================================================>] 252.9kB/252.9kB
9a0681ff3216: Loading layer [==================================================>] 1.539MB/1.539MB
Loaded image: goharbor/harbor-portal:v2.13.0
73a766bfd622: Loading layer [==================================================>] 11.62MB/11.62MB
60054c673e23: Loading layer [==================================================>] 3.584kB/3.584kB
50a9f5d3c46a: Loading layer [==================================================>] 2.56kB/2.56kB
3be02743af9a: Loading layer [==================================================>] 72.79MB/72.79MB
3d523d6680c1: Loading layer [==================================================>] 5.632kB/5.632kB
d0a9846c2224: Loading layer [==================================================>] 128kB/128kB
a1a1bb15583a: Loading layer [==================================================>] 209.9kB/209.9kB
705224c9382c: Loading layer [==================================================>] 73.92MB/73.92MB
a9ae194dbd47: Loading layer [==================================================>] 2.56kB/2.56kB
Loaded image: goharbor/harbor-core:v2.13.0
2f66f2193f14: Loading layer [==================================================>] 125.3MB/125.3MB
8f72c6b5c033: Loading layer [==================================================>] 3.584kB/3.584kB
075f0064255b: Loading layer [==================================================>] 3.072kB/3.072kB
69682d7bfa3e: Loading layer [==================================================>] 2.56kB/2.56kB
a3f75b6ab7bb: Loading layer [==================================================>] 3.072kB/3.072kB
ace7e62314e6: Loading layer [==================================================>] 3.584kB/3.584kB
c0ffef127b98: Loading layer [==================================================>] 20.48kB/20.48kB
Loaded image: goharbor/harbor-log:v2.13.0
0f653918fb04: Loading layer [==================================================>] 11.62MB/11.62MB
448770b89f7c: Loading layer [==================================================>] 38.16MB/38.16MB
149d5517f77b: Loading layer [==================================================>] 4.608kB/4.608kB
832349ff3d50: Loading layer [==================================================>] 38.95MB/38.95MB
Loaded image: goharbor/harbor-exporter:v2.13.0
[Step 3]: preparing environment ...
[Step 4]: preparing harbor configs ...
prepare base dir is set to /root/harbor
Generated configuration file: /config/portal/nginx.conf
Generated configuration file: /config/log/logrotate.conf
Generated configuration file: /config/log/rsyslog_docker.conf
Generated configuration file: /config/nginx/nginx.conf
Generated configuration file: /config/core/env
Generated configuration file: /config/core/app.conf
Generated configuration file: /config/registry/config.yml
Generated configuration file: /config/registryctl/env
Generated configuration file: /config/registryctl/config.yml
Generated configuration file: /config/db/env
Generated configuration file: /config/jobservice/env
Generated configuration file: /config/jobservice/config.yml
copy /data/secret/tls/harbor_internal_ca.crt to shared trust ca dir as name harbor_internal_ca.crt ...
ca file /hostfs/data/secret/tls/harbor_internal_ca.crt is not exist
copy to shared trust ca dir as name storage_ca_bundle.crt ...
copy None to shared trust ca dir as name redis_tls_ca.crt ...
Generated and saved secret to file: /data/secret/keys/secretkey
Successfully called func: create_root_cert
Generated configuration file: /compose_location/docker-compose.yml
Clean up the input dir
Note: stopping existing Harbor instance ...
[Step 5]: starting Harbor ...
[+] Running 10/10
✔ Network harbor_harbor Created 0.1s
✔ Container harbor-log Started 0.4s
✔ Container harbor-db Started 0.5s
✔ Container harbor-portal Started 0.6s
✔ Container redis Started 0.6s
✔ Container registryctl Started 0.6s
✔ Container registry Started 0.6s
✔ Container harbor-core Started 0.7s
✔ Container harbor-jobservice Started 0.8s
✔ Container nginx Started 0.8s
✔ ----Harbor has been installed and started successfully.----
После установки можно будет пройти по доменному имени, которое вы привязали к серверу, и залогиниться в Harbor.


Поздравляем! На этом моменте у вас есть полностью рабочее хранилище образов.
Хорошей идеей будет изменить пароль по умолчанию для административного пользователя и завести отдельных пользователей для каждой из задач.
Harbor внутри имеет множество репозиториев, каждый из которых называется «проектом». Создадим новый проект и назовем его air-gap.

Теперь нужно его наполнить образами.
Первое действие для этого — получить список необходимых образов. Для дистрибутивов на базе kubeadm это может быть сделано следующей командой:
$ kubeadm config images list
registry.k8s.io/kube-apiserver:v1.31.7
registry.k8s.io/kube-controller-manager:v1.31.7
registry.k8s.io/kube-scheduler:v1.31.7
registry.k8s.io/kube-proxy:v1.31.7
registry.k8s.io/coredns/coredns:v1.11.3
registry.k8s.io/pause:3.10
registry.k8s.io/etcd:3.5.15-0
Локально скачиваем все эти образа по одному.
docker pull registry.k8s.io/kube-apiserver:v1.31.7
docker pull registry.k8s.io/kube-controller-manager:v1.31.7
docker pull registry.k8s.io/kube-scheduler:v1.31.7
docker pull registry.k8s.io/kube-proxy:v1.31.7
docker pull registry.k8s.io/coredns/coredns:v1.11.3
docker pull registry.k8s.io/pause:3.10
docker pull registry.k8s.io/etcd:3.5.15-0
Перетегировать
docker tag registry.k8s.io/kube-apiserver:v1.31.7 registry.gecube.eu/air-gap/kube-apiserver:v1.31.7
docker tag registry.k8s.io/kube-controller-manager:v1.31.7 registry.gecube.eu/air-gap/kube-controller-manager:v1.31.7
docker tag registry.k8s.io/kube-scheduler:v1.31.7 registry.gecube.eu/air-gap/kube-scheduler:v1.31.7
docker tag registry.k8s.io/kube-proxy:v1.31.7 registry.gecube.eu/air-gap/kube-proxy:v1.31.7
docker tag registry.k8s.io/coredns/coredns:v1.11.3 registry.gecube.eu/air-gap/coredns:v1.11.3
docker tag registry.k8s.io/pause:3.10 registry.gecube.eu/air-gap/pause:3.10
docker tag registry.k8s.io/etcd:3.5.15-0 registry.gecube.eu/air-gap/etcd:3.5.15-0
Не забудьте перед тем, как загрузить образа в хранилище аутентифицироваться в нем при помощи команды
docker login registry.gecube.eu
Запушить в хранилище
docker push registry.gecube.eu/air-gap/kube-apiserver:v1.31.7
docker push registry.gecube.eu/air-gap/kube-controller-manager:v1.31.7
docker push registry.gecube.eu/air-gap/kube-scheduler:v1.31.7
docker push registry.gecube.eu/air-gap/kube-proxy:v1.31.7
docker push registry.gecube.eu/air-gap/coredns:v1.11.3
docker push registry.gecube.eu/air-gap/pause:3.10
docker push registry.gecube.eu/air-gap/etcd:3.5.15-0
Так как скачивание происходит из интернета, то удобно это делать на ноутбуке администратора. Который или подключён к двум сетям одновременно (публичной и внутренней). Или произвести переключение - переключиться на публичную сеть, скачать образа, потом переключиться во внутреннюю и уже загрузить образа в хранилище.
Дальше при инициализации кластера необходимо передать правильные имена образов. Для kubeadm установки это делается в двух местах:
в настройках containerd демона необходимо указать наш pause образ:
root@ubuntu-16gb-hel1-1:~# cat /etc/containerd/config.toml
...
[plugins]
[plugins."io.containerd.gc.v1.scheduler"]
deletion_threshold = 0
mutation_threshold = 100
pause_threshold = 0.02
schedule_delay = "0s"
startup_delay = "100ms"
[plugins."io.containerd.grpc.v1.cri"]
device_ownership_from_security_context = false
disable_apparmor = false
disable_cgroup = false
disable_hugetlb_controller = true
disable_proc_mount = false
disable_tcp_service = true
enable_selinux = false
enable_tls_streaming = false
enable_unprivileged_icmp = false
enable_unprivileged_ports = false
endpoint = "unix:///var/run/containerd/containerd.sock"
ignore_image_defined_volumes = false
max_concurrent_downloads = 3
max_container_log_line_size = 16384
netns_mounts_under_state_dir = false
restrict_oom_score_adj = false
sandbox_image = "registry.gecube.eu/air-gap/pause:3.10"
selinux_category_range = 1024
stats_collect_period = 10
stream_idle_timeout = "4h0m0s"
stream_server_address = "127.0.0.1"
stream_server_port = "0"
systemd_cgroup = false
tolerate_missing_hugetlb_controller = true
unset_seccomp_profile = ""
...
Нас интересует ключ sandbox_image в конфигурационном файле /etc/containerd/config.toml. После изменения файла необходимо не забыть перезапустить демон containerd командой systemctl restart containerd (или аналогичной)
в файле конфигурации kubeadm в секции ClusterConfiguration необходимо указать параметр ImageRepository
root@ubuntu-16gb-hel1-1:~# cat kubeadm-config.yaml
kind: ClusterConfiguration
apiVersion: kubeadm.k8s.io/v1beta3
kubernetesVersion: v1.28.2
networking:
serviceSubnet: 100.65.0.0/16
podSubnet: 100.64.0.0/16
apiServer:
certSANs:
- "127.0.0.1"
- "localhost"
- "10.0.0.17"
- "212.233.76.0"
imageRepository: "registry.gecube.eu"
---
kind: KubeletConfiguration
apiVersion: kubelet.config.k8s.io/v1beta1
cgroupDriver: systemd
Далее при разворачивании кластера можно применить этот конфигурационный файл через ключ --config. В остальном установка точно такая же, как и не в air-gap среде.
kubeadm init --config kubeadm-config.yaml --upload-certs -v=5 --skip-phases=addon
Поздравляем! Теперь вы видите насколько просто можно произвести air gapped установку. Дальнейшие действия могут быть установкой CNI (так же с зеркалированием образов в наш репозиторий), установка дополнительных компонентов кластера таких как мониторинг, логирование, ingress контроллеры и прочее прочее.
Дополнительно обращаю ваше внимание, что своё хранилище образов это всегда хорошо, так как:
это позволяет не зависеть от внешних зависимостей вроде докерхаба. Уже были истории с блокировками Докерхаба в РФ.
докерхаб ввел лимиты на скачивание образов — 100 образов за 6 часов. Это ещё раз доказывает, что ставить работоспособность своей системы (в частности, такой динамичной системы как любой кластер k8s) в зависимость от внешних ресурсов — очень плохая идея.
можно сэкономить много трафика, так как каждый перезапуск пода фактически может приводить к перекачиванию образа, а образы иногда бывают достаточно большими (например, если речь про ML — скажем, 5ГБ)
это повышает безопасность, так как есть возможность запуска только известных образов
нет риска того, что образ был подменён (умышленно или случайно) в исходном хранилище (например, на докерхабе — такие прецеденты уже бывали)
Kubernetes Мега — продвинутый курс по k8s от учебного центра Слёрм. За 7 недель обучим глубокими практическим навыкам управления и масштабирования контейнерных приложений с использованием Kubernetes в производственной среде. Для тех, кто уже работал с k8s или прошел Kubernetes База