В прошлой статье я рассказывал, как безопасники отключили интернет на учебном стенде, а мне нужно было провести курс для ста преподавателей. Тогда я выкрутился с помощью офлайн-образа виртуальной машины, где все необходимое уже внутри. Рассказываю, как сделать такой образ.
Оговорка: в боевом образе для студентов были еще Kubernetes и Helm — полный набор для прохождения курса. Здесь я покажу фундамент: как собрать автономную DevOps-среду с Docker на борту. Масштаб немного другой, но принцип одинаковый. Если вы поймете, как запечатать Docker, то K8s и Helm — вопрос количества артефактов, а не подхода.

Идея: образ-консерва
Задача звучит просто: взять чистый образ Linux, засунуть туда Docker, контейнерные образы, скрипты настройки — и сделать так, чтобы при первом запуске все заработало без единого обращения в сеть. Как консервная банка: открыл — и готово.
На практике есть три проблемы. Первая — Docker при установке из репозитория хочет в интернет. Вторая — при запуске контейнеров Docker тянет образы из Docker Hub. Третья, неочевидная — даже в офлайне Docker создает сетевые мосты, и если не настроить маршрутизацию правильно, контейнеры просто не запустятся.
Часть 1. Собираем рабочее место
Нам нужна виртуальная машина — билдер — место, где мы соберем образ. Я использовал Proxmox, но подойдет любой гипервизор. Главное — билдер должен иметь доступ в интернет (на этапе сборки он нам нужен), а вот финальный образ будет работать без него.
Создаем ВМ
$ qm create 100 --name image-builder \ --net0 virtio,bridge=vmbr0 \ --memory 4096 \ --cores 4 \ --ostype l26 \ --cpu host \ --onboot no
Скачиваем основу
Берем облачный образ Debian 12 — легкий, чистый, без лишнего:
$ wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2

Облачный образ весит около 400 МБ, а диск в нем — пару гигабайт. Для нашей сборки этого мало, расширяем до 20 ГБ:
$ qemu-img resize debian-12-generic-amd64.qcow2 20G
Подключаем диск к ВМ
Импортируем скачанный образ как диск виртуальной машины. Название хранилища у вас может отличаться — у меня local-zfs, у вас может быть local-lvm или что-то еще:
$ qm importdisk 100 debian-12-generic-amd64.qcow2 local-zfs
Диск импортирован, но еще не подключен к ВМ. Привязываем его к контроллеру и делаем загрузочным:
$ qm set 100 --scsihw virtio-scsi-pci --scsi0 local-zfs:vm-100-disk-0
$ qm set 100 --boot c --bootdisk scsi0
Добавляем виртуальный CD-привод для cloud-init — через него зададим пароль и сеть:
$ qm set 100 --ide2 local-zfs:cloudinit

Настраиваем доступ
Облачные образы по умолчанию не имеют пароля — подразумевается вход только по ключам. Для работы в консоли зададим пароль и включим DHCP:
$ qm set 100 --cipassword superpass --ciuser root
$ qm set 100 --ipconfig0 ip=dhcp
Запускаем и подключаемся
$ qm start 100
$ qm status 100

Подождем, пока ВМ загрузится и сконфигурирует заданный нами пароль. Спустя минуту переходим в веб-интерфейс Proxmox, выбираем нашу ВМ и вкладку «Console» (логин root, пароль — тот, что задали выше):

Если в веб-интерфейсе Proxmox работать неудобно, настроим SSH. Редактируем /etc/ssh/sshd_config:
PermitRootLogin yes
PasswordAuthentication yes

Перезапускаем SSH и узнаем IP-адрес, который получила наша ВМ:
$ systemctl restart ssh
$ ip addr show eth0

С рабочей машины копируем публичный ключ:
$ ssh-copy-id root@192.168.30.12

Теперь в конфиге виртуальной машины можно отключить парольную аутентификацию:

Проверяем, что подключение по ключу работает:
$ ssh root@192.168.30.12

Удобство обеспечено, можно приступать к сборке.
Ставим инструменты
Делаем все одной командой:
$ apt update && apt install -y qemu-utils libguestfs-tools docker.io zstd curl tar tcpdump iptables nfs-common wget

