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 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).
Подробнее про установку 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.
Какой режим выбрать
Для 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)).
Попробуй сам
- На Linux: проверь свои subuid/subgid:
cat /etc/subuid /etc/subgid | grep $USER. Должны быть строки вродеlev:100000:65536. - Включи userns-remap (если не rootless): добавь
"userns-remap": "default"в/etc/docker/daemon.json, рестартни daemon. Запусти контейнер, посмотри PID на хосте:ps -eo pid,uid,user,cmd | grep <process>. UID должен быть НЕ 0. - Создай bind mount, запусти контейнер с записью в файл, посмотри UID файла на хосте.
- Сравни «обычный» 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). - На macOS с OrbStack: это уже rootless. Подтверди через
docker info | grep -i rootless.