В прошлой статье я рассказывал, как безопасники отключили интернет на учебном стенде, а мне нужно было провести курс для ста преподавателей. Тогда я выкрутился с помощью офлайн-образа виртуальной машины, где все необходимое уже внутри. Рассказываю, как сделать такой образ.

Оговорка: в боевом образе для студентов были еще 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-чарты, предзагруженные системные образы для кластера, дополнительные утилиты. Но фундамент — тот же самый:

  1. Скачали все что нужно;

  2. Инжектировали в образ через virt-customize;

  3. Настроили скрипт первого запуска, который соберет все воедино;

  4. Запечатали сетевой контур;

  5. Проверили, что ничего не просится наружу.

Если вы готовите учебные стенды, лабораторные среды или демо-образы для закрытых контуров — не надейтесь на интернет, запечатайте зависимости заранее. Потратите пару часов на подготовку, но избежите ситуации, когда сто человек сидят перед черным экраном, потому что docker pull уходит в таймаут.

Комментарии (4)


  1. DikSoft
    15.05.2026 13:58

    Подскажите, а локальный NEXUS Sonatype Free для учебного класса как вариант не рассматривали?


  1. lifespirit
    15.05.2026 13:58

    Пара советов:

    1. Лучше ставить не docker а podman. Есть в обычном репе без шаманства.

    2. Если уж ставить podman то можно выдать доступ не от рута, тогда каждый пользователь сможет запускать контейнеры на своём uid:gid диапазоне и (что важно) в своём home

    3. Просто запуск nginx не очень объясняет цель такой виртуалки. Возможно вообще можно обойтись даже не podman а systemd-nspawn.

    4. Сеть в podman так же изолируется из коробки:

      podman network create \
        --driver bridge \
        --subnet 10.89.10.0/24 \
        --internal \
        isolated_net

      По идее можно просто заменить default сеть

    Зачем локальные пакеты nginx если он запускается в контейнере так и не понял.


  1. remzalp
    15.05.2026 13:58

    а чем не подходит "docker image save"?


  1. 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   -