Главная/Security/Урок

12.3. Пользователи и namespaces

Цели

После этого материала вы сможете:

  • объяснить, что может и чего не может root внутри container'а;
  • объяснить механизм user namespace и отображение UID;
  • настроить userns-remap и назвать, что при этом ломается;
  • сравнить три подхода — USER, userns-remap, rootless — по модели безопасности;
  • выбрать подход под свой сценарий и обосновать выбор;
  • проверить, какая модель действует на конкретной системе.

Предварительные знания

Ключевые термины

ТерминОбъяснение
user namespaceNamespace, отображающий UID container'а в UID host
subuid / subgidДиапазоны UID и GID, выделенные пользователю для отображения
userns-remapРежим демона: все container'ы работают в отдельном user namespace
rootlessРежим, где сам демон работает от непривилегированного пользователя
capabilityОтдельная привилегия root (урок 2.5)

Теория

Что такое root в container

По умолчанию user namespace не изолирован (урок 12.1): UID 0 в container — это UID 0 на host.

Но полномочия ограничены другими механизмами:

ОграничениеЧто отнимает у root
Capabilities (14 из 41)SYS_ADMIN, SYS_MODULE, SYS_TIME и другие
seccompОколо 44 системных вызовов
Mount namespaceВидит только свою файловую систему
Read-only кореньЗапись, если задан
AppArmor / SELinuxДоступ к путям и объектам

Отсюда точная формулировка: root в container — это UID 0 с усечённым набором полномочий.

Что он может:

ВозможностьПоследствие
Читать и писать любые файлы в container'еИзменить приложение, конфигурацию
Устанавливать пакетыПринести инструменты
Привязываться к портам ниже 1024При наличии NET_BIND_SERVICE
Изменять владельца файлов в смонтированном каталогеСоздать файлы root на host (урок 7.5)
Использовать оставшиеся capabilitiesЗависит от набора

Чего не может при настройках по умолчанию:

ОграничениеМеханизм
Загрузить модуль ядраНет SYS_MODULE
Изменить системное времяНет SYS_TIME
Смонтировать произвольную файловую системуНет SYS_ADMIN
Обратиться к устройствам hostНет доступа к /dev
Выполнить заблокированный системный вызовseccomp

Почему root в container всё равно плохо

Три причины, в порядке важности:

1. Уязвимость ядра эксплуатируется проще от UID 0. Многие известные способы побега требуют именно root в container'е.

2. Файлы в смонтированных каталогах. Процесс от UID 0 создаёт на host файлы, принадлежащие root (урок 7.5).

3. Ошибка в конфигурации становится критичной. Забытый --privileged, лишняя capability, смонтированный /etc — от root последствия несравнимо тяжелее.

Отсюда практика: USER в образе — обязательный минимум, независимо от других мер.

User namespace: механизм

User namespace отображает диапазон UID container'а в другой диапазон на host:

text
   container            host
   ─────────            ────
   UID 0        ──►     UID 100000
   UID 1        ──►     UID 100001
   UID 1000     ──►     UID 101000
   ...                  ...
   UID 65535    ──►     UID 165535

Процесс в container видит себя как root и обладает всеми capabilities внутри своего namespace. На host это UID 100000 — обычный непривилегированный пользователь.

Отображение задаётся файлами:

bash
/etc/subuid    dockremap:100000:65536
/etc/subgid    dockremap:100000:65536

Запись означает: пользователю dockremap выделены 65536 UID начиная со 100000.

userns-remap

Режим демона, включающий user namespace для всех container'ов:

json
{
  "userns-remap": "default"
}

Значение default создаёт пользователя dockremap автоматически.

Что даётЧто ломает
root в container ≠ root на hostVolumes принадлежат смещённым UID
Побег даёт непривилегированный UID--privileged несовместим
Работает для всех container'ов сразу--pid=host, --net=host ограничены
Не требует переписывания образовСуществующие volumes нужно мигрировать

Строка про volumes — главная практическая сложность. После включения userns-remap файлы в существующих volumes принадлежат UID 0, а процесс в container'е — UID 100000. Доступ пропадает.

Отключить отображение для отдельного container'а:

bash
docker run --userns=host ...

Это возвращает прежнее поведение — и прежние риски. Флаг нужен, например, для container'ов, которым требуется --privileged.

Rootless Docker

Здесь в отдельном user namespace работает сам демон:

text
rootful:   dockerd (root) ──► container (UID 0 = root на host)
rootless:  dockerd (evg)  ──► container (UID 0 = UID 100000 на host)
                │
                └── сам демон не имеет привилегий
СвойствоЗначение
Демон работает отОбычного пользователя
Компрометация демона даётПрава этого пользователя
Доступ к сокету даётПрава этого пользователя (урок 12.2)
Порты ниже 1024Требуют настройки
Сетевая производительностьНиже: используется пространство пользователя
Часть возможностейНедоступна

Rootless — самая сильная из трёх моделей, потому что снимает главный вектор: доступ к сокету перестаёт быть доступом к root.

Сравнение трёх подходов

USER в образеuserns-remapRootless
Что защищаетПроцесс не root в containerUID container ≠ UID hostДемон не root
root в container возможенНет (по умолчанию)Да, но безвредныйДа, но безвредный
Побег даётUID приложения на hostСмещённый UIDСмещённый UID
Доступ к сокету даётroot на hostroot на hostПрава пользователя
Сложность внедренияНизкаяСредняяВысокая
СовместимостьПолнаяОграниченнаяОграниченная
Кто настраиваетАвтор образаАдминистратор узлаПользователь

Они не взаимоисключающие. USER в образе применяют всегда; userns-remap или rootless — дополнительно, по результатам модели угроз.

Рекомендация по сценариям:

СценарийПодход
Машина разработчикаUSER плюс rootless
Одиночный сервер, свои сервисыUSER плюс userns-remap
CI-агентRootless либо сборка без демона
Многопользовательский узелRootless обязателен
Недоверенный кодНичего из перечисленного не достаточно (урок 12.1)

Внутренний механизм

Как ядро отображает UID

При создании user namespace ядро читает /proc/<pid>/uid_map:

text
0 100000 65536
│    │      └── размер диапазона
│    └───────── первый UID на host
└────────────── первый UID в namespace

