Learning Platform
Глоссарий Troubleshooting
Урок 16.01 · 24 мин
Средний
dockersecuritynamespaces

Rootless Docker и user namespaces

В Docker по умолчанию есть фундаментальный security-issue, который Junior часто не осознаёт: root внутри контейнера — это root на хосте. Если приложение в контейнере получит RCE и выйдет из изоляции (есть способы — docker.sock, escape-эксплоиты ядра, неаккуратно смонтированный bind mount), оно получит полный root-доступ на хосте. С возможностью читать /etc/shadow, добавлять SSH-ключи, ставить криптомайнеры — что хочешь.

Два механизма защиты: rootless Docker (daemon запускается от обычного пользователя) и user namespace remap (UID внутри контейнера != UID на хосте). В этом уроке разберёмся, как они работают, чем отличаются, и где ограничения.


sudo: super user do, sudoers и sudo-rs

Default: root в контейнере = root на хосте

По умолчанию docker run alpine sh запускает shell от UID 0 (root) внутри контейнера. Этот UID 0 — тот же UID 0, что и root на хосте. Linux kernel один на хост и контейнеры; UID — это просто число, и ядро не различает «root в контейнере» от «root на хосте».

Что это значит на практике. Если ты делаешь:

docker run --rm -v /etc:/etc-host alpine cat /etc-host/shadow

Видишь хешированные пароли всех пользователей хоста. Bind mount примонтировал хостовый /etc внутрь контейнера, и root внутри читает то, что доступно root’у на хосте — то есть всё.

Это не теоретическая дыра. Большая часть docker-escape эксплоитов работает через комбинации:

  • Доступ к /var/run/docker.sock (если ты по глупости его примонтировал) — можешь запросить у daemon любой контейнер с привилегиями, включая --privileged --pid=host -v /:/host.
  • Капабилити CAP_SYS_ADMIN (если включена) — почти всё, что может root.
  • Баги в runc (CVE-2019-5736, CVE-2024-21626) — позволяли перезаписать runc бинарь на хосте из контейнера.

В production такого допускать нельзя. Решений два.


Rootless Docker

Идея: запустить сам Docker daemon от обычного пользователя, без root. Тогда даже если контейнер escape’нет из изоляции — он окажется в правах обычного пользователя, не root.

Архитектурно это работает через subuid/subgid mapping. У пользователя lev есть запись в /etc/subuid:

lev:100000:65536

Это значит: пользователю lev разрешено использовать UID’ы 100000-165535 как «свои» (он может создавать процессы под этими UID через user namespace). Аналогично /etc/subgid для группы.

Когда rootless dockerd запускает контейнер, UID 0 внутри контейнера маппится в UID 100000 на хосте. Root в контейнере — это вообще не root на хосте, а юзер с UID 100000. На хосте этот UID не имеет прав даже на чтение /etc/shadow.

Rootless: UID mapping
UID 0 в контейнереroot внутри namespace — может всё внутри (создать файл, поставить пакет)
user_ns_map
UID 100000 на хостеОбычный непривилегированный пользователь. Не root. Не может читать /etc/shadow, не может убить чужие процессы
UID 1000 в контейнереОбычный юзер app внутри контейнера
UID 101000 на хосте100000 + 1000 — тоже обычный пользователь, ничем особенным не выделен

Установка rootless Docker

На Ubuntu 22.04+ / Debian 12:

sudo apt install -y uidmap dbus-user-session
sudo systemctl disable --now docker.service docker.socket
sudo rm /var/run/docker.sock

# От своего пользователя (НЕ root):
dockerd-rootless-setuptool.sh install

После этого:

docker context use rootless
docker run hello-world
# Hello from Docker!

Внутри hello-world процесс крутится от UID 0 (в контейнере) -> UID 100000 на хосте (ps -p $(docker top hello-world -1 -o pid) -o uid).