Здесь два ключевых пакета: libguestfs‑tools (позволит модифицировать образ без запуска ВМ) и docker.io (понадобится, чтобы скачать и сохранить контейнерные образы).
(Опционально) NFS для тяжелых файлов
Если не хотите забивать локальный диск — подключите сетевую шару (в нашем примере это nas). Работать с тяжелыми образами по внутренней сети быстрее.
Создадим папĸу монтирования:
$ mkdir -p /mnt/nas
Подĸлючим сетевую папĸу (здесь я использую ip моего nas из другой сети):
$ mount -t nfs 192.168.40.10:/volume1/rapax /mnt/nas
Перейдем в рабочую диреĸторию проеĸта:
$ mkdir -p /mnt/nas/offline-image && cd /mnt/nas/offline-image

Скачиваем артефакты
Теперь самое важное — собираем все, что понадобится в офлайне. Это три вещи: чистый образ ОС (болванка для кастомизации), deb-пакеты Docker (чтобы установить его без интернета) и контейнерный образ для проверки.
Болванка:
$ wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2 -O offline_base.qcow2

Пакеты Docker:
$ mkdir -p ./docker_packages && cd ./docker_packages
$ apt-get download docker.io containerd runc

Команда apt-get download скачивает deb-файлы, не устанавливая их. Именно эти файлы мы потом инжектируем в образ.
Подготовим тестовый образ nginx в виде архива:
$ docker pull nginx:latest
$ docker save nginx:latest -o nginx_offline.tar

docker save — ключевая команда. Она сохраняет образ со всеми слоями в tar-архив. Потом, в офлайне, docker load прочитает этот архив и восстановит образ без обращения к Docker Hub.
К этому моменту у нас есть три артефакта:
offline_base.qcow2 (основа для кастомизации),
docker_packages/ (deb-пакеты),
nginx_offline.tar (контейнерный образ для финального теста).

Часть 2. Кастомизация образа
Здесь начинается самое интересное. Нужно взять чистую болванку Debian и, не запуская ее, засунуть внутрь Docker, контейнерные образы и скрипт, который все настроит при первом запуске.
Расширяем диск
Базовый образ слишком мал для Docker-контейнеров. Добавляем 15 ГБ:
$ qemu-img resize offline_base.qcow2 +15G

Скрипт автоматизации
Дальше нам нужен скрипт, который virt-customize — утилиту из пакета libguestfs-tools — выполнит внутри образа. Скрипт установит Docker из локальных пакетов и создаст systemd-сервис для первого запуска:
$ nano offline_setup.sh #!/bin/bash # Установка Docker из локальных пакетов dpkg -i /opt/offline/docker_packages/*.deb # Включаем Docker-демон systemctl enable docker # Создаем скрипт, который отработает один раз при первом запуске cat <<'BOOT_EOF' > /usr/local/bin/first_boot_logic.sh #!/bin/bash # Включаем пересылку трафика в ядре. Без этого Docker не сможет маршрутизировать пакеты между контейнерами, даже в офлайне sysctl -w net.ipv4.ip_forward=1 # Отключаем Spanning Tree Protocol на мосте docker0. В изолированной среде STP не нужен, а его включение может задерживать поднятие сетевого моста на 30 секунд ip link set docker0 type bridge stp_state 0 2>/dev/null || true # Настройка файрвола: iptables -P INPUT DROP iptables -P FORWARD DROP iptables -P OUTPUT ACCEPT iptables -A INPUT -i lo -j ACCEPT iptables -A INPUT -i docker0 -j ACCEPT iptables -A INPUT -p tcp --dport 80 -j ACCEPT iptables -A FORWARD -i docker0 -o docker0 -j ACCEPT iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # Запечатываем контур. Блокируем любые новые исходящие соединения во внешний мир iptables -A OUTPUT -o ens18 -m conntrack --ctstate NEW -j DROP # Загружаем офлайн-образ nginx sleep 5 docker load -i /opt/offline/nginx_offline.tar # Сервис отработал — убираем systemctl disable first-boot.service BOOT_EOF chmod +x /usr/local/bin/first_boot_logic.sh # Создание Unit-файла для Systemd cat <<'UNIT_EOF' > /etc/systemd/system/first-boot.service [Unit] Description=Offline Environment Initialization After=docker.service [Service] Type=oneshot ExecStart=/usr/local/bin/first_boot_logic.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target UNIT_EOF systemctl enable first-boot.service
Разберу логику файрвола отдельно: мы разрешаем Docker-контейнерам работать внутри виртуальной машины (мост docker0), но при этом запрещаем любые исходящие соединения через физический интерфейс ens18. Получается запечатанный контур: контейнеры живут и общаются друг с другом, но во внешний мир ни один пакет не уйдет.
Инжектируем все в образ
Тут начинает работать virt-customize: утилита монтирует образ диска, закидывает внутрь файлы, выполняет скрипты и отмонтирует обратно. Все это без запуска ВМ — чистая работа с файловой системой:
$ virt-customize -a offline_base.qcow2 \ --mkdir /opt/offline \ --copy-in ./docker_packages:/opt/offline/ \ --upload ./nginx_offline.tar:/opt/offline/ \ --upload ./offline_setup.sh:/usr/local/bin/offline_setup.sh \ --run-command "bash /usr/local/bin/offline_setup.sh" \ --root-password password:student \ --hostname offline-devops-box
Внутри команды шесть действий: создать директорию, скопировать пакеты Docker, закинуть tar-архив с образом nginx, загрузить скрипт настройки, выполнить его и задать пароль. На выходе — образ, готовый к офлайн-работе.