Запись означает: UID 0–65535 внутри соответствуют UID 100000–165535 снаружи.

Проверить изнутри container'а:

bash
cat /proc/self/uid_map

Строка 0 0 4294967295 означает, что отображения нет: namespace совпадает с host.

Почему capabilities внутри namespace «полные»

Процесс, создавший user namespace, получает все capabilities в нём. Это не привилегии на host: capability действует только над объектами, принадлежащими этому namespace.

Например, CAP_SYS_ADMIN внутри позволяет монтировать файловые системы в своей mount namespace, но не трогать монтирования host.

Именно на этом основана безопасность rootless-режима: демон обладает полномочиями в своём namespace и никакими — снаружи.


Команды и примеры

Что может root в container

bash
mkdir -p /tmp/users && cd /tmp/users

cat > probe.py <<'PY'
"""Что доступно процессу: UID, capabilities, привилегированные операции."""
from __future__ import annotations

import ctypes
import ctypes.util
import json
import os
from pathlib import Path

CAP_NAMES = {
    0: "CHOWN", 1: "DAC_OVERRIDE", 2: "DAC_READ_SEARCH", 3: "FOWNER", 4: "FSETID",
    5: "KILL", 6: "SETGID", 7: "SETUID", 8: "SETPCAP", 9: "LINUX_IMMUTABLE",
    10: "NET_BIND_SERVICE", 11: "NET_BROADCAST", 12: "NET_ADMIN", 13: "NET_RAW",
    14: "IPC_LOCK", 16: "SYS_MODULE", 17: "SYS_RAWIO", 18: "SYS_CHROOT",
    19: "SYS_PTRACE", 21: "SYS_ADMIN", 22: "SYS_BOOT", 23: "SYS_NICE",
    24: "SYS_RESOURCE", 25: "SYS_TIME", 27: "MKNOD", 29: "AUDIT_WRITE", 31: "SETFCAP",
}


def status(field: str) -> str:
    for line in Path("/proc/self/status").read_text().splitlines():
        if line.startswith(field + ":"):
            return line.split(maxsplit=1)[1].strip()
    return "?"


def caps() -> list[str]:
    mask = int(status("CapEff"), 16)
    return [n for b, n in sorted(CAP_NAMES.items()) if mask & (1 << b)]


def uid_map() -> str:
    try:
        return Path("/proc/self/uid_map").read_text().strip()
    except OSError:
        return "нет"


def try_mount() -> str:
    """Монтирование требует CAP_SYS_ADMIN."""
    libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
    os.makedirs("/tmp/mnt-probe", exist_ok=True)
    res = libc.mount(b"tmpfs", b"/tmp/mnt-probe", b"tmpfs", 0, None)
    if res == 0:
        libc.umount(b"/tmp/mnt-probe")
        return "разрешено"
    return f"отказ (errno {ctypes.get_errno()})"


def try_settime() -> str:
    """Изменение времени требует CAP_SYS_TIME."""
    libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)

    class Timespec(ctypes.Structure):
        _fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)]

    ts = Timespec()
    libc.clock_gettime(0, ctypes.byref(ts))
    res = libc.clock_settime(0, ctypes.byref(ts))
    return "разрешено" if res == 0 else f"отказ (errno {ctypes.get_errno()})"


def try_module() -> str:
    """Загрузка модуля требует CAP_SYS_MODULE."""
    try:
        Path("/proc/sys/kernel/modprobe").read_text()
        return "чтение доступно; загрузка требует SYS_MODULE"
    except OSError as exc:
        return f"отказ: {exc.strerror}"


print(json.dumps({
    "uid": os.getuid(),
    "euid": os.geteuid(),
    "uid_map": uid_map(),
    "capabilities": caps(),
    "capabilities_всего": len(caps()),
    "монтирование tmpfs": try_mount(),
    "изменение времени": try_settime(),
    "модули ядра": try_module(),
}, ensure_ascii=False, indent=2))
PY

echo "═══ root в container по умолчанию ═══"
docker run --rm -v "$PWD/probe.py:/p.py:ro" python:3.13-slim python /p.py

Ожидаемый вывод:

text
═══ root в container по умолчанию ═══
{
  "uid": 0,
  "euid": 0,
  "uid_map": "0          0 4294967295",
  "capabilities": [
    "CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL", "SETGID", "SETUID",
    "SETPCAP", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT", "MKNOD",
    "AUDIT_WRITE", "SETFCAP"
  ],
  "capabilities_всего": 14,
  "монтирование tmpfs": "отказ (errno 1)",
  "изменение времени": "отказ (errno 1)",
  "модули ядра": "чтение доступно; загрузка требует SYS_MODULE"
}

Разбор строки uid_map: 0 0 4294967295 означает, что UID 0 в container отображается в UID 0 на host — отображения нет.

При этом монтирование и изменение времени запрещены: errno 1 — это EPERM. Ограничивают не namespace, а отсутствие SYS_ADMIN и SYS_TIME в наборе из 14 capabilities.

Это и есть точная картина: UID 0 с усечёнными полномочиями.

Что меняет USER

bash
cd /tmp/users
echo "═══ тот же образ, но --user 10001 ═══"
docker run --rm --user 10001:10001 -v "$PWD/probe.py:/p.py:ro" \
    python:3.13-slim python /p.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  uid: {d['uid']}, capabilities: {d['capabilities_всего']}\")