Альтернатива на macOS — OrbStack rootless по умолчанию. Docker Desktop запускает daemon в VM, что эквивалентно rootless с точки зрения хоста (контейнеры не могут попасть на macOS).

NOTE

Подробнее про установку rootless: https://docs.docker.com/engine/security/rootless/. Для production рекомендую читать официальную доку — там много нюансов с systemd, networking, storage.


Ограничения rootless

Rootless — не free lunch. Есть ограничения:

  • Нельзя bind к портам < 1024 без специальной capability. По умолчанию обычный пользователь не может слушать порт 80/443. Workaround: sudo setcap cap_net_bind_service=ep $(which dockerd) — но это уже даёт capability. Альтернатива: запускать на 8080 и проксировать через root-процесс (nginx на хосте).
  • Нет cgroups v1. Rootless требует cgroups v2 (Linux 5.13+, Ubuntu 22.04+). На старых системах не работает.
  • Сложный networking. По умолчанию rootless использует slirp4netns для сети — это медленнее (на 30-50%), чем обычный bridge. Можно использовать --network host или настроить bypass4netns для performance.
  • AppArmor / SELinux могут конфликтовать.
  • Performance overhead — slirp4netns добавляет ~30% latency на сетевые операции. Для DE-стенда с локальным Postgres это незаметно, для production high-throughput — заметно.

Для dev-машины rootless — хороший trade-off. Для production надо тестировать под нагрузкой.


User namespace remap (без rootless)

Альтернатива rootless — оставить daemon с root, но включить user namespace remap. Это значит: daemon root, но контейнеры запускаются с UID mapping.

Включение через /etc/docker/daemon.json:

{
  "userns-remap": "default"
}

default создаёт пользователя dockremap и использует его subuid/subgid. Альтернатива: "userns-remap": "lev" — использует subuid пользователя lev.

После рестарта daemon (systemctl restart docker) все контейнеры запускаются с маппингом. docker run alpine id внутри покажет uid=0(root), но на хосте ps покажет UID 165536 (или другой из subuid).

docker run -d --name test alpine sleep 1000

# Внутри:
docker exec test id
# uid=0(root) gid=0(root) groups=0(root)

# Снаружи (хост):
ps -eo pid,uid,user,cmd | grep "sleep 1000"
# 12345 165536 165536    sleep 1000

UID 165536 на хосте — это 100000 + 65536 - где-то там. Точное значение зависит от subuid mapping.

Rootless vs userns-remap vs default
Defaultdockerd от root, контейнер от root (UID 0 = UID 0). Опасно в production
UID 0 в контейнере = UID 0 на хостеПри escape сразу полный root на хосте. Один баг в runc и хост взломан
userns-remapdockerd от root, контейнеры от mapped UID. Daemon-операции остаются root, но контейнеры — нет
UID 0 в контейнере = UID 100000 на хостеПри escape — обычный непривилегированный юзер. Не может ничего критичного на хосте
Rootlessdockerd сам от обычного юзера. Даже daemon не root. Самый безопасный режим
Всё от обычного юзераНевозможно сделать ничего, что не может сделать обычный юзер. Цена — performance и ограничения

Какой режим выбрать

Для Junior DE:

  • На рабочей машине (macOS + OrbStack или Linux dev) — uses rootless by default (OrbStack) или включи userns-remap. Для разработки разницы нет.
  • На CI — userns-remap или Docker-in-Docker rootless. Зависит от CI-системы.
  • На production-сервере — rootless или userns-remap. Никогда не оставляй default.

Главное правило: никогда не запускай untrusted images от root. Это правило само по себе закрывает 95% реальных атак. Image из официального Docker Hub Postgres — trusted. Image из чьего-то GitHub репозитория — нет.


Ограничения rootless: bind low ports

Демонстрация:

# В rootless режиме:
docker run -d --name web -p 80:80 nginx
# Error: rootlesskit: cannot expose privileged port 80, you can add 'net.ipv4.ip_unprivileged_port_start=0' to /etc/sysctl.conf

