Погодин Евгений — ИТ Заметки
🐧Linux Обновлено 28.10.2026

Linux для админа Windows 10. Docker для начинающих: контейнеры, тома, compose

Note

Материал сверен с официальной документацией (Ubuntu Server, man-страницы и документация проектов; ссылки — в конце статьи). Примеры вывода — иллюстрации: на вашем сервере значения и формат могут отличаться. Перед работой на боевом сервере проверьте команды на тестовой машине.

Чтобы изолировать приложение в Windows, мы создаём виртуальную машину (ВМ) Hyper-V: у неё свой диск, своё ядро и полная ОС. Docker решает ту же задачу легче: приложение запускается в изолированном процессе, который называется контейнером, и использует ядро самого сервера (в Docker его называют хостом). Поэтому на одном сервере помещается больше приложений, чем виртуальных машин. В статье мы:

  • установим Docker Engine на Ubuntu Server по инструкции Docker;
  • запустим первый контейнер и разберём основные команды;
  • научимся хранить данные так, чтобы они не пропадали вместе с контейнером;
  • опишем приложение из двух контейнеров в файле compose.yaml;
  • разберём безопасность: именно здесь у новичков бывают самые неприятные сюрпризы.

Речь только о контейнерах Linux. Сопоставим понятия:

Hyper-V Docker
Виртуальная машина: своё ядро и полная ОС Контейнер: изолированный процесс на ядре хоста
Диск ВМ или шаблон, из которого создают машины Образ (image): неизменяемый шаблон из слоёв
Хранилище шаблонов Реестр (registry), по умолчанию Docker Hub
Виртуальный коммутатор Сеть Docker (bridge)
Виртуальный диск, подключённый к ВМ Том (volume) или каталог хоста (bind mount)
Описание стенда, из которого создаются несколько ВМ (вы описываете результат, а не шаги) Файл compose.yaml

Что нужно знать заранее

Контейнер, образ, реестр

Образ — неизменяемый набор файлов: программа, библиотеки, настройки. Он состоит из слоёв, каждый из которых описывает изменения файловой системы. Контейнер — запущенный экземпляр образа со своим изменяемым слоем поверх него. Из одного образа можно запустить много контейнеров.

Образы хранятся в реестре; по умолчанию это Docker Hub. Имя образа выглядит так: nginx:1.30-alpine, то есть репозиторий и тег (версия или вариант). Если тег не указан, Docker берёт latest. Использовать его не стоит: через полгода тот же запуск принесёт другую версию программы.

Docker устроен как клиент и сервер. Команда docker — клиент, а демон dockerd (фоновая служба) работает от имени root и делает всю работу. Клиент общается с ним через сокет (специальный файл для связи программ) /var/run/docker.sock: к нему имеют доступ root и члены группы docker.

Docker Engine — сервер для Linux, его мы и ставим. Docker Desktop — отдельное приложение для рабочих станций.

Установка Docker Engine на Ubuntu

Ставим по инструкции docs.docker.com. Она поддерживает, в том числе, выпуски с долгой поддержкой (LTS) 24.04 и 26.04.

Warning

Прочитайте раздел о безопасности ниже до первого запуска. Docker меняет правила брандмауэра, и ufw не защищает опубликованные порты контейнеров.

Сначала удалите конфликтующие пакеты. В репозитории Ubuntu есть свой docker.io; Docker относит его и ещё несколько пакетов (в том числе docker-compose и docker-compose-v2) к неофициальным и просит удалить перед установкой. Не смешивайте два источника.

sudo apt remove $(dpkg --get-selections docker.io \
  docker-compose docker-compose-v2 docker-doc docker-buildx \
  podman-docker containerd runc | cut -f1)

apt может сообщить, что этих пакетов у вас нет: это нормально. Затем подключаем репозиторий Docker: ключ кладём в /etc/apt/keyrings, источник описываем в формате deb822 (устройство источников и ключей — в статье про apt):

sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update

Устанавливаем пакеты: docker-ce — демон, docker-ce-cli — команда docker, containerd.io — среда запуска контейнеров, docker-buildx-plugin — сборка образов, docker-compose-plugin — команда docker compose.

sudo apt install docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl status docker
sudo docker run hello-world

Образ hello-world скачивается, печатает приветствие и завершается. Пример вывода (начало):

Hello from Docker!
This message shows that your installation appears to be working correctly.

Обновляется Docker обычным путём через apt. Есть и «удобный скрипт» get.docker.com, но сам Docker рекомендует его только для тестовых сред и просит читать скачанные скрипты перед запуском. Не запускайте его на боевом сервере, не прочитав.