print(f\"  список: {d['capabilities'] or 'пусто'}\")
print(f\"  монтирование: {d['монтирование tmpfs']}\")
"

echo "═══ и с --cap-drop=ALL от root ═══"
docker run --rm --cap-drop=ALL -v "$PWD/probe.py:/p.py:ro" \
    python:3.13-slim python /p.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  uid: {d['uid']}, capabilities: {d['capabilities_всего']}\")
print(f\"  монтирование: {d['монтирование tmpfs']}\")
"

Ожидаемый вывод:

text
═══ тот же образ, но --user 10001 ═══
  uid: 10001, capabilities: 0
  список: пусто
  монтирование: отказ (errno 1)
═══ и с --cap-drop=ALL от root ═══
  uid: 0, capabilities: 0
  монтирование: отказ (errno 1)

Оба варианта дают пустой набор capabilities, но разными путями.

При --user 10001 ядро очищает effective-набор при переходе на непривилегированного пользователя. При --cap-drop=ALL набор очищает Docker, а UID остаётся нулевым.

Практическая разница: во втором случае процесс формально root, и файлы в смонтированных каталогах создаются от root (урок 7.5). Первый вариант этого лишён — ещё один довод за USER в образе.

Файлы на host: с root и без

bash
cd /tmp/users
mkdir -p shared && chmod 777 shared

echo "═══ файл, созданный root в container ═══"
docker run --rm -v "$PWD/shared:/data" alpine:3.21 \
    sh -c 'echo x > /data/from-root.txt'
ls -ln shared/from-root.txt | awk '{print "  владелец: " $3 ":" $4}'

echo "═══ файл, созданный UID 10001 ═══"
docker run --rm --user 10001:10001 -v "$PWD/shared:/data" alpine:3.21 \
    sh -c 'echo x > /data/from-user.txt'
ls -ln shared/from-user.txt | awk '{print "  владелец: " $3 ":" $4}'

echo "═══ удаление обычным пользователем ═══"
rm -f shared/from-user.txt 2>&1 && echo "  from-user.txt: удалён" || echo "  from-user.txt: отказ"
rm -f shared/from-root.txt 2>&1 | tail -1 | sed 's/^/  from-root.txt: /' \
    || echo "  from-root.txt: удалён"

sudo rm -rf shared 2>/dev/null || rm -rf shared

Ожидаемый вывод:

text
═══ файл, созданный root в container ═══
  владелец: 0:0
═══ файл, созданный UID 10001 ═══
  владелец: 10001:10001
═══ удаление обычным пользователем ═══
  from-user.txt: удалён
  from-root.txt: rm: cannot remove 'shared/from-root.txt': Permission denied

Прямое следствие отсутствия user namespace: UID 0 в container — это UID 0 на host, и созданный файл принадлежит настоящему root.

Как выглядит отображение UID

bash
cd /tmp/users
echo "═══ uid_map на host ═══"
cat /proc/self/uid_map | sed 's/^/  /'

echo "═══ uid_map в обычном container ═══"
docker run --rm alpine:3.21 cat /proc/self/uid_map | sed 's/^/  /'

echo "═══ как читать ═══"
cat <<'TXT'
  формат: <первый UID внутри> <первый UID снаружи> <размер диапазона>

  "0 0 4294967295"        отображения нет: UID совпадают
  "0 100000 65536"        UID 0-65535 внутри = UID 100000-165535 снаружи
TXT

echo "═══ настройки subuid на этой машине ═══"
if [ -f /etc/subuid ]; then
    head -3 /etc/subuid | sed 's/^/  /'
else
    echo "  /etc/subuid отсутствует"
fi

Ожидаемый вывод:

text
═══ uid_map на host ═══
           0          0 4294967295
═══ uid_map в обычном container ═══
           0          0 4294967295
═══ как читать ═══
  формат: <первый UID внутри> <первый UID снаружи> <размер диапазона>

  "0 0 4294967295"        отображения нет: UID совпадают
  "0 100000 65536"        UID 0-65535 внутри = UID 100000-165535 снаружи
═══ настройки subuid на этой машине ═══
  evg:100000:65536

Одинаковый uid_map на host и в container — прямое доказательство, что user namespace не изолирован.

Строка в /etc/subuid показывает выделенный пользователю диапазон: он понадобится для rootless-режима.

Настройка userns-remap

bash
cd /tmp/users
cat > setup-userns.md <<'TXT'
# Включение userns-remap

## 1. Конфигурация демона

/etc/docker/daemon.json:

    {
      "userns-remap": "default"
    }

Значение "default" создаёт пользователя dockremap автоматически.
Можно указать существующего пользователя: "userns-remap": "myuser".

## 2. Перезапуск демона

    sudo systemctl restart docker

## 3. Проверка

    docker info --format '{{.SecurityOptions}}'
    # должно появиться: name=userns

    docker run --rm alpine cat /proc/self/uid_map
    # должно быть: 0 100000 65536 (вместо 0 0 4294967295)

## 4. Что сломается

| Проблема | Причина | Решение |
|---|---|---|
| Существующие volumes недоступны | Файлы принадлежат UID 0, процесс — 100000 | chown -R 100000:100000 или пересоздать |
| --privileged не работает | Несовместим с userns | --userns=host для этого container |
| --pid=host, --net=host ограничены | Требуют совпадения namespace | --userns=host |
| Образы скачиваются заново | Отдельное хранилище для remap | Ожидаемо, разово |

## 5. Отключение для отдельного container

    docker run --userns=host ...

Возвращает прежнее поведение и прежние риски. Применять точечно.

## 6. Проверка после включения

    # файл, созданный root в container, на host принадлежит 100000
    docker run --rm -v /tmp/probe:/data alpine sh -c 'touch /data/f'
    ls -ln /tmp/probe/f    # владелец 100000, а не 0
TXT

echo "═══ инструкция подготовлена ═══"
head -12 setup-userns.md | sed 's/^/  /'

echo
echo "═══ текущее состояние демона ═══"
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
    echo "  userns-remap ВКЛЮЧЁН"
    docker run --rm alpine:3.21 cat /proc/self/uid_map | sed 's/^/    uid_map: /'
else
    echo "  userns-remap выключен"
    echo "  (включение требует правки daemon.json и перезапуска демона)"
fi

Ожидаемый вывод:

text
═══ инструкция подготовлена ═══
  # Включение userns-remap
  
  ## 1. Конфигурация демона
  
  /etc/docker/daemon.json:
  
      {
        "userns-remap": "default"
      }
  
  Значение "default" создаёт пользователя dockremap автоматически.
  Можно указать существующего пользователя: "userns-remap": "myuser".

═══ текущее состояние демона ═══
  userns-remap выключен
  (включение требует правки daemon.json и перезапуска демона)

Включение userns-remap меняет поведение всех container'ов на узле и требует перезапуска демона — поэтому в уроке приводится инструкция, а не выполняется автоматически.

Симуляция отображения UID

Механизм user namespace можно наблюдать без перенастройки демона:

bash
cd /tmp/users
echo "═══ создаём user namespace вручную ═══"
if command -v unshare > /dev/null 2>&1; then
    echo "  на host:"
    printf '    UID: %s, uid_map: %s\n' "$(id -u)" "$(cat /proc/self/uid_map | tr -s ' ')"

    echo "  внутри нового user namespace:"
    unshare --user --map-root-user sh -c '
        printf "    UID: %s (внутри кажемся root)\n" "$(id -u)"
        printf "    uid_map: %s\n" "$(cat /proc/self/uid_map | tr -s " ")"
        printf "    capabilities: %s\n" "$(grep CapEff /proc/self/status | awk "{print \$2}")"
        printf "    чтение /etc/shadow: "
        cat /etc/shadow > /dev/null 2>&1 && echo "разрешено" || echo "ОТКАЗ"
    ' 2>/dev/null || echo "    (unshare требует поддержки в ядре)"
else
    echo "  утилита unshare недоступна"
fi

echo "═══ что это показывает ═══"
cat <<'TXT'
  Внутри namespace процесс видит себя как root (UID 0) и имеет
  полный набор capabilities. Но uid_map показывает отображение
  на реальный UID, и файлы root по-прежнему недоступны.

  Это ровно та модель, которую даёт userns-remap и rootless:
  root внутри — обычный пользователь снаружи.
TXT

Ожидаемый вывод:

text
═══ создаём user namespace вручную ═══
  на host:
    UID: 1000, uid_map: 0 0 4294967295
  внутри нового user namespace:
    UID: 0 (внутри кажемся root)
    uid_map: 0 1000 1
    capabilities: 000001ffffffffff
    чтение /etc/shadow: ОТКАЗ
═══ что это показывает ═══
  Внутри namespace процесс видит себя как root (UID 0) и имеет
  полный набор capabilities. Но uid_map показывает отображение
  на реальный UID, и файлы root по-прежнему недоступны.

  Это ровно та модель, которую даёт userns-remap и rootless:
  root внутри — обычный пользователь снаружи.

Три строки объясняют весь механизм.

UID: 0 — процесс считает себя root.

uid_map: 0 1000 1 — UID 0 внутри соответствует UID 1000 снаружи.

capabilities: 000001ffffffffff — полный набор, но только в этом namespace: /etc/shadow по-прежнему недоступен, потому что файл принадлежит настоящему root, а namespace его UID не отображает.

Сравнение трёх моделей

bash
cd /tmp/users
cat > compare.py <<'PY'
"""Сравнение подходов к ограничению привилегий."""
from __future__ import annotations

MODELS = {
    "USER в образе": {
        "что защищает": "процесс не root внутри container",
        "root внутри возможен": "нет",
        "побег даёт": "UID приложения на host",
        "сокет даёт": "root на host",
        "сложность": 1,
        "совместимость": 5,
        "кто настраивает": "автор образа",
    },
    "userns-remap": {
        "что защищает": "UID container отображён в другой диапазон",
        "root внутри возможен": "да, но безвредный",
        "побег даёт": "смещённый UID (100000+)",
        "сокет даёт": "root на host",
        "сложность": 3,
        "совместимость": 3,
        "кто настраивает": "администратор узла",
    },
    "rootless": {
        "что защищает": "сам демон не имеет привилегий",
        "root внутри возможен": "да, но безвредный",
        "побег даёт": "смещённый UID",
        "сокет даёт": "права пользователя",
        "сложность": 4,
        "совместимость": 3,
        "кто настраивает": "пользователь",
    },
}

FIELDS = ["что защищает", "root внутри возможен", "побег даёт",
          "сокет даёт", "кто настраивает"]

print(f"  {'свойство':<24} " + " ".join(f"{name:<26}" for name in MODELS))
print("  " + "─" * 104)
for field in FIELDS:
    row = f"  {field:<24} "
    row += " ".join(f"{MODELS[name][field]:<26}" for name in MODELS)
    print(row[:106])

print()
print(f"  {'сложность (1-5)':<24} " +
      " ".join(f"{str(MODELS[n]['сложность']):<26}" for n in MODELS))
print(f"  {'совместимость (1-5)':<24} " +
      " ".join(f"{str(MODELS[n]['совместимость']):<26}" for n in MODELS))

print()
print("  Ключевая строка — «сокет даёт»: только rootless снимает этот вектор.")
print("  Подходы НЕ взаимоисключающие: USER применяют всегда,")
print("  остальное — дополнительно по модели угроз.")
PY

python3 compare.py

Ожидаемый вывод:

text
  свойство                 USER в образе              userns-remap               rootless                  
  ────────────────────────────────────────────────────────────────────────────────────────────────────────
  что защищает             процесс не root внутри con UID container отображён в  сам демон не имеет привиле
  root внутри возможен     нет                        да, но безвредный          да, но безвредный         
  побег даёт               UID приложения на host     смещённый UID (100000+)    смещённый UID             
  сокет даёт               root на host               root на host               права пользователя        
  кто настраивает          автор образа               администратор узла         пользователь              

  сложность (1-5)          1                          3                          4                         
  совместимость (1-5)      5                          3                          3                         

  Ключевая строка — «сокет даёт»: только rootless снимает этот вектор.
  Подходы НЕ взаимоисключающие: USER применяют всегда,
  остальное — дополнительно по модели угроз.

Строка «сокет даёт» — итог сравнения. USER и userns-remap ограничивают процесс в container'е, но не меняют того, что даёт доступ к сокету (урок 12.2).

Проверка действующей модели

bash
cd /tmp/users
cat > check-model.sh <<'SH'
#!/usr/bin/env bash
# Определяет, какая модель ограничения привилегий действует на системе.
set -uo pipefail

printf '\n── Демон ──\n'
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q rootless; then
    printf '  режим: ROOTLESS\n'
    printf '  сокет даёт: права вашего пользователя\n'
    mode="rootless"
elif docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
    printf '  режим: rootful + userns-remap\n'
    printf '  сокет даёт: root на host\n'
    mode="userns"
else
    printf '  режим: rootful (демон работает от root)\n'
    printf '  сокет даёт: root на host\n'
    mode="rootful"
fi

printf '\n── Отображение UID ──\n'
map="$(docker run --rm alpine:3.21 cat /proc/self/uid_map 2>/dev/null | tr -s ' ' | sed 's/^ //')"
printf '  uid_map в container: %s\n' "$map"
case "$map" in
    "0 0 "*) printf '  → отображения нет: UID 0 внутри = UID 0 на host\n' ;;
    *)       printf '  → отображение действует\n' ;;
esac

printf '\n── Проверка: файл от root в container ──\n'
probe="$(mktemp -d)"
docker run --rm -v "$probe:/data" alpine:3.21 sh -c 'echo x > /data/f' 2>/dev/null
owner="$(stat -c '%u:%g' "$probe/f" 2>/dev/null || echo '?')"
printf '  владелец на host: %s\n' "$owner"
case "$owner" in
    "0:0") printf '  → UID 0 в container = настоящий root\n' ;;
    "?")   printf '  → проверка не удалась\n' ;;
    *)     printf '  → UID отображён, root в container безвреден\n' ;;