Системный sysctl net.ipv4.ip_unprivileged_port_start по умолчанию = 1024 (порты ниже требуют root). В rootless контейнер не может биндить такие порты.

Решения:

  • sudo sysctl net.ipv4.ip_unprivileged_port_start=80 — разрешить все порты от 80.
  • Запускать nginx на 8080, использовать обратный прокси на хосте (или iptables redirect 80->8080).
  • Использовать --network host (но тогда теряешь изоляцию сети).

Файлы и UID mapping

Когда rootless контейнер пишет файл в bind mount, UID файла на хосте — это mapped UID, не UID контейнера:

mkdir data
docker run --rm -v $(pwd)/data:/data alpine sh -c "touch /data/test"

ls -la data/test
# -rw-r--r--  1 100000 100000  0 May 15 11:00 test

Файл принадлежит UID 100000 на хосте — это «псевдо-root» rootless’а. Если ты на хосте — пользователь lev (UID 1000), ты не можешь удалить этот файл напрямую. Нужно sudo rm или удалять через тот же контейнер.

Это частая ловушка для Junior — chmod, rm руками не работают на файлах от контейнера. Решение: либо chown после, либо запускать контейнер с тем же UID (--user $(id -u)).


Попробуй сам

  1. На Linux: проверь свои subuid/subgid: cat /etc/subuid /etc/subgid | grep $USER. Должны быть строки вроде lev:100000:65536.
  2. Включи userns-remap (если не rootless): добавь "userns-remap": "default" в /etc/docker/daemon.json, рестартни daemon. Запусти контейнер, посмотри PID на хосте: ps -eo pid,uid,user,cmd | grep <process>. UID должен быть НЕ 0.
  3. Создай bind mount, запусти контейнер с записью в файл, посмотри UID файла на хосте.
  4. Сравни «обычный» docker (default) с rootless/userns: попробуй docker run --rm -v /etc:/etc-host alpine cat /etc-host/shadow. В default — работает. В userns — Permission denied (UID файла 0 на хосте, в контейнере у нас mapped UID который НЕ имеет доступа к UID 0-файлам через mapping).
  5. На macOS с OrbStack: это уже rootless. Подтверди через docker info | grep -i rootless.

Проверка знанийKnowledge check
Чем rootless Docker отличается от обычного Docker с точки зрения безопасности, и почему UID 0 (root) внутри контейнера в default-режиме = UID 0 на хосте, что делает default-режим опасным?
ОтветAnswer
В default-режиме Docker daemon работает от root на хосте, и контейнеры запускаются как процессы под root'ом daemon'а. UID 0 в контейнере = UID 0 на хосте — это один и тот же Linux UID. Ядро не различает «root в контейнере» от «root на хосте», изоляция держится только на namespaces и cgroups. Если есть docker-escape эксплоит (CVE в runc, доступ к /var/run/docker.sock, неаккуратный bind mount /etc:/etc-host) — контейнер получает полный root на хосте. Можно читать /etc/shadow, добавлять SSH-ключи, запускать майнеры. Rootless Docker: сам daemon работает от обычного пользователя (не root). Через механизм subuid/subgid (записи в /etc/subuid типа lev:100000:65536) UID 0 внутри контейнера маппится в UID 100000 на хосте. Это обычный непривилегированный пользователь — он не может читать /etc/shadow, не может убить чужие процессы, не может сделать ничего, что не может обычный юзер. Даже при полном escape атакующий получает права обычного юзера, не root. Цена rootless: запрет на bind low ports (меньше 1024), требует cgroups v2 (Linux 5.13+), networking через slirp4netns на 30% медленнее. Для dev — хороший trade-off (OrbStack по умолчанию rootless). Для production — стоит включать всегда: либо rootless, либо userns-remap (daemon остаётся root, но контейнеры маппятся).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В default-Docker (без rootless, без userns-remap) UID 0 (root) внутри контейнера и UID 0 на хосте — это одно и то же или нет?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5