Возьмём стек, который встречается чаще любого другого: в Compose-файле три сервиса. Первый — nginx или приложение, которое слушает порт 80 и отдаёт сайт наружу. Второй — база данных, например PostgreSQL. Третий — каталог с загруженными файлами, примонтированный в контейнер приложения, чтобы вложения переживали пересоздание контейнера. На старом сервере всё это запущено демоном Docker от root и никаких вопросов не вызывает. Переносим тот же файл на пользователя без привилегий, выполняем docker compose up -d и видим, что контейнеры поднялись. А дальше начинаются мелочи: порт 80 не открылся, файлы в каталоге принадлежат непонятному UID, лимит памяти будто не действует, а после выхода из SSH-сессии стек исчез.
Это не поломки. Это следствия одного решения: демон и контейнеры живут внутри пользовательского пространства имён и не имеют прав root на хосте. Разберём, что именно меняется для каждого из трёх элементов нашего стека. Заодно договоримся, чего rootless не обещает.
Что на самом деле значит «без root»
Согласно документации Docker о rootless-режиме, демон и контейнеры запускаются без root внутри user namespace. Для этого нужны подходящие диапазоны UID/GID (subuid и subgid) и некоторые системные зависимости. Там же отмечено важное различие: это не то же самое, что userns-remap, где демон по-прежнему работает от root, а переназначаются лишь идентификаторы внутри контейнеров.
Практический смысл такой. «Root» внутри контейнера в rootless-режиме — это не root хоста, а ваш обычный пользователь или один из подчинённых ему идентификаторов. Если процесс в контейнере вырвется за пределы изоляции, он окажется с правами этого пользователя, а не администратора системы. Поверхность последствий действительно уменьшается.
Но поставим границу сразу. Rootless не превращает контейнер в отдельную виртуальную машину и не гарантирует безопасность. Код в контейнере по-прежнему исполняется на ядре общего хоста. Он может читать всё, что доступно вашему пользователю: каталоги, ключи, содержимое тома, переменные окружения соседних контейнеров этого же демона. Если в приложении уязвимость, злоумышленник получит приложение вместе с его базой и файлами, вне зависимости от режима демона. Rootless ограничивает масштаб последствий на уровне хоста, а не защищает данные внутри стека. Дальше мы говорим именно об этом: об ограничениях и изменениях поведения, а не о волшебной безопасности.
Каталог с файлами: чей это UID на самом деле
Начнём с тома, потому что на нём спотыкаются чаще всего. Допустим, приложение в контейнере работает от пользователя с UID 1000, а на хосте каталог /srv/app/uploads принадлежит вашему пользователю с UID 1000. Интуиция подсказывает: совпало, значит запись пройдёт. В rootless-режиме это не так.
Внутри пользовательского пространства имён идентификаторы отображаются так: UID 0 контейнера соответствует вашему пользователю на хосте, а UID с 1 и выше — диапазону из subuid. Условный пример: у вашего пользователя UID 1001, а в файле /etc/subuid указано, что ему отданы 65536 идентификаторов, начиная со 100000. Тогда:
| UID в контейнере | UID на хосте | Что это значит для файлов |
|---|---|---|
| 0 (root в контейнере) | 1001 (ваш пользователь) | Файлы, созданные «root» контейнера, на хосте принадлежат вам |
| 1000 (пользователь приложения) | 100999 | Файлы на хосте принадлежат «чужому» номеру из диапазона subuid |
| 999 (типичный пользователь postgres) | 100998 | Данные базы на хосте принадлежат другому номеру из диапазона |
Числа здесь иллюстративные, у вас они будут свои; посмотреть их можно в /etc/subuid и /etc/subgid. Важно понять механику: приложение с UID 1000 внутри контейнера не может писать в каталог, который на хосте принадлежит UID 1000, потому что оно для хоста — UID 100999. Каталог должен принадлежать именно тому хостовому номеру, который соответствует нужному контейнерному.
Отсюда три рабочих варианта для каталога с загрузками.
- Запустить приложение от root контейнера. Для хоста это ваш обычный пользователь, права на каталог совпадают естественно. Это самый простой путь, и именно здесь «root внутри» перестаёт пугать. Минус: внутри контейнера процесс имеет больше прав, чем требуется, и если образ рассчитан на непривилегированного пользователя, придётся менять его настройку.
- Сменить владельца каталога через пространство имён. Команда
docker unshare chownне нужна как отдельная магия; общий принцип — выполнить смену владельца изнутри того же user namespace, чтобы номер был записан в нужном «контейнерном» виде. Либо вычислить хостовый UID вручную по таблице выше и назначить его каталогу от своего пользователя, если подчинённые идентификаторы вам доступны для этой операции. Проверяйте результат командойls -ln: она показывает числовые идентификаторы, а не имена. - Использовать именованный том Docker. Тогда каталог создаётся и хранится в данных демона вашего пользователя, а права выставляются при первом заполнении образом. Для базы данных это нередко самый безболезненный вариант: PostgreSQL сама инициализирует каталог под своим пользователем внутри контейнера, и вам не приходится считать номера.
У каждого варианта своя цена. Bind-mount удобен тем, что файлы лежат там, где вы их видите и можете копировать обычными инструментами, но вы платите за ручное согласование UID. Именованный том избавляет от арифметики, но данные уходят вглубь каталога демона (по умолчанию в домашнем каталоге пользователя), и при резервном копировании придётся учитывать это место.
Есть и обратная сторона. Файлы, принадлежащие диапазону subuid, нельзя просто так прочитать или удалить обычными командами от вашего пользователя: для хоста это чужие номера. Когда понадобится почистить такой каталог или достать оттуда файл, пригодится вход в то же пространство имён (через docker run с примонтированным каталогом или инструмент unshare), а не sudo rm. Лучше узнать об этом до того, как приложение накопит гигабайты загрузок.
Порт 80: почему он «не открывается»
Теперь сетевая часть. Приложение внутри контейнера слушает 80-й порт, мы публикуем его строкой "80:80". В rootless-режиме демон — обычный процесс, а привилегированные порты ниже 1024 по умолчанию доступны только привилегированным. В разделе советов по rootless-режиму прямо сказано, что публикация портов ниже 1024 требует дополнительной настройки.
Здесь есть развилка, и у неё три ветви.
Ветвь первая: не использовать привилегированный порт для самого Docker. Опубликуйте приложение на порту выше 1024, например 8080:80, а привычные 80 и 443 отдайте другому компоненту. Если перед стеком стоит обратный прокси, установленный на хосте как обычный системный сервис с правом слушать 80 и 443, он пересылает запросы на 127.0.0.1:8080. Тогда rootless-демон вообще не касается привилегированных портов, и ничего ослаблять не нужно. Для многих сайтов это самое аккуратное решение: TLS-сертификаты и привилегированные порты остаются в одном месте.
Ветвь вторая: разрешить непривилегированным процессам занимать низкие порты. Для этого меняют параметр ядра, определяющий нижнюю границу непривилегированных портов, или выдают соответствующую возможность бинарному файлу. Конкретные команды берите из раздела советов по ссылке выше и проверяйте под свою систему. Последствие нужно понимать честно: параметр ядра действует на весь хост, то есть после него любой непривилегированный пользователь на машине сможет занять порт 80. На одиночном VPS с одним администратором это мало что меняет, на машине с несколькими пользователями — меняет. Так что это сознательное решение, а не «чтобы заработало».
Ветвь третья: перенаправление портов на уровне файрвола. Можно оставить Docker на высоком порту, а внешний 80 перенаправить правилом файрвола на 8080. Это работает, но добавляет ещё один слой, о котором легко забыть при переезде или диагностике.
Теперь про «фактический маршрут». В rootless-режиме трафик в контейнер идёт не так, как у демона с root. Сетевой стек вашего пользователя изолирован, и пакеты проходят через пользовательский прокси-слой. Практическое следствие: приложение в контейнере может видеть в качестве адреса клиента не реальный внешний IP, а внутренний адрес этого слоя. Для журналов доступа, ограничения частоты запросов и блокировки по IP это важно. Проверить просто: сделайте запрос с внешней машины и посмотрите, какой адрес приложение записало в журнал. Если вместо вашего адреса видите служебный, то вопрос решается на уровне обратного прокси, который стоит на хосте и передаёт настоящий адрес клиента в заголовке, а приложение настраивается ему доверять. Конкретное поведение зависит от версии Docker и выбранного сетевого драйвера, поэтому не полагайтесь на память, а проверьте на своём стенде.
Связь между контейнерами внутри Compose-сети от всего этого почти не страдает: приложение по-прежнему обращается к базе по имени сервиса, и порт базы наружу публиковать не нужно. Это, пожалуй, лучшая новость для нашего стека: самый чувствительный сервис остаётся без внешнего порта в любом режиме. А вот обращение контейнера к службе, запущенной на самом хосте, потребует отдельного внимания: адрес хоста изнутри rootless-контейнера выглядит иначе, чем в привычной схеме, и стоит один раз проверить его опытным путём.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Лимиты памяти и CPU: когда они действительно работают
В Compose-файле у приложения написано mem_limit: 512m и ограничение по процессору. На старом сервере это выполнялось демоном с root, который сам управлял cgroup. Что происходит у обычного пользователя?
По той же странице с рекомендациями, ограничения ресурсов через cgroup-флаги поддерживаются при cgroup v2 и systemd. Без этих условий флаги могут игнорироваться, причём тихо. Кроме того, доступность отдельных контроллеров зависит от того, делегированы ли они пользователю. То есть лимит можно написать, получить успешный запуск и не получить никакого ограничения.
Поэтому здесь полезна не вера, а проверка в три шага:
- Выяснить, какая версия cgroup используется на хосте. Если это cgroup v2, команда
stat -fc %T /sys/fs/cgroupпокажет тип файловой системыcgroup2fs. - Посмотреть, какие контроллеры делегированы вашему пользователю. Список лежит в файле
cgroup.controllersв каталоге его пользовательской единицы systemd; если там нетmemory, ограничение памяти применяться не будет. Если нетcpu— не сработает и лимит процессора. Делегирование настраивается на уровне systemd через конфигурацию сервисаuser@.service, точные шаги смотрите в документации по ссылке выше. - Запустить тестовый контейнер с лимитом и сравнить:
docker statsпоказывает предел памяти, а значение в файлеmemory.maxвнутри cgroup контейнера подтверждает, что ядро его действительно применило. Расхождение между тем, что вы указали, и тем, что видит ядро, и есть ваша проблема.
Почему это критично именно для нашего стека? Потому что лимит памяти защищает соседей. На небольшом VPS приложение, база и обратный прокси делят одну оперативную память. Если лимит у приложения тихо не действует, утечка памяти в нём доберётся до базы и системных процессов. В итоге вместо аккуратного перезапуска одного контейнера вы получите вмешательство OOM-killer, который выберет жертву по собственным правилам. Конкретные объёмы зависят от приложения: тариф вроде Ba с 2 ГБ RAM и Heka с 8 ГБ дают разный запас, но это параметры тарифа, а не обещание, что нужное вам приложение в них поместится. Реальный расход покажет замер на вашем стеке.
Если делегирования нет и менять конфигурацию systemd вы не готовы, честный выход — понимать, что лимитов у вас фактически нет, и закладывать запас по оперативной памяти или ограничивать нагрузку другими средствами, например настройкой самого приложения (число рабочих процессов, размер кэша в базе). Это скучно, но надёжнее, чем уверенность в параметре, который ядро не видело.
Жизнь после выхода из SSH и перезагрузки
Остаётся последний сюрприз нашей сцены: после закрытия сессии стек исчез. В rootless-режиме демон принадлежит пользовательской сессии systemd. Пока пользователь не вошёл в систему, его сервисы не работают, а при выходе последней сессии они останавливаются, если не включено так называемое «задержанное» пребывание пользователя.
Поэтому демон запускают как пользовательский сервис: управление идёт командами вида systemctl --user start docker и systemctl --user enable docker, а не привычным systemctl от root. Чтобы сервис стартовал при загрузке хоста без входа пользователя и не умирал после выхода, для пользователя включают linger командой loginctl enable-linger (выполняется от имени администратора). Проверьте итог на себе: перезагрузите VPS, не заходя по SSH, и посмотрите, отвечает ли сайт. Это единственный способ узнать, что автозапуск действительно настроен.
Тут же полезно напомнить о политике перезапуска контейнеров. Строка restart: unless-stopped в Compose работает только пока работает демон. Если демон пользовательский и не поднялся после перезагрузки, контейнеры не вернутся, как бы красиво ни была написана политика. Цепочка зависимостей такая: хост загрузился, systemd запустил пользовательский менеджер благодаря linger, тот поднял демон Docker, демон поднял контейнеры по их политике. Любое звено, не проверенное в деле, остаётся предположением.
Заодно решается вопрос с управлением: команды docker теперь должны знать, к какому сокету подключаться. Это переменная окружения DOCKER_HOST, указывающая на сокет в каталоге выполнения пользователя, либо отдельный контекст Docker. Если вы пользуетесь веб-панелью управления контейнерами, имейте в виду, что ей нужен доступ именно к этому сокету, а не к системному; для общего знакомства с такими панелями есть материал о Portainer на VPS. Учтите при этом, что доступ к сокету демона равнозначен управлению всеми контейнерами пользователя, поэтому его не стоит выставлять в сеть без понимания последствий.
Где на VPS пригодится root-доступ при rootless-демоне
Парадокс в том, что rootless-установка на VPS требует административных шагов, которые нельзя сделать от обычного пользователя: поставить системные пакеты, прописать диапазоны subuid и subgid, включить linger, при необходимости изменить параметр ядра для портов или делегирование контроллеров cgroup. На VPS с root-доступом всё это выполняется один раз при подготовке, после чего ежедневная работа идёт от пользователя без привилегий. Так root остаётся инструментом настройки хоста, а не тем, от чьего имени крутится каждый контейнер. На серверах MAATRIX (NVMe, AMD EPYC, root-доступ; локации и состав образов уточняйте в форме заказа на странице аренды VPS) такая схема реализуема штатно: подготовительные шаги делаются под root, потом стек живёт под отдельным пользователем.
Для самого стека ресурсы тоже влияют на решение. Если база и приложение делят небольшой тариф, а лимиты cgroup без делегирования не работают, запас по оперативной памяти становится вашей единственной подушкой. Если нужна заметная нагрузка, имеет смысл начинать с замера на пробном стенде, а не с выбора по таблице тарифов.
Что уже работает без root, а что придётся переделать
Вернёмся к нашему стеку из трёх частей и разложим итог по каждой.
- База данных с именованным томом и без внешнего порта. Переезжает почти без правок. Связь по имени сервиса в Compose-сети сохраняется, права на данные выставляет сам образ при инициализации. Единственное, что стоит проверить, — расположение данных тома при копировании.
- Каталог с загрузками. Требует решения. Либо запуск приложения от root контейнера (то есть от вашего пользователя на хосте), либо расчёт хостового UID по таблице subuid, либо именованный том. Выбирайте, исходя из того, как вы собираетесь копировать и чистить эти файлы.
- Публичный порт 80. Требует осознанного выбора. Самый чистый вариант — публиковать приложение на высоком порту и принимать трафик обратным прокси на хосте. Изменение параметра ядра допустимо, но это сознательное ослабление ограничения для всей машины, и о нём лучше помнить.
- Лимиты памяти и CPU. Работают лишь при cgroup v2, systemd и делегированных контроллерах. Проверяются по
memory.max, а не по отсутствию ошибки при запуске. - Автозапуск. Требует linger и пользовательского сервиса systemd, проверяется перезагрузкой без входа по SSH.
Если у вас обычный сайт с базой, обратным прокси и несколькими томами, то большая часть Compose-файла переедет без изменений. Переделка нужна в трёх местах: права на каталог, источник привилегированного порта и проверка лимитов. Все три видны за один вечер на пробном стенде, а не после переключения боевого трафика. Для стеков, которым нужен прямой доступ к устройствам, особые сетевые режимы или работа с чужими сокетами, список ограничений длиннее, и их придётся проверять каждое по отдельности по документации выбранной версии Docker.
И последнее, о чём стоит помнить при принятии решения. Rootless уменьшает ущерб от побега из контейнера на уровне хоста. Он не защищает от уязвимостей приложения, утечек паролей из переменных окружения, слабых паролей базы и открытых портов. Поэтому порядок такой: сначала закройте базу от внешнего мира, обновите образы и сузьте права приложения, а затем выбирайте rootless как ещё один слой, цена которого — несколько осознанных переделок, описанных выше.