
Контейнеры, в отличие от виртуальных машин, разделяют ядро с хостом. Это порождает важный вопрос: как Unix-права соотносятся между процессами внутри контейнера, соседними контейнерами и хостовой машиной?
Представим, что есть volume — bind mount директории с хоста, внутри которой лежит файл config.yml:
/some-volume/config.yml
-
Если назначить файлу владельца
rootна хосте — будет ли это тот же самыйrootвнутри контейнера? -
Если на хосте существует пользователь
gtosss— можно ли переключиться на него внутри контейнера и получить доступ к файлу? -
Если создать пользователя
gtosssвнутри контейнера и выдать ему права на файл — сможет ли хост обратиться к этому файлу под таким же пользователем?
Если хотя бы один вопрос вызывает сомнение — статья для вас.
Перед прочтением рекомендую ознакомиться со статьей — Права в Linux: chown/chmod, SELinux context, символьная/восьмеричная нотация, DAC/MAC/RBAC/ABAC
Важно помнить
Первое, что нужно держать в голове, — ядро оперирует не именами, а айдишниками UID и GID:
-
UID — User ID
-
GID — Group ID
Сопоставления пользователей и их ID хранятся в файле /etc/passwd.
Данный файл содержит строки такого вида:
gtosss:x:1000:1000:gtosss:/home/gtosss:/bin/bash
Это отдельные значения разделенные символом :
Подготовим песочницу для экспериментов
Опишем docker-compose.yml
services: alpine-playground: image: alpine:3.22 container_name: alpine-playground volumes: - ./common:/common command: ["sleep", "infinity"] restart: unless-stopped
Создадим директорию с файлом и запишем в него текст.
mkdir common
echo 'hello from host' > docker-common/message.txt
Отвечаем на поставленные вопросы в Ubuntu 22
1. Если назначить файлу владельца root на хосте — будет ли это тот же самый root внутри контейнера?
На хосте я работаю от пользователя gtosss с UID 1000. Соответственно директория и файл принадлежат ему. А теперь я запущу контейнер и зайду внутрь него
docker exec -it alpine-playground sh
Внутри контейнера я root, попробуем посмотреть права на файл из волума:
/ # ls -l commontotal 4-rw-r--r-- 1 1000 1000 16 May 9 22:22 message.txt
Вместо названия выводиться сразу UID и GID. Посмотрим чуть подробнее.
/common # stat message.txt File: message.txt Size: 16 Blocks: 8 IO Block: 4096 regular fileDevice: 820h/2080d Inode: 17210 Links: 1Access: (0644/-rw-r--r--) Uid: ( 1000/ UNKNOWN) Gid: ( 1000/ UNKNOWN)
Uid: ( 1000/ UNKNOWN) — UID 1000, но такой пользователь, как и группа, внутри контейнера не известны. UNKNOWN (неизвестен) так как в контейнере свой файл /etc/passwd, в который gtosss никогда не добавлялся.
И вроде все логично, для всех, кто не является владельцем, разрешено чтение:
0644/-rw-r--r-- ↑ ↑
Читаем без проблем:
/ # cat common/message.txthello from host
А может дело в том, что ядро linux считает меня root’ом? Что по поводу записи:
echo 'hello from container' >> message.txt
Никаких проблем, мы можем из контейнера писать в файл. Убедимся, прочитав результат с хоста:
/common # cat message.txthello from hosthello from container
Выходит, мы будучи рутом в контейнере, убедили ядро linux в том, что мы являемся рутом относительно файла, который лежит в хостовой файловой системе.
На хосте попробуем назначить владельцем файла root пользователя и только ему дать права.
На хосте меняем владельца файла на рута, убираем все остальные права. Итого получается:
ls -ltotal 4-rw------- 1 root root 60 May 10 18:00 message.txt
Видим, что файл принадлежит руту и никто не имеет к нему доступа:
Зайдя в контейнер, нет никаких преград, чтобы читать или писать в файл, в итоге у нас получилось из контейнера выполнить:
/common # echo 'hello from container 2' >> message.txt
Выходим из контейнера, и с хоста, от имени супер-пользователя читаем:
sudo cat message.txt
Видим содержимое:
hello from hosthello from containerhello from container 2
Делаем вывод, что в Ubuntu, root внутри контейнера, это тот же самый root что и в хостовой системе. Ядро линукс доверяет супер-пользователю внутри контейнера. Для неподготовленного системного администратора, не очень очевидное поведение и довольно рискованное. Можно неосознанно дать доступ к рутовой БД, если она примонтирована к контейнеру.
2. Если на хосте существует пользователь gtosss — можно ли переключиться на него внутри контейнера и получить доступ к файлу?
На хосте устанавливаем владельцем файла пользователя gtosss. Доступы выглядят так:
Access: (0600/-rw-------) Uid: ( 1001/ gtosss) Gid: ( 1002/ gtosss)
А теперь запрыгиваем в контейнер. Добавляем пользователя gtosss и смотрим, какой у него UID.
/ $ cat etc/passwd | grep gtosssgtosss:x:1000:1000::/home/gtosss:/bin/sh
Обратите внимание, что UID у пользователя gtosss — 1000. На хосте пользователь с таким именем имеет UID 1001. Пробуем прочитать файл:
/common $ cat message.txtcat: can't open 'message.txt': Permission denied
А теперь установим тот же самый UID что у на хосте. На linux alpine утилита usermod отсутствует поэтому сначала устанавливаем пакет shadow в который она включена:
apk add shadow
Теперь устанавливаем UID 1001
usermod -u 1001 gtosss
И переключаемся на пользователя:
/ $ cat /common/message.txthello from hosthello from containerhello from container 2
Ответ на вопрос получен. Чтобы получить изнутри контейнера доступ к файлу, владельцем которого мы не являемся, нам достаточно создать внутри контейнера юзера с тем же UID. Причем имя не имеет значения, ядро линукс проверяет именно ID пользователя и группы.
3. Если создать пользователя внутри контейнера и выдать ему права на файл — сможет ли хост обратиться к этому файлу под другим пользователем?
Думаю полученных знаний достаточно, чтобы ответить на этот вопрос даже без экспериментов. Решающим фактором получения прав к файлу является UID. Тут такая же ситуация, как во втором вопросе.
Отвечаем на поставленные вопросы в SELinux (Fedora Linux)
На своем сервере я использую Fedora, которая использует модуль ядра SELinux (Security-Enhanced Linux). SELinux реализует множество политик безопасности. Вместо Docker использую Podman — функционал их практически одинаковый, но разный подход к безопасности. Podman отличается тем, что позволяет запускать контейнеры без прав root (режим rootles), и по умолчанию работает именно в таком режиме.
На хосте посмотрим через ls -l какие у файла права
-rwxr--r--. 1 gtosss gtosss 16 May 9 09:48 message.txt
А теперь зайдем внутрь контейнера и посмотрим, что мы увидим изнутри контейнера
gtosss@laptop-dc:~/playground$ podman exec -it alpine-playground sh
В контейнере мы root, попытаемся посмотреть что внутри /common:
/common # ls -lls: can't open '.': Permission denied
На уровне классической системы unix прав проблем нет. На Ubuntu мы имели бы доступ, но тут как раз работает SELinux и особенности Podman. Рассмотрим две особенности, которыми SELinux и Podman отличается от классических Ubuntu и Docker.
Особенность первая — SELinux контекст и политики доступов
Выходим из контейнера и смотрим context SELinux через stat:
gtosss@laptop-dc:~/playground$ stat common File: common Size: 22 Blocks: 0 IO Block: 4096 directoryDevice: 0,44 Inode: 3457892 Links: 1Access: (0744/drwxr--r--) Uid: ( 1000/ gtosss) Gid: ( 1000/ gtosss)Context: unconfined_u:object_r:user_home_t:s0
Нас интересует эта строка:
Context: unconfined_u:object_r:user_home_t:s0
В статье о правах linux есть детальный разбор этой строки. Если она не понятна, то имеет смысл сначала ознакомиться со статьей. А в статье с обнордингом по SELinux рассказывается все необходимое, что нужно знать о политиках безопасности.
Здесь права ограничены типом user_home_t. Данный тип говорит о том, что файл относится к домашней директории обычного пользователя. Это значит, что доступ имеют сам пользователь, или процессы пользователя (user_t, staff_t) и процессы типа unconfined_t. Поставим специальную метку для волума (bind mount), чтобы Podman разрешал доступ к файлу.
Согласно документации Docker и Podman:
-
z(маленькая) — если volume разделяют несколько контейнеров (наш случай, т.к раннер будет создавать много контейнеров). -
Z(большая) — если volume использует только один контейнер.
Добавляем маленькую :z:
services: alpine-playground: image: alpine:3.22 container_name: alpine-playground volumes: - ./common:/common:z command: ["sleep", "infinity"] restart: unless-stopped
Теперь, зайдя в контейнер, мы можем получить доступ к файлам.
gtosss@laptop-dc:~/playground$ podman exec -it alpine-playground sh/ # ls common/message.txt/ # cat common/message.txt hello from host
При этом на хосте контекст у директории и файлов из домашней директории автоматически изменяется после запуска контейнера:
gtosss@laptop-dc:~/playground$ ls -Z common/system_u:object_r:container_file_t:s0:c384,c447 message.txt
Тип файла изменился: user_home_t → container_file_t
Итого, в SELinux помимо классических unix прав (DAC) реализована более строгая система доступов, работающая на основе SELinux политик. Так что доступы к файлам в bind mount покрыты дополнительным слоем безопасности за счет SELinux контекста файлов.
Особенность вторая — User namespace и UID/GID mapping
Если Docker приравнивает root пользователя в контейнере к root пользователю на хосте, то Podman реализует мапинг пользователей таким образом, что root в контейнере может мапиться на конкретного пользователя в хосте.
Вручную, при запуске контейнера мапинг указывается через флаг --uidmap container_uid:host_uid:amount. Например:
podman run --uidmap 0:10000:5000 alpine
Данная команда говорит, что начиная с UID 0 в контейнере, все нужно сопоставлять с 10000 и так 5000 раз.
Составлю наглядную таблицу сопоставлений, для лучшего понимания:
|
в контейнере |
на хосте |
|---|---|
|
0 |
10000 |
|
1 |
10001 |
|
… |
… |
|
4999 |
10499 |
5000 — не входит, так как счет идет с нуля.
Аналогичный флаг есть для групп: --gidmap.
В docker-compose.yml это указывается так:
services: alpine-playground: image: alpine x-podman: uidmaps: - "0:100:100" gidmaps: - "0:100:100"
Поведение зависит так же от флага --userns и переменной окружения PODMAN_USERNS. Podman по умолчанию работает в rootles режиме, и значения PODMAN_USERNS равно пустой строке, это значит, что по умолчанию будет мапинг:
-
0 — root пользователя в контейнере, мапиться на ↓
-
Значение переменной
UID(эта переменная хранит UID текущего пользователя).
Иными словами root в контейнере мапиться на пользователя, который поднимал контейнер.
Помимо дефолтного поведения, описанного выше, есть еще несколько режимов (англ. документация). В основе всех режимов, за исключением режима host, лежит главная идея, заключающаяся в том, чтобы не выдать контейнеру какого-то привилегированного пользователя из хоста:
-
keep-id—$UID:$GIDна хосте = тот же$UID:$GIDв контейнере. Это удобно для волумов, когда они принадлежат конкретному пользователю на хосте. -
auto— Podman сам выбирает уникальный диапазон из специальных конфигурационных файлов/etc/subuid/etc/subgid. -
host— Нет сопоставлений, и механизм user-namespace не работает, root в хосте = root в контейнере (небезопасно).
Для понимания стоит вспомнить что $UID:$GID в контейнере назначается одним из следующих вариантов:
-
--user— Через CLI напримерpodman run --user 1000:1000 alpine -
user: "1000:1000"— Вdocker-compose.yml -
USER 1000:1000— ВDockerfile
Если ничего не указано, контейнер запускается от пользователя, прописанного в образе (или root, если не задан USER в Dockerfile образа).
Как работает механизм мапинга пользователей (user-namespace)
Мапинг пользователей основан на механизме ядра linux, который называется namespaces (англ. документация). Это тема довольно объемная, поэтому я проведу максимально короткий онбординг.
namespaces — Механизм ядра линукс. Он позволяет оборачивать системные ресурсы в абстракцию, которая называется namespace (пространство имен). Эта абстракция позволяет выделить часть ресурсов системы для каких-то процессов и реализовать изоляцию друг от друга.
Простейшими словами — представьте, что мы создали какую-то коробку. На ней написали, что она имеет доступ к таким-то и таким-то системным ресурсам, а теперь в эту коробку мы можем закинуть процессы и они будут иметь доступ к указанным ресурсам.
Теперь про сами ресурсы которыми можно управлять через namespaces.
|
Namespace |
Флаг clone |
Man-страница |
Изолирует |
|---|---|---|---|
|
Cgroup |
|
Корневой каталог cgroup — для управления ресурсами (RAM, CPU и тд) |
|
|
IPC |
|
System V IPC, очереди сообщений POSIX (взаимодействие процессов). |
|
|
Network |
|
Сетевые устройства, стеки, порты, таблицы маршрутизации и т.д. |
|
|
Mount |
|
Точки монтирования (файловая система) |
|
|
PID |
|
Идентификаторы процессов |
|
|
Time |
|
Часы реального времени и монотонные часы |
|
|
User |
|
Идентификаторы пользователей и групп |
|
|
UTS |
|
Имя хоста и доменное имя NIS |
Из всего этого, механизм маппинга пользователей в Podman основан на user_namespaces (предпоследняя строка в таблице). Кратко это работает следующим образом:
Ядро создает специальные файлы в:
-
/proc/$pid/uid_map— для мапинга пользователей -
/proc/$pid/gid_map— для мапинга групп
Где $pid — ID процесса.
Понятнее всего будет на практическом примере. Есть запущенный podman контейнер:
gtosss@laptop-dc:~$ podman ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMEScc86938573d3 docker.io/library/alpine:3.22 sleep infinity 10 days ago Up 10 days alpine-playground
Чтобы увидеть uid_map нам нужно узнать pid процесса. Тут поможет команда podman top <ID контейнера> — она покажет, какие процессы запущены в контейнере:
gtosss@laptop-dc:~$ podman top cc8USER PID PPID %CPU ELAPSED TTY TIME COMMANDroot 1 0 0.000 262h2m7.553624745s ? 0s sleep infinity
-
PID — Process ID.
-
PPID — это
Parent ID, то есть ID процесса, который является родителем.
Но как мы видим, в отличие от docker, podman показывает PID относительно контейнера, а не хостовой машины — иначе бы мы видели значение на много большее чем 0 или 1. Для просмотра хостовых данных, нужно добавить соответствующий аргумент, начинающийся с буквы h (сокращение от host), в нашем случае hpid:
gtosss@laptop-dc:~$ podman top cc8 hpidHPID404796
Теперь зная PID смотрим файл:
gtosss@laptop-dc:~$ cat /proc/404796/uid_map 0 1000 1 1 524288 65536
Читать следует ровно так же, как это реализовано в Podman --uidmap. Выше я уже составлял таблицу сопоставлений, но давайте еще раз продублируем:
-
Первый столбец — User ID внутри namespace (контейнера)
-
Второй столбец — User ID снаружи namespace (на хосте)
-
Третий столбец — Кол-во сопоставлений (range — диапазон)
Первая строка сопоставляет 0 (root в контейнере) c 1000 (gtosss на хосте) один раз.
Все остальные пользователи, начиная от 1 в контейнере будут сопоставляться с UID 524288 и так 65536 раз.
-
1 → 524288
-
2 → 524289
-
3 → 524290
-
и так 65536 раз.
Почему именно такие значения? Напоминаю, что мы смотрим конфигурация user namespaces которую создал podman, а его поведение мапинга контролируется флагом --userns и переменной окружения PODMAN_USERNS. Если там auto, то конфигурация будет взята из /etc/subuid. Заглянем в конфиг:
gtosss@laptop-dc:~$ sudo cat /etc/subuidgtosss:524288:65536
Видим, что в конфиге как раз эти значения.
Итого
-
Docker по умолчанию не используется namespaces, и root внутри контейнера = root на хосте.
-
Podman по умолчанию опирается на механизм user namespaces, реализованный на уровне ядра Linux, что делает его более безопасным.
При этом Docker умеет работать в режиме Rootles mode — но это требует дополнительных действий.
Об авторе
-
10 лет в IT как разработчик, вторая половина карьеры FullStack и DevOps.
-
Веду тг-канал в котором пишу все что думаю об IT и нашем будущем, часто поднимаю социально важные темы, поддерживаю активистов и правозащитников.
-
Последние годы пробовал в себя на руководящих должностях.
-
Есть скромный опыт предпринимательской деятельности.
ссылка на оригинал статьи https://habr.com/ru/articles/1040300/