Поднять рабочий веб-сервер на чистой машине обычно означает поставить пакеты, поправить конфигурацию, открыть порт и надеяться, что ничего не конфликтует с уже запущенными службами. Контейнер убирает всю эту подготовку. Одна команда даёт работающий 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. Всё работает, но для реальной задачи такой запуск слишком наивен. Полезные флаги стоит разобрать по одному:
- Флаг -d отделяет контейнер от терминала, без него процесс держит консоль и умирает вместе с ней;
- Флаг --name задаёт понятное имя, иначе движок придумает случайное вроде jovial_curie и каждый раз придётся искать идентификатор;
- Флаг -p пробрасывает порт в формате порт_хоста:порт_контейнера, причём допустима запись 127.0.0.1:8080:80, тогда сервер виден только с самой машины;
- Флаги --memory и --cpus ограничивают аппетиты контейнера, например --memory 128m --cpus 0.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
Вторая команда пересоздаёт только контейнеры с изменившимся образом, остальные не трогает. Простой составляет секунды.
От одной команды до сервиса с ограничениями, автоперезапуском и версионируемой конфигурацией проходит примерно час работы. Дальше на этот каркас встают вещи посерьёзнее: обратный прокси перед внутренними приложениями, выдача собранной документации, дашборд мониторинга. Привычка сразу ограничивать привилегии и ресурсы окупается при первом же неудачном эксперименте.