Оптимизируем размер
Образ после кастомизации содержит пустые блоки от удаленных временных файлов. virt-sparsify убирает «воздух» и сжимает результат:
$ virt-sparsify --compress offline_base.qcow2 offline_base_optimized.qcow2

Проверяем содержимое
Самое время проверить, что образ внутри не содержит непредвиденных ошибок — особенно если вы будете передавать его другим людям (студентам, администраторам, техподдержке). Убедимся, что скрипт для службы первого запуска добавлен без ошибок:
$ virt-cat -a offline_base_optimized.qcow2 /usr/local/bin/first_boot_logic.sh

Ну, и напоследок — проверим, на месте ли подготовленные Docker-образы (директорию мы указывали в скрипте для службы первого запуска):
$ virt-ls -a offline_base_optimized.qcow2 /opt/offline/

Архивируем для передачи
Сжимаем образ для отправки. Алгоритм Zstandard на максимальных настройках выжимает из qcow2 все возможное:
$ zstd -19 --long -T0 offline_base_optimized.qcow2 -o offline_base_optimized.qcow2.zst
Флаг -T0 задействует все ядра процессора, --long включает расширенное окно поиска повторов, а -19 — почти максимальный уровень сжатия. Получается файл, который удобно передать техподдержке или залить на стенд.

Часть 3. Тестируем в офлайне
Образ собран, но работает ли он? Создаем проверочную виртуальную машину, которая имитирует среду, например студента.
Создаем изолированную ВМ
$ qm create 101 --name offline-student-pc \ --memory 2048 \ --cores 2 \ --cpu host \ --net0 virtio,bridge=vmbr0 \ --bios ovmf
Импортируем собранный образ:
$ qm importdisk 101 /mnt/pve/rapax/offline-image/offline_base_optimized.qcow2 local-zfs

Привязываем диск и настраиваем загрузку:
$ qm set 101 --scsihw virtio-scsi-pci --scsi0 local-zfs:vm-101-disk-0
$ qm set 101 --boot c --bootdisk scsi0

Отключаем сеть на уровне гипервизора
В скрипте первого запуска мы уже запретили исходящие соединения через iptables. Но для чистоты эксперимента отключим сетевой интерфейс еще и на уровне Proxmox — так, будто сетевой кабель вынут:
Datacenter → VM 101 → Hardware → Network Device → Edit → Disconnected ✓