esac
rm -rf "$probe" 2>/dev/null || sudo rm -rf "$probe" 2>/dev/null

printf '\n── Рекомендации ──\n'
case "$mode" in
    rootful)
        printf '  1. USER в образе — обязательно (сейчас единственная защита)\n'
        printf '  2. Рассмотреть userns-remap для узла\n'
        printf '  3. Rootless — если возможно по совместимости\n'
        printf '  4. Не монтировать сокет никуда\n'
        ;;
    userns)
        printf '  1. USER в образе — всё равно применять\n'
        printf '  2. Сокет по-прежнему опасен: не монтировать\n'
        ;;
    rootless)
        printf '  1. USER в образе — всё равно применять\n'
        printf '  2. Основные векторы закрыты\n'
        ;;
esac
SH
chmod +x check-model.sh
./check-model.sh

cd /tmp && rm -rf /tmp/users

Ожидаемый вывод:

text
── Демон ──
  режим: rootful (демон работает от root)
  сокет даёт: root на host

── Отображение UID ──
  uid_map в container: 0 0 4294967295
  → отображения нет: UID 0 внутри = UID 0 на host

── Проверка: файл от root в container ──
  владелец на host: 0:0
  → UID 0 в container = настоящий root

── Рекомендации ──
  1. USER в образе — обязательно (сейчас единственная защита)
  2. Рассмотреть userns-remap для узла
  3. Rootless — если возможно по совместимости
  4. Не монтировать сокет никуда

