Поднять рабочий веб-сервер на чистой машине обычно означает поставить пакеты, поправить конфигурацию, открыть порт и надеяться, что ничего не конфликтует с уже запущенными службами. Контейнер убирает всю эту подготовку. Одна команда даёт работающий Nginx с отданной наружу страницей, а удаляется он второй командой без единого следа в системе. Разбираем, что именно происходит за эти тридцать секунд, какие флаги пригодятся сразу и как из одноразового эксперимента сделать сервис, который переживёт перезагрузку.

Почему одна команда заменяет час ручной установки

Образ контейнера это готовая файловая система в архиве. Внутри уже лежат бинарник Nginx, нужные библиотеки, базовый nginx.conf и каталог для статики. Хостовой системе не требуется знать об этом ничего, кроме наличия самого контейнерного движка.

Сам контейнер это обычный процесс Linux, который ядро ограничивает пространством имён и контрольными группами. Процесс видит собственную файловую систему, собственную сетевую таблицу и собственный список соседей. Для хоста он остаётся процессом со всеми вытекающими возможностями по учёту и ограничению ресурсов.

Размер имеет значение на медленной сети. Образ nginx на базе Debian занимает около 70 МБ, вариант alpine примерно вдвое меньше, а alpine-slim укладывается в 25 МБ. Скачивание занимает секунды.

Главная польза для новичка в другом. Эксперимент обратим. Контейнер останавливается и удаляется целиком, вместе со всем, что успело накопиться внутри. В системе не остаётся конфигурационных файлов, единиц служб и пакетов, которые потом придётся вычищать вручную. Можно смело пробовать, ломать и начинать заново.

Актуальные версии на момент подготовки материала: ветка mainline 1.31.8 от 21 октября 2026 года и ветка stable 1.30.6 от 14 октября 2026 года. Новичку разумнее брать stable, она получает только исправления.

Выбор движка между Docker и Podman и что даёт rootless режим

Docker работает через фоновую службу, которая обрабатывает все операции с контейнерами и выполняется от имени суперпользователя. Любой, кто имеет доступ к её сокету, фактически располагает правами root на хосте. Это стоит понимать до первого запуска, а не после.

Podman устроен иначе. У него нет постоянно висящего привилегированного процесса, команды напрямую обращаются к низкоуровневым инструментам вроде crun и создают контейнер от имени текущего пользователя. Именно поэтому rootless в Podman это режим по умолчанию, а не отдельная настройка.

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

Команды у двух движков совпадают, меняется только первое слово. Всё написанное ниже для docker работает в podman с той же аргументацией. В rootless режиме есть ограничение, о которое спотыкаются почти все. Порты ниже 1024 привилегированные, непривилегированный процесс их занять не может. Отсюда в примерах 8080, а не 80.

Ограничение обходится параметром ядра net.ipv4.ip_unprivileged_port_start, но менять его на общей машине не лучшая идея, настройка затрагивает всех пользователей сразу. Гораздо спокойнее отдать контейнеру порт из верхнего диапазона, а доступ наружу при необходимости настроить на уровне маршрутизатора.

Есть и готовый образ nginxinc/nginx-unprivileged, где процесс работает под идентификатором 101 и слушает внутренний порт 8080. Привилегии там не нужны в принципе.

Первый запуск и пять флагов, которые пригодятся сразу

Минимальная команда выглядит так:

docker run -d --name site -p 8080:80 nginx:1.30-alpine

Через полминуты браузер по адресу localhost:8080 показывает стандартную страницу Welcome to nginx. Всё работает, но для реальной задачи такой запуск слишком наивен. Полезные флаги стоит разобрать по одному:

  1. Флаг -d отделяет контейнер от терминала, без него процесс держит консоль и умирает вместе с ней;
  2. Флаг --name задаёт понятное имя, иначе движок придумает случайное вроде jovial_curie и каждый раз придётся искать идентификатор;
  3. Флаг -p пробрасывает порт в формате порт_хоста:порт_контейнера, причём допустима запись 127.0.0.1:8080:80, тогда сервер виден только с самой машины;
  4. Флаги --memory и --cpus ограничивают аппетиты контейнера, например --memory 128m --cpus 0.5 не даст одному эксперименту съесть ресурсы всего сервера;
  5. Флаг --restart unless-stopped поднимает контейнер после перезагрузки хоста и после аварийного завершения процесса, если только его не остановили вручную.

Запись 127.0.0.1:8080:80 заслуживает отдельного внимания. Пока сайт сырой, держать его доступным для всей локальной сети незачем. Привязка к локальному адресу закрывает доступ из сети, а проверяют страницу на самом сервере либо пробросом порта на рабочую машину. Когда страница готова, привязку убирают.

Осмысленный старт выглядит так:

docker run -d \
  --name site \
  --restart unless-stopped \
  --memory 128m \
  --cpus 0.5 \
  -p 127.0.0.1:8080:80 \
  nginx:1.30-alpine

Проверить, что порт действительно слушается, помогает ss:

ss -tlnp | grep 8080

Как отдать наружу собственную HTML-страницу через том

Пересобирать образ ради каждой правки текста занятие бессмысленное. Содержимое подключается с хоста через том, и тогда файлы редактируются в обычном текстовом редакторе, а сервер подхватывает изменения немедленно.

mkdir -p ~/site/html
cat > ~/site/html/index.html <<'HTML'
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>Домашняя документация</title>
</head>
<body>
  <h1>Проект работает</h1>
  <p>Эта страница отдана Nginx из контейнера.</p>
</body>
</html>
HTML

docker run -d --name site --restart unless-stopped \
  -p 8080:80 \
  -v ~/site/html:/usr/share/nginx/html:ro \
  nginx:1.30-alpine

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