Двойная изоляция: программная (iptables) и аппаратная (отключенный интерфейс). Даже если в скрипте что-то пойдет не так, ни один пакет не уйдет наружу.
Запускаем и проверяем
Запускаем виртуальную машину, ждем минуту-две (скрипт первого запуска должен отработать) и логинимся: root / student.

Проверяем интерфейсы:
$ ip address show

Должны увидеть loopback, физический интерфейс (без IP, потому что отключен) и мост docker0.
Проверяем, что сеть действительно молчит:
$ tcpdump -i any

Запускаем nginx из предзагруженного образа:
$ docker run -d -p 80:80 nginx

Обратите внимание на сетевые политики: трафик ограничен только внутренней работой Docker-сети.
Проверяем:
$ curl localhost

Если в ответ пришла стандартная страница nginx — образ работает. Docker поднял контейнер из локального tar-архива, создал сеть, прокинул порт — и все это без выхода в интернет.
Для полноты картины можно попробовать достучаться наружу — убедиться, что не получится:

При запущенных контейнерах поднимаются только сетевые интерфейсы, обслуживающие Docker-сеть:

При остановке контейнеров контейнерные сети переходят в состояние DOWN:

Образ ведет себя ровно так, как задумано.
Что дальше
Этот гайд показывает базовый сценарий — один контейнерный образ, один инструмент. Боевой образ для нашего курса устроен сложнее: там Kubernetes (K3s), Helm-чарты, предзагруженные системные образы для кластера, дополнительные утилиты. Но фундамент — тот же самый:
Скачали все что нужно;
Инжектировали в образ через virt-customize;
Настроили скрипт первого запуска, который соберет все воедино;
Запечатали сетевой контур;
Проверили, что ничего не просится наружу.
Если вы готовите учебные стенды, лабораторные среды или демо-образы для закрытых контуров — не надейтесь на интернет, запечатайте зависимости заранее. Потратите пару часов на подготовку, но избежите ситуации, когда сто человек сидят перед черным экраном, потому что docker pull уходит в таймаут.
Комментарии (4)

lifespirit
15.05.2026 13:58Пара советов:
Лучше ставить не docker а podman. Есть в обычном репе без шаманства.
Если уж ставить podman то можно выдать доступ не от рута, тогда каждый пользователь сможет запускать контейнеры на своём uid:gid диапазоне и (что важно) в своём home
Просто запуск nginx не очень объясняет цель такой виртуалки. Возможно вообще можно обойтись даже не podman а systemd-nspawn.
-
Сеть в podman так же изолируется из коробки:
podman network create \ --driver bridge \ --subnet 10.89.10.0/24 \ --internal \ isolated_netПо идее можно просто заменить default сеть
Зачем локальные пакеты nginx если он запускается в контейнере так и не понял.

MrBotikkk
15.05.2026 13:58Базовый образ слишком мал для Docker-контейнеров. Добавляем 15 ГБ:
$ qemu-img resize offline_base.qcow2 +15GНу и как сделать
expand the partition /dev/sda1? Не увидел решения в статье.LC_ALL=C virt-filesystems -a offline_base.qcow2 --all --long -h Name Type VFS Label MBR Size Parent /dev/sda1 filesystem ext4 - - 2.8G - /dev/sda14 filesystem unknown - - 3.0M - /dev/sda15 filesystem vfat - - 124M - /dev/sda1 partition - - - 2.9G /dev/sda /dev/sda14 partition - - - 3.0M /dev/sda /dev/sda15 partition - - - 124M /dev/sda /dev/sda device - - - 3.0G - ### qemu-img resize offline_base.qcow2 +15G ### LC_ALL=C virt-filesystems -a offline_base.qcow2 --all --long -h Name Type VFS Label MBR Size Parent /dev/sda1 filesystem ext4 - - 2.8G - /dev/sda14 filesystem unknown - - 3.0M - /dev/sda15 filesystem vfat - - 124M - /dev/sda1 partition - - - 2.9G /dev/sda /dev/sda14 partition - - - 3.0M /dev/sda /dev/sda15 partition - - - 124M /dev/sda /dev/sda device - - - 18G -
DikSoft
Подскажите, а локальный NEXUS Sonatype Free для учебного класса как вариант не рассматривали?