Три проверки дают согласованный ответ, и скрипт выдаёт рекомендации, соответствующие действующей модели. Это удобнее общих советов: они зависят от того, что уже настроено.


Практическое упражнение

Задание. Определите действующую модель привилегий и обоснуйте выбор подхода.

Требования:

  1. Показать, что может и чего не может root в container: capabilities, монтирование, изменение времени.
  2. Показать разницу между --user 10001 и --cap-drop=ALL от root: в обоих случаях capabilities пусты, но владелец создаваемых файлов различается.
  3. Показать механизм user namespace через unshare: root внутри, обычный UID снаружи.
  4. Определить действующую модель на системе тремя независимыми проверками.
  5. Составить таблицу сравнения трёх подходов с обоснованием выбора для конкретного сценария.
  6. Записать решение с условием пересмотра.

Подсказки

Подсказка 1

Для пункта 1 привилегированные операции проверяются через ctypes и libc, а не через утилиты — так надёжнее.

Подсказка 2

Пункт 2 проверяется владельцем файла в смонтированном каталоге.

Подсказка 3

Пункт 4: docker info, uid_map в container и владелец созданного файла должны давать согласованный ответ.

Решение

Показать решение
bash
mkdir -p /tmp/userns/{probe,shared} && cd /tmp/userns

cat > probe/capabilities.py <<'PY'
"""Проверка привилегий процесса: capabilities и привилегированные операции."""
from __future__ import annotations

import ctypes
import ctypes.util
import json
import os
from pathlib import Path

CAP_NAMES = {
    0: "CHOWN", 1: "DAC_OVERRIDE", 2: "DAC_READ_SEARCH", 3: "FOWNER", 4: "FSETID",
    5: "KILL", 6: "SETGID", 7: "SETUID", 8: "SETPCAP", 9: "LINUX_IMMUTABLE",
    10: "NET_BIND_SERVICE", 11: "NET_BROADCAST", 12: "NET_ADMIN", 13: "NET_RAW",
    14: "IPC_LOCK", 15: "IPC_OWNER", 16: "SYS_MODULE", 17: "SYS_RAWIO",
    18: "SYS_CHROOT", 19: "SYS_PTRACE", 20: "SYS_PACCT", 21: "SYS_ADMIN",
    22: "SYS_BOOT", 23: "SYS_NICE", 24: "SYS_RESOURCE", 25: "SYS_TIME",
    26: "SYS_TTY_CONFIG", 27: "MKNOD", 28: "LEASE", 29: "AUDIT_WRITE",
    30: "AUDIT_CONTROL", 31: "SETFCAP",
}

_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)


def status(field: str) -> str:
    try:
        for line in Path("/proc/self/status").read_text().splitlines():
            if line.startswith(field + ":"):
                return line.split(maxsplit=1)[1].strip()
    except OSError:
        pass
    return "?"


def caps_list() -> list[str]:
    raw = status("CapEff")
    if raw == "?":
        return []
    mask = int(raw, 16)
    return [n for b, n in sorted(CAP_NAMES.items()) if mask & (1 << b)]


def op_mount() -> str:
    """Требует CAP_SYS_ADMIN."""
    target = "/tmp/_mnt_probe"
    try:
        os.makedirs(target, exist_ok=True)
    except OSError as exc:
        return f"нет доступа к /tmp: {exc.strerror}"
    ctypes.set_errno(0)
    if _libc.mount(b"tmpfs", target.encode(), b"tmpfs", 0, None) == 0:
        _libc.umount(target.encode())
        return "РАЗРЕШЕНО"
    return f"отказ (errno {ctypes.get_errno()})"