Том перезаписывает содержимое целевого каталога целиком. Если смонтировать ~/site/html в /etc/nginx, штатная конфигурация окажется скрыта под пустой папкой, и сервер не стартует. Ошибка выглядит загадочно, а причина тривиальна, поэтому путь монтирования лучше перечитать дважды.

Для документации проекта этого уже достаточно. Складывают markdown, собранный генератором статики, или просто набор HTML-файлов, и получается внутренняя вики, доступная всей команде по адресу сервера.

Почему Nginx не стартует в контейнере и что показывают логи

Первая осмысленная ошибка обычно приходит вместе с монтированием тома. В логах видно permission denied при попытке открыть index.html.

docker logs site

Причин две, и обе типовые. Первая касается SELinux. На системах с включённым принудительным контролем доступа файлам на хосте присвоены метки, которые не совпадают с ожиданиями контейнерного процесса. Лечится суффиксом к пути тома:

-v ~/site/html:/usr/share/nginx/html:ro,Z

Заглавная Z присваивает каталогу уникальную метку, рассчитанную на один контейнер. Строчная z делает метку общей, что нужно, когда один каталог читают несколько сервисов. Перепутать их легко, а последствия разные: с общей меткой изоляция между контейнерами слабеет.

Вторая причина в правах доступа. Процесс в образе nginx работает под пользователем с идентификатором 101, а файлы на хосте принадлежат обычному пользователю с идентификатором 1000. Если права выставлены как 600, читать их некому. Помогает либо chmod 644 на файлы и 755 на каталог, либо запуск под своим идентификатором:

docker run -d --name site -u 1000:1000 \
  -v ~/site/html:/usr/share/nginx/html:ro \
  -p 8080:8080 nginxinc/nginx-unprivileged:1.30-alpine

Вторая частая проблема: адрес уже занят. Ошибка address already in use означает, что на 8080 висит другой сервис. Проверяется одной командой ss -tlnp, дальше остаётся либо остановить соседа, либо взять порт свободный.

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

docker ps -a

В колонке STATUS будет видно Exited с кодом. Код 1 почти всегда означает проблему в конфигурации или в путях, код 137 говорит о принудительной остановке, часто из-за нехватки памяти.

Отдельная ловушка с путями. Символ ~ раскрывает оболочка хоста, но при запуске из скрипта или от другого пользователя раскрытия может не произойти, и движок создаст каталог с именем из двух символов. Надёжнее писать абсолютный путь /home/имя/site/html.

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

Стандартный docker run выдаёт контейнеру набор из четырнадцати привилегий. Веб-серверу, отдающему статику, из них нужно от силы три: CHOWN, SETGID и SETUID для смены пользователя рабочими процессами. Остальные стоит снять.

docker run -d --name site \
  --cap-drop ALL \
  --cap-add CHOWN --cap-add SETGID --cap-add SETUID \
  --security-opt no-new-privileges \
  --read-only \
  --tmpfs /var/cache/nginx --tmpfs /var/run \
  --memory 128m --cpus 0.5 \
  -p 8080:80 \
  -v ~/site/html:/usr/share/nginx/html:ro \
  nginx:1.30-alpine

Параметр no-new-privileges запрещает процессу повышать свои права, в том числе через setuid-бинарники. Флаг --read-only переводит корневую файловую систему в режим только для чтения, и любая попытка записать что-то в системные каталоги заканчивается ошибкой. Nginx при этом обязан куда-то писать кэш и pid-файл, для этого и нужны два tmpfs, живущие в оперативной памяти и исчезающие вместе с контейнером.

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

Из одноразового эксперимента в сервис, переживающий перезагрузку

Пока контейнер запускают руками, он живёт до первой перезагрузки сервера. Есть три способа сделать запуск постоянным, и выбирать стоит по ситуации.

Первый, самый простой, это уже упомянутый --restart unless-stopped. Движок сам поднимет контейнер при старте системы. Минус в том, что логика целиком скрыта внутри службы движка, и повлиять на порядок запуска сложно.

Второй способ подходит Podman и даёт systemd-юнит автоматически:

podman generate systemd --new --name site --files
mv container-site.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now container-site.service
loginctl enable-linger $USER

Последняя команда обязательна. Без неё пользовательские службы завершаются вместе с сеансом, и контейнер не стартует после перезагрузки, пока никто не войдёт в систему. Флаг --new заставляет юнит создавать контейнер заново, а не искать существующий, что снимает половину проблем при обновлении образа.

Третий способ универсален и читается легче остальных. Файл compose.yml описывает весь сервис декларативно:

services:
  site:
    image: nginx:1.30-alpine
    container_name: site
    restart: unless-stopped
    read_only: true
    cap_drop: [ALL]
    cap_add: [CHOWN, SETGID, SETUID]
    security_opt:
      - no-new-privileges
    tmpfs:
      - /var/cache/nginx
      - /var/run
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    deploy:
      resources:
        limits:
          memory: 128M
          cpus: "0.5"

Запуск сводится к одной строке в каталоге с файлом:

docker compose up -d

Преимущество файла в том, что настройка лежит в одном месте и её можно положить в систему контроля версий вместе со страницей. Через полгода не придётся вспоминать флаги первого запуска, достаточно прочитать двадцать строк.

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

docker compose pull
docker compose up -d

Вторая команда пересоздаёт только контейнеры с изменившимся образом, остальные не трогает. Простой составляет секунды.

От одной команды до сервиса с ограничениями, автоперезапуском и версионируемой конфигурацией проходит примерно час работы. Дальше на этот каркас встают вещи посерьёзнее: обратный прокси перед внутренними приложениями, выдача собранной документации, дашборд мониторинга. Привычка сразу ограничивать привилегии и ресурсы окупается при первом же неудачном эксперименте.