Первые команды

В примерах мы публикуем порты на 8081: порт 127.0.0.1:8080 мог остаться занятым учебным сайтом backend из статьи про nginx, и контейнер на нём не запустится. Запустим nginx в контейнере:

sudo docker run -d --name web \
  -p 127.0.0.1:8081:80 nginx:1.30-alpine

Эту конструкцию вы будете писать чаще всего:

Опция Значение
-d Запустить в фоне и вывести идентификатор контейнера
--name web Своё имя вместо случайного
-p 127.0.0.1:8081:80 Порт 8081 хоста на адресе 127.0.0.1 соединить с портом 80 контейнера
-e ИМЯ=значение Передать переменную окружения
-v, --mount Подключить том или каталог хоста
--restart unless-stopped Политика перезапуска
--rm Удалить контейнер после завершения
-i -t Интерактивный сеанс с терминалом

Адрес 127.0.0.1 перед портом — это не украшение, а защита; подробности ниже. Проверяем:

sudo docker ps
curl -I http://127.0.0.1:8081

Пример вывода docker ps (часть столбцов опущена):

CONTAINER ID   IMAGE               STATUS          PORTS
2f6a1c0d9e11   nginx:1.30-alpine   Up 12 seconds   127.0.0.1:8081->80/tcp
Команда Что делает
docker ps -a Все контейнеры, включая остановленные
docker logs -f --tail 20 web Последние строки вывода контейнера и слежение за ним
docker exec -it web sh Запустить в работающем контейнере оболочку
docker stop web, docker start web Остановить (сигнал SIGTERM, затем SIGKILL) и снова запустить
docker rm web Удалить остановленный контейнер
docker images, docker pull Список образов; скачать образ

Контейнер одноразовый: всё, что приложение записало внутрь него, исчезает при docker rm. Данные, которые нужно сохранить, хранят в томах.

Политика --restart решает, что делать, когда контейнер завершился. no (по умолчанию) не перезапускает; on-failure перезапускает после ошибки; always и unless-stopped перезапускают всегда, но unless-stopped не поднимает контейнер после перезапуска демона, если вы остановили его вручную. Для служб обычно берут unless-stopped.

Остановим и удалим контейнер: sudo docker stop web && sudo docker rm web.

Тома и каталоги хоста

Контейнер может хранить файлы в трёх местах:

  • слой контейнера исчезает вместе с контейнером;
  • том (volume) — каталог под управлением Docker, живёт отдельно от контейнеров. На хосте он лежит в /var/lib/docker/volumes/<имя>/_data;
  • bind mount — каталог или файл хоста, подключённый в контейнер.

Документация считает тома предпочтительным способом хранить данные: их проще копировать и переносить. Bind mount нужен, когда файлы должны быть доступны и хосту, например конфигурация или каталог сайта. Покажем, что данные в томе переживают контейнеры (в учебных командах с alpine тег опущен ради краткости):

admin-lab@lnx1:~$ sudo docker volume create app-data
admin-lab@lnx1:~$ sudo docker run --rm -v app-data:/data alpine \
  sh -c 'echo saved > /data/test.txt'
admin-lab@lnx1:~$ sudo docker run --rm -v app-data:/data alpine \
  cat /data/test.txt
saved

Оба контейнера удалены (--rm), а файл остался в томе app-data. Тома показывают docker volume ls и docker volume inspect. Теперь каталог сайта с хоста, подключённый только для чтения (:ro):

sudo mkdir -p /srv/site
echo '<h1>Hello from a bind mount</h1>' | sudo tee /srv/site/index.html
sudo docker run -d --name web -p 127.0.0.1:8081:80 \
  -v /srv/site:/usr/share/nginx/html:ro nginx:1.30-alpine

Каталог /usr/share/nginx/html — то место, откуда образ nginx отдаёт страницы. Что важно знать про bind mount:

  • по умолчанию контейнер может писать в подключённый каталог хоста: менять и удалять там файлы. Для неизменяемых данных добавляйте :ro;
  • права проверяет ядро хоста по числовым UID и GID, а не по именам: имя пользователя внутри контейнера не важно (в образе nginx рабочие процессы запускаются с UID 101);
  • подключённый каталог закрывает собой то, что лежало в этом месте образа;
  • если пути на хосте нет, -v молча создаст каталог, даже когда вы имели в виду файл. Опция --mount type=bind,... в этом случае выдаст ошибку.

Удалите контейнер командой sudo docker rm -f web: опция -f позволяет удалить и работающий.