def op_settime() -> str:
    """Требует CAP_SYS_TIME."""
    class Timespec(ctypes.Structure):
        _fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)]

    ts = Timespec()
    _libc.clock_gettime(0, ctypes.byref(ts))
    ctypes.set_errno(0)
    if _libc.clock_settime(0, ctypes.byref(ts)) == 0:
        return "РАЗРЕШЕНО"
    return f"отказ (errno {ctypes.get_errno()})"


def op_chroot() -> str:
    """Требует CAP_SYS_CHROOT."""
    ctypes.set_errno(0)
    pid = os.fork()
    if pid == 0:
        rc = _libc.chroot(b"/tmp")
        os._exit(0 if rc == 0 else 1)
    _, st = os.waitpid(pid, 0)
    return "РАЗРЕШЕНО" if os.waitstatus_to_exitcode(st) == 0 else "отказ"


def op_read_shadow() -> str:
    try:
        Path("/etc/shadow").read_bytes()
        return "РАЗРЕШЕНО"
    except OSError as exc:
        return f"отказ: {exc.strerror}"


if __name__ == "__main__":
    print(json.dumps({
        "uid": os.getuid(),
        "euid": os.geteuid(),
        "uid_map": Path("/proc/self/uid_map").read_text().strip()
        if Path("/proc/self/uid_map").exists() else "нет",
        "cap_eff": status("CapEff"),
        "capabilities": caps_list(),
        "всего_capabilities": len(caps_list()),
        "операции": {
            "монтирование (SYS_ADMIN)": op_mount(),
            "изменение времени (SYS_TIME)": op_settime(),
            "chroot (SYS_CHROOT)": op_chroot(),
            "чтение /etc/shadow": op_read_shadow(),
        },
    }, ensure_ascii=False, indent=2))
PY

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
run_probe() { docker run --rm "$@" -v "$PWD/probe/capabilities.py:/p.py:ro" \
              python:3.13-slim python /p.py 2>/dev/null; }
field() { python3 -c "
import json, sys
d = json.load(sys.stdin)
keys = '$1'.split('.')
v = d
for k in keys:
    v = v[k]
print(v)
"; }

printf '\n═══ Пункт 1: что может root в container ═══\n'
out_root="$(run_probe)"
echo "$out_root" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    uid={d['uid']} capabilities={d['всего_capabilities']}\")
print(f\"    uid_map: {' '.join(d['uid_map'].split())}\")
for op, res in d['операции'].items():
    print(f'    {op:<30} {res}')
"
caps_root="$(echo "$out_root" | field "всего_capabilities")"
mount_root="$(echo "$out_root" | field "операции.монтирование (SYS_ADMIN)")"
[ "$caps_root" = "14" ] && ok "14 capabilities из 41 — усечённый набор" \
    || ok "capabilities: $caps_root"
case "$mount_root" in
    отказ*) ok "монтирование запрещено: нет SYS_ADMIN" ;;
    *) bad "монтирование разрешено — проверьте настройки" ;;
esac

printf '\n═══ Пункт 2: --user против --cap-drop=ALL ═══\n'
printf '    %-22s %-6s %-14s %s\n' "конфигурация" "uid" "capabilities" "владелец файла"
chmod 777 shared
for cfg in "по умолчанию:" "--user 10001:10001" "--cap-drop=ALL"; do
    case "$cfg" in
        "по умолчанию:") args=(); label="по умолчанию"; fname="default" ;;
        "--user 10001:10001") args=(--user 10001:10001); label="--user 10001"; fname="user" ;;
        *) args=(--cap-drop=ALL); label="--cap-drop=ALL"; fname="capdrop" ;;
    esac
    out="$(run_probe "${args[@]}")"
    uid="$(echo "$out" | field uid)"
    ncaps="$(echo "$out" | field "всего_capabilities")"
    docker run --rm "${args[@]}" -v "$PWD/shared:/data" alpine:3.21 \
        sh -c "echo x > /data/$fname.txt" 2>/dev/null
    owner="$(stat -c '%u:%g' "shared/$fname.txt" 2>/dev/null || echo '?')"
    printf '    %-22s %-6s %-14s %s\n' "$label" "$uid" "$ncaps" "$owner"
done

owner_user="$(stat -c '%u' shared/user.txt 2>/dev/null || echo '?')"
owner_capdrop="$(stat -c '%u' shared/capdrop.txt 2>/dev/null || echo '?')"
caps_user="$(run_probe --user 10001:10001 | field "всего_capabilities")"
caps_drop="$(run_probe --cap-drop=ALL | field "всего_capabilities")"
[ "$caps_user" = "0" ] && [ "$caps_drop" = "0" ] \
    && ok "оба варианта дают пустой набор capabilities" || bad "capabilities: $caps_user и $caps_drop"
[ "$owner_user" = "10001" ] && [ "$owner_capdrop" = "0" ] \
    && ok "но владелец файла различается: 10001 против 0 — довод за USER" \
    || bad "владельцы: $owner_user и $owner_capdrop"
sudo rm -f shared/*.txt 2>/dev/null || rm -f shared/*.txt 2>/dev/null

printf '\n═══ Пункт 3: механизм user namespace ═══\n'
if command -v unshare > /dev/null 2>&1; then
    printf '    на host: uid=%s uid_map=%s\n' "$(id -u)" "$(tr -s ' ' < /proc/self/uid_map | sed 's/^ //')"
    unshare_out="$(unshare --user --map-root-user sh -c '
        printf "uid=%s|map=%s|caps=%s|shadow=%s" \
            "$(id -u)" \
            "$(tr -s " " < /proc/self/uid_map | sed "s/^ //")" \
            "$(grep CapEff /proc/self/status | awk "{print \$2}")" \
            "$(cat /etc/shadow > /dev/null 2>&1 && echo разрешено || echo ОТКАЗ)"
    ' 2>/dev/null)"
    if [ -n "$unshare_out" ]; then
        IFS='|' read -r u m c s <<< "$unshare_out"
        printf '    в namespace: %s %s\n' "$u" "$m"
        printf '                 %s %s\n' "$c" "$s"
        echo "$u" | grep -q 'uid=0' && echo "$s" | grep -q 'ОТКАЗ' \
            && ok "внутри root, снаружи обычный UID: файлы root недоступны" \
            || bad "поведение неожиданное"
    else
        printf '    unshare недоступен в этой среде\n'
        ok "механизм описан, проверка пропущена"
    fi
else
    printf '    утилита unshare отсутствует\n'
    ok "механизм описан, проверка пропущена"
fi

printf '\n═══ Пункт 4: действующая модель, три проверки ═══\n'
# Проверка 1: режим демона
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q rootless; then
    mode="rootless"
elif docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
    mode="userns-remap"
else
    mode="rootful"
fi
printf '    1. режим демона:        %s\n' "$mode"

# Проверка 2: uid_map
map="$(docker run --rm alpine:3.21 cat /proc/self/uid_map 2>/dev/null | tr -s ' ' | sed 's/^ //')"
printf '    2. uid_map в container: %s\n' "$map"
case "$map" in
    "0 0 "*) map_verdict="отображения нет" ;;
    *)       map_verdict="отображение действует" ;;
esac
printf '       → %s\n' "$map_verdict"

# Проверка 3: владелец файла
docker run --rm -v "$PWD/shared:/data" alpine:3.21 sh -c 'echo x > /data/model.txt' 2>/dev/null
owner="$(stat -c '%u:%g' shared/model.txt 2>/dev/null || echo '?')"
printf '    3. файл от root:        владелец %s\n' "$owner"
case "$owner" in
    "0:0") owner_verdict="UID 0 в container = настоящий root" ;;
    *)     owner_verdict="UID отображён" ;;
esac
printf '       → %s\n' "$owner_verdict"
sudo rm -f shared/model.txt 2>/dev/null || rm -f shared/model.txt 2>/dev/null

# Согласованность
consistent=0
if [ "$mode" = "rootful" ] && [ "$map_verdict" = "отображения нет" ] && [ "$owner" = "0:0" ]; then
    consistent=1
elif [ "$mode" != "rootful" ] && [ "$map_verdict" = "отображение действует" ]; then
    consistent=1
fi
[ "$consistent" -eq 1 ] && ok "три проверки согласованы: модель — $mode" \
    || bad "проверки противоречат друг другу"

printf '\n═══ Пункт 5: сравнение подходов ═══\n'
python3 - <<'PY'
MODELS = [
    ("USER в образе", "процесс не root в container", "UID приложения",
     "root на host", 1, 5),
    ("userns-remap",  "UID отображён в другой диапазон", "смещённый UID",
     "root на host", 3, 3),
    ("rootless",      "демон без привилегий", "смещённый UID",
     "права пользователя", 4, 3),
]

print(f"    {'подход':<16} {'побег даёт':<18} {'сокет даёт':<22} {'слож.':>5} {'совм.':>6}")
print("    " + "─" * 72)
for name, protects, escape, socket_gives, complexity, compat in MODELS:
    print(f"    {name:<16} {escape:<18} {socket_gives:<22} {complexity:>5} {compat:>6}")

print()
print("    Вывод: только rootless меняет последствия доступа к сокету.")
print("    USER применяется всегда и не конфликтует с остальными.")
PY
ok "таблица сравнения составлена"

printf '\n═══ Пункт 6: решение ═══\n'
cat > DECISION.md <<TXT
# Решение по модели привилегий

Дата: 2026-07-31. Действующий режим демона на момент решения: $mode.

## Применяем

**USER 10001:10001 в образе — безусловно.**

Обоснование: единственная мера, не требующая изменений на узле и
не имеющая ограничений совместимости. Снимает вектор «файлы от root
в смонтированных каталогах» и усложняет эксплуатацию уязвимостей ядра.

## Не применяем: userns-remap

Обоснование: узел используется одной командой, недоверенный код не
запускается. Внедрение требует миграции существующих volumes
(chown -R 100000:100000) и ломает container'ы, которым нужен --privileged.
Выигрыш не покрывает стоимость перехода при текущей модели угроз.

**Условие пересмотра:** появление на узле сервисов других команд;
запуск кода, полученного извне; требование аудита.

## Не применяем: rootless

Обоснование: требуется публикация портов ниже 1024 без дополнительной
настройки и полная производительность сети. Rootless накладывает
ограничения на оба пункта.

**Условие пересмотра:** переход на узлы без прав администратора;
необходимость снять вектор доступа к сокету полностью.

## Компенсирующая мера

Поскольку rootless не применяется, доступ к сокету остаётся
эквивалентом root ([урок 12.2]). Компенсируем запретом:

- Docker socket не монтируется ни в один container.
- Проверка в CI: grep по конфигурациям на docker.sock.
- Состав группы docker пересматривается ежеквартально.
TXT
head -14 DECISION.md | sed 's/^/    /'
[ -s DECISION.md ] && grep -q "Условие пересмотра" DECISION.md \
    && ok "решение записано с обоснованием и условиями пересмотра" \
    || bad "решение неполно"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

cd /tmp && rm -rf /tmp/userns
exit "$fail"

Ожидаемый вывод:

text
═══ Пункт 1: что может root в container ═══
    uid=0 capabilities=14
    uid_map: 0 0 4294967295
    монтирование (SYS_ADMIN)       отказ (errno 1)
    изменение времени (SYS_TIME)   отказ (errno 1)
    chroot (SYS_CHROOT)            РАЗРЕШЕНО
    чтение /etc/shadow             РАЗРЕШЕНО
  ✓ 14 capabilities из 41 — усечённый набор
  ✓ монтирование запрещено: нет SYS_ADMIN

═══ Пункт 2: --user против --cap-drop=ALL ═══
    конфигурация           uid    capabilities   владелец файла
    по умолчанию           0      14             0:0
    --user 10001           10001  0              10001:10001
    --cap-drop=ALL         0      0              0:0
  ✓ оба варианта дают пустой набор capabilities
  ✓ но владелец файла различается: 10001 против 0 — довод за USER

═══ Пункт 3: механизм user namespace ═══
    на host: uid=1000 uid_map=0 0 4294967295
    в namespace: uid=0 map=0 1000 1
                 caps=000001ffffffffff shadow=ОТКАЗ
  ✓ внутри root, снаружи обычный UID: файлы root недоступны

═══ Пункт 4: действующая модель, три проверки ═══
    1. режим демона:        rootful
    2. uid_map в container: 0 0 4294967295
       → отображения нет
    3. файл от root:        владелец 0:0
       → UID 0 в container = настоящий root
  ✓ три проверки согласованы: модель — rootful

═══ Пункт 5: сравнение подходов ═══
    подход           побег даёт         сокет даёт             слож. совм.
    ────────────────────────────────────────────────────────────────────────
    USER в образе    UID приложения     root на host               1      5
    userns-remap     смещённый UID      root на host               3      3
    rootless         смещённый UID      права пользователя         4      3

    Вывод: только rootless меняет последствия доступа к сокету.
    USER применяется всегда и не конфликтует с остальными.
  ✓ таблица сравнения составлена

═══ Пункт 6: решение ═══
    # Решение по модели привилегий
    
    Дата: 2026-07-31. Действующий режим демона на момент решения: rootful.
    
    ## Применяем
    
    **USER 10001:10001 в образе — безусловно.**
    ...
  ✓ решение записано с обоснованием и условиями пересмотра

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Обратите внимание на пункт 1: chroot и чтение /etc/shadow разрешены. Первое — потому что SYS_CHROOT входит в набор по умолчанию; второе — потому что процесс работает от настоящего UID 0, а файл принадлежит root. Ни то ни другое не является побегом, но показывает, что «root в container» — это всё-таки root.

Три решения, определяющие качество.

Пункт 2 сравнивает не только capabilities, но и владельца созданного файла. По количеству привилегий варианты неотличимы — оба дают ноль. Различие проявляется только на побочном эффекте: --cap-drop=ALL оставляет UID 0, и файлы на host принадлежат root. Это и есть практический довод за USER, который не виден при сравнении одних лишь capabilities.

Привилегированные операции проверяются через libc, а не через утилиты. Команда mount могла бы отсутствовать в образе, и отказ означал бы «нет программы», а не «нет привилегии». Прямой системный вызов даёт errno — однозначный ответ о причине.

Пункт 4 требует согласованности трёх проверок. Каждая по отдельности может ввести в заблуждение: docker info показывает настройку, а не факт; uid_map можно прочитать в container'е с --userns=host; владелец файла зависит от смонтированного каталога. Совпадение всех трёх — надёжный вывод.

Чего решение не делает. userns-remap не включается: это меняет поведение всех container'ов на узле и требует перезапуска демона, что недопустимо делать автоматически в учебном скрипте. Проверка выполняется на текущем режиме, а инструкция по переходу приведена отдельно. Не измеряется и цена rootless-режима: ограничения сети и производительности зависят от сценария и требуют собственных измерений.

Проверка результата

bash
docker run --rm alpine:3.21 sh -c '
    echo "uid: $(id -u)"
    echo "uid_map: $(cat /proc/self/uid_map)"
    echo "capabilities: $(grep CapEff /proc/self/status | awk "{print \$2}")"
'

Строка 0 0 4294967295 в uid_map означает, что user namespace не изолирован: UID 0 в container — это root на host.

Типичные ошибки

ОшибкаПричинаИсправление
Считают root в container безобидным«Он же изолирован»UID 0 = UID 0 на host без userns
--cap-drop=ALL вместо USERCapabilities обнуляютсяФайлы на host всё равно от root
Включают userns-remap без миграции volumesНе знали о последствииДанные становятся недоступны
Ожидают, что userns-remap закроет вектор сокетаЛогично предположитьДемон по-прежнему root
Считают rootless полной заменой hardeningНазвание обнадёживаетUSER применяют всё равно
--userns=host для удобстваЧто-то не заработалоВозвращает прежние риски
Не проверяют действующую модельСчитают известнойТри проверки занимают минуту
Правят /etc/subuid вручную без нуждыКажется настройкойДиапазоны выделяются автоматически

Контрольные вопросы

На понимание:

  1. Что означает строка 0 0 4294967295 в uid_map?
  2. Что может и чего не может root в container при настройках по умолчанию?
  3. Чем --user 10001 отличается от --cap-drop=ALL от root?
  4. Как работает отображение UID в user namespace?
  5. Почему userns-remap не закрывает вектор доступа к сокету?

На применение:

  1. Как определить действующую модель привилегий на системе?
  2. Что нужно сделать с существующими volumes при включении userns-remap?
  3. Какой подход выбрать для многопользовательского узла?

На диагностику:

  1. После включения userns-remap приложение не может писать в volume. Причина?
  2. Файлы, созданные container'ом, принадлежат UID 100000. Что это означает?

Краткое резюме

  1. По умолчанию user namespace не изолирован: UID 0 в container — это root на host.
  2. Полномочия root в container усечены capabilities, seccomp и namespaces.
  3. Он может писать файлы, устанавливать пакеты, менять владельца в смонтированных каталогах.
  4. Не может монтировать, менять время и загружать модули — нет соответствующих capabilities.
  5. Root в container плох тремя вещами: проще эксплуатация ядра, файлы от root, цена ошибки.
  6. USER в образе — обязательный минимум независимо от прочих мер.
  7. User namespace отображает диапазон UID container'а в другой диапазон host.
  8. userns-remap включает отображение для всех container'ов узла.
  9. Главная сложность внедрения — существующие volumes принадлежат прежним UID.
  10. Rootless запускает сам демон без привилегий и снимает вектор доступа к сокету.
  11. Три подхода не взаимоисключающие: USER всегда, остальное — по модели угроз.
  12. Действующую модель определяют тремя проверками: режим демона, uid_map, владелец файла.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: userns-remaphttps://docs.docker.com/engine/security/userns-remap/Настройка, ограничения, --userns=host
Docker: rootless modehttps://docs.docker.com/engine/security/rootless/Модель, требования, ограничения
Docker: securityhttps://docs.docker.com/engine/security/Роль user namespace в модели безопасности
Linux: user_namespaces(7)https://man7.org/linux/man-pages/man7/user_namespaces.7.htmlМеханизм отображения, capabilities
Linux: subuid(5)https://man7.org/linux/man-pages/man5/subuid.5.htmlФормат файлов диапазонов
Linux: capabilities(7)https://man7.org/linux/man-pages/man7/capabilities.7.htmlПолный список, наборы
Dockerfile: USERhttps://docs.docker.com/reference/dockerfile/#userЗадание пользователя в образе

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Capabilities и privileged
Главное оглавление

Markdown на GitHub ↗