Сети

Контейнеры подключаются к виртуальным сетям внутри сервера (тип bridge — аналог виртуального коммутатора). Из внешней сети в контейнер можно попасть только через опубликованный порт (-p). Compose создаёт для проекта отдельную сеть, где службы находят друг друга по имени и обычному порту приложения; публиковать порты для этого не нужно. У каждого контейнера свой сетевой стек, поэтому localhost внутри него означает сам этот контейнер; службу на хосте вызывают по адресу хоста.

Docker Compose: приложение из двух контейнеров

Длинные команды docker run неудобно хранить и повторять. Docker Compose описывает приложение в файле compose.yaml: какие службы (services) запускать, из каких образов, с какими портами и томами. Файл можно хранить в системе контроля версий, а запускать одной командой. Это описание стенда, по которому его разворачивают заново.

Вызывайте docker compose (через пробел: это плагин docker-compose-plugin). Не путайте его со старой программой docker-compose через дефис.

Сделаем приложение из двух контейнеров: web (nginx в роли обратного прокси) и app (простой веб-сервер на Python в роли «приложения»). Наружу, и только на 127.0.0.1, опубликован один web.

mkdir -p ~/lab-app/nginx ~/lab-app/app
cd ~/lab-app
echo '<h1>Hello from the app</h1>' > app/index.html
nano nginx/default.conf

Содержимое nginx/default.conf:

server {
    listen 80;

    location / {
        proxy_pass http://app:8000;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Адрес app здесь — это имя службы из compose.yaml. Создайте файл compose.yaml (nano compose.yaml) в ~/lab-app:

services:
  web:
    image: nginx:1.30-alpine
    ports:
      - "127.0.0.1:8081:80"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_started
        restart: true
    restart: unless-stopped

  app:
    image: python:3.13-alpine
    command: ["python", "-m", "http.server", "8000", "--directory", "/srv/app"]
    volumes:
      - ./app:/srv/app:ro
    restart: unless-stopped
  • ports — строка в кавычках, адрес 127.0.0.1 указан явно. Без него порт будет опубликован на всех интерфейсах;
  • у app нет ports: до него можно дойти только по сети Compose, по имени app и порту 8000;
  • depends_on задаёт порядок: app создаётся раньше web. Готовности app Compose не ждёт: service_started гарантирует лишь запуск контейнера. Строку restart: true разберём в разделе об обновлении;
  • пути в volumes начинаются с ./: так Compose отличает каталог хоста от имени тома;
  • версии образов закреплены до ветки (3.13, 1.30): исправления внутри ветки придут при docker compose pull. Для полной фиксации указывайте полный тег и меняйте его сами.

Запускаем из каталога ~/lab-app:

sudo docker compose config
sudo docker compose up -d
sudo docker compose ps

docker compose config проверяет файл и показывает итоговую конфигурацию. Пример вывода docker compose ps (часть столбцов опущена):

NAME            SERVICE   STATUS         PORTS
lab-app-app-1   app       Up 5 seconds
lab-app-web-1   web       Up 5 seconds   127.0.0.1:8081->80/tcp

Имя проекта (lab-app) Compose берёт из имени каталога. Проверка:

admin-lab@lnx1:~/lab-app$ curl http://127.0.0.1:8081/
<h1>Hello from the app</h1>
Команда Что делает
docker compose up -d Создать и запустить службы; изменившиеся пересоздаются, тома сохраняются
docker compose logs -f --tail 20 web Журнал службы
docker compose exec web nginx -s reload Выполнить команду в работающей службе
docker compose pull Скачать новые версии образов
docker compose down Удалить контейнеры и сеть проекта

down не удаляет именованные тома: данные остаются. Опция down -v удалит и тома, и данные.

Чтобы показать приложение сети, на хосте настраивают nginx, как в статье про nginx. Контейнер остаётся доступным только через него:

server {
    listen 80;
    server_name lab-app.lab.example;

    location / {
        proxy_pass http://127.0.0.1:8081;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Когда закончите, уберите стенд: он перезапускается вместе с сервером (restart: unless-stopped) и занимает порт 8081. Если вы включали сайт на хостовом nginx, отключите и его (sudo rm ссылки в sites-enabled, затем nginx -t и reload).

cd ~/lab-app
sudo docker compose down
sudo docker volume rm app-data
sudo rm -r /srv/site

Обновление образов и очистка

Образ не обновляется сам, и работающие контейнеры продолжают использовать старый. Обновление: sudo docker compose pull, затем sudo docker compose up -d; пересоздаются те службы, у которых образ изменился.

Если обновился только app, он получает новый IP-адрес, а имя службы остаётся прежним. nginx разрешает имя из proxy_pass при чтении конфигурации (при запуске и при reload), поэтому web продолжил бы ходить на старый адрес и отвечать 502. Параметр restart: true в depends_on заставляет Compose перезапускать web после обновления app. Без него выполните sudo docker compose exec web nginx -s reload или sudo docker compose restart web.

Старые образы занимают место. Сколько занято образами, контейнерами и томами, показывает sudo docker system df. Освободить место помогают:

  • sudo docker image prune удаляет «висячие» образы (без тега); с опцией -a — все образы, не используемые ни одним контейнером;
  • sudo docker system prune удаляет остановленные контейнеры, неиспользуемые сети, «висячие» образы и кэш сборки; с --volumes затрагивает и анонимные тома.

Обе команды спрашивают подтверждение: читайте список и не добавляйте -f, пока не поймёте последствия.

Вторая «утечка» места — журналы. Docker по умолчанию пишет вывод контейнеров в файлы JSON без ротации, и разговорчивый контейнер может заполнить диск. Ограничить размер можно в /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Перед перезапуском проверьте файл: sudo dockerd --validate --config-file=/etc/docker/daemon.json (при успехе команда отвечает configuration OK). Ошибка в JSON не даст демону запуститься, и все контейнеры останутся остановленными. Затем перезапустите службу в окно обслуживания: sudo systemctl restart docker. Настройки действуют только на вновь созданные контейнеры.

Безопасность: что нужно знать до первого запуска

Группа docker — это root

Демон работает от root. Кто может им управлять, тот может запустить контейнер с подключённым корнем файловой системы хоста и менять там что угодно. Поэтому документация предупреждает: группа docker даёт права root. Команда sudo usermod -aG docker admin-lab из руководства по настройке после установки фактически делает пользователя администратором сервера. Добавляйте в группу только тех, у кого и так есть sudo. Если права root недопустимы, у Docker есть режим rootless (демон и контейнеры работают без прав root).

Опубликованные порты обходят ufw

ufw — надстройка над iptables, то есть над правилами фильтрации пакетов в ядре Linux (статья про ufw). Правила собраны в цепочки: в INPUT проверяются пакеты, адресованные самому серверу, а таблица nat подменяет адреса и порты. Docker направляет трафик контейнеров через таблицу nat, и пакеты уходят в контейнер раньше, чем попадают в цепочки INPUT и OUTPUT, на которых работает ufw. Поэтому ufw deny 8081 порт не закроет, а ufw status покажет только ваше правило и ничего не скажет о правилах Docker.

Документация называет публикацию портов небезопасной по умолчанию: -p 8081:80 без адреса открывает порт на всех интерфейсах сервера. Как ограничить доступ:

  1. Публиковать только на 127.0.0.1: -p 127.0.0.1:8081:80 в docker run и "127.0.0.1:8081:80" в Compose. Это решение по умолчанию. Наружу приложение показывает nginx на хосте, а администратор заходит через SSH-туннель. Нужен Docker Engine 28.0.0 или новее: в более ранних выпусках узлы из того же сегмента сети могли достучаться до портов, опубликованных на петлевом интерфейсе (loopback). Адрес по умолчанию можно менять в настройках демона, но ключ ip в daemon.json действует только для стандартной сети bridge, а не для сетей Compose, поэтому адрес надёжнее указывать в каждом -p и ports.
  2. Привязать к адресу внутренней сети: -p 192.168.10.20:8081:80. Порт будет слушать только на этом адресе сервера.
  3. Ограничить источники правилом iptables. Для ваших правил Docker предусмотрел цепочку DOCKER-USER, которую обрабатывают раньше его собственных (примеры — на странице «Docker with iptables» документации). Но правила, добавленные командой iptables, действуют до перезагрузки сервера: если не настроить их восстановление, после неё порты снова откроются для всех, а ufw status этого не покажет. Поэтому считайте этот способ дополнительным слоем, основными остаются способы 1 и 2, а порты проверяйте снаружи после каждой перезагрузки.

Проверяйте снаружи, например с PC1. Если порт закрыт, TcpTestSucceeded будет False:

Test-NetConnection 192.168.10.20 -Port 8081

Не открывайте API демона и панели управления

По умолчанию демон слушает только Unix-сокет. Открыть его по TCP можно только с TLS (шифрованием): в актуальных выпусках (Docker 28 и новее) демон без TLS на сетевом TCP-адресе не запускается, а раньше такой порт позволял удалённому пользователю получить root на хосте. Брандмауэр не уберегает от обращений из контейнеров. Для удалённого управления используйте SSH: DOCKER_HOST=ssh://пользователь@хост. Пользователь должен иметь доступ к сокету, то есть фактически права root (см. выше про группу docker); о настройке SSH — в статье про SSH.

То же верно для веб-панелей управления контейнерами и любого контейнера с подключённым /var/run/docker.sock: доступ к сокету равен управлению демоном, то есть правам root. Не публикуйте такие панели в интернет, держите их во внутренней сети или за VPN.

Не используйте --privileged

Опция включает все возможности ядра, отключает профили seccomp (фильтр системных вызовов) и AppArmor (мандатный контроль доступа), открывает все устройства хоста. По документации, такой контейнер не изолирован и может получить оболочку root на хосте. Если нужна одна привилегия, выдавайте её точечно (--cap-add, --device). Docker рекомендует убирать все возможности, кроме явно нужных. Если приложение позволяет, запускайте его не от root (--user) и с файловой системой только для чтения (--read-only).

Базы данных и служебные порты — не наружу

Приложение обращается к базе данных по имени службы внутри сети Compose, публиковать её порт не нужно. Для администрирования публикуйте порт на 127.0.0.1 и подключайтесь через SSH-туннель.

Образы и секреты

  • Берите образы из доверенных источников: Docker Official Images, Verified Publisher, Docker-Sponsored Open Source. Происхождение неизвестного образа вы не проверите.
  • Не запускайте из интернета команды docker run без разбора: --privileged или -v /:/host сводят изоляцию на нет.
  • Закрепляйте версии (nginx:1.30-alpine, а не latest) и регулярно обновляйте образы: исправления уязвимостей приходят с новым образом.
  • Не вшивайте пароли и ключи в образ и в compose.yaml из репозитория. Compose умеет передавать секреты файлами в /run/secrets/; это надёжнее переменных окружения, которые легко попадают в журналы.

Типичные ошибки и недоразумения

  • «Закрыл порт в ufw, а он открыт». Опубликованные порты Docker обходит ufw. Публикуйте на 127.0.0.1.
  • Данные пропали. Они лежали в слое контейнера, а не в томе. Или выполнили docker compose down -v либо docker volume prune -a.
  • Обновили образ, а работает старая версия. docker pull не трогает запущенные контейнеры: нужен docker compose up -d. А с latest версия может смениться при любом запуске.
  • После обновления app сайт отвечает 502. Прокси запомнил старый адрес: перезагрузите его (docker compose exec web nginx -s reload) или используйте restart: true.
  • Вместо файла создался каталог. Пути для bind mount не было, и Docker создал каталог. Создайте файл до запуска.
  • Правка конфигурации nginx не действует. Её читают при старте: перезагрузите (docker compose exec web nginx -s reload).
  • Диск заполнился. Журналы контейнеров без ротации, неиспользуемые образы: смотрите docker system df.
  • Без sudo жалуется на ~/.docker. Каталог создан под root, пока вы запускали команды через sudo. Исправьте владельца командой chown, как описано в руководстве Docker.

Проверьте себя

  1. Контейнер запущен как -p 5432:5432, а ufw запрещает входящие соединения. Достижим ли порт из офисной сети? Что изменить? Достижим: Docker направляет трафик в контейнер раньше правил ufw. Публикуйте порт на 127.0.0.1 (или на адресе внутренней сети), а базу не публикуйте вовсе.
  2. Чем том отличается от bind mount и что происходит с данными при docker rm? Том создаёт и хранит Docker, bind mount — каталог или файл хоста. Данные в слое контейнера пропадают при docker rm, данные в томе и каталоге хоста остаются.
  3. Что делает docker compose down и чем опасен -v? Удаляет контейнеры и сеть проекта, но не именованные тома. С -v тома тоже удаляются, и данные теряются.
  4. Почему членство в группе docker равносильно правам root? Демон работает от root, а доступ к его сокету позволяет запускать контейнеры с подключённым корнем файловой системы хоста.
  5. Как web обращается к app в одном проекте Compose и нужно ли публиковать порт app? По имени службы (http://app:8000): Compose создаёт общую сеть, где службы видят друг друга по имени. Публиковать порт app не нужно.

Что дальше

Серия на этом закончена. Дальше стоит разобраться с резервным копированием данных контейнеров (тома), HTTPS-сертификатами и мониторингом сервера: подробно об этом — отдельные статьи позже.

Источники

Ещё по теме «Linux»