Главная/Docker internals/Урок

17.3. Namespaces: углублённо

Цели

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

  • назвать все восемь типов namespaces и механизм изоляции каждого;
  • различить три системных вызова, работающих с namespaces, и выбрать нужный;
  • прочитать /proc/PID/ns/ и определить, какие namespaces у процессов общие;
  • войти в отдельный namespace, оставив остальные, и объяснить, зачем так делают;
  • создать изолированное окружение вручную, без Docker;
  • объяснить, почему user namespace особенный, и показать отображение UID.

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

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

ТерминОбъяснение
namespaceИзолированное представление ресурса ядра
cloneСоздание процесса с новыми namespaces
unshareОтделение текущего процесса в новые namespaces
setnsВход в существующий namespace
inodeИдентификатор namespace в /proc/PID/ns/

Теория

Восемь типов

ТипФлаг cloneЧто изолируетВведён
mntCLONE_NEWNSТочки монтированияПервым
utsCLONE_NEWUTSИмя хоста и домена
ipcCLONE_NEWIPCОчереди сообщений, семафоры, разделяемая память
pidCLONE_NEWPIDПространство идентификаторов процессов
netCLONE_NEWNETИнтерфейсы, маршруты, порты, netfilter
userCLONE_NEWUSERОтображение UID и GID, capabilities
cgroupCLONE_NEWCGROUPКорень иерархии cgroups
timeCLONE_NEWTIMEСмещение монотонных часовПоследним

Флаг mnt называется CLONE_NEWNS без буквы M — историческое имя: когда его вводили, других namespaces не существовало.

Docker по умолчанию создаёт шесть: mnt, uts, ipc, pid, net, cgroup. Не создаёт user (без userns-remap) и time.

Отсюда утверждение из урока 12.3: UID внутри и снаружи совпадают, потому что user namespace не создан.

Три системных вызова

ВызовЧто делаетКто применяет
cloneСоздаёт новый процесс сразу в новых namespacesrunc при запуске
unshareПереводит текущий процесс в новые namespacesУтилита unshare
setnsПомещает текущий процесс в существующий namespacensenter, docker exec

Различие clone и unshare имеет практическое последствие для PID namespace:

text
clone(CLONE_NEWPID)     новый процесс становится PID 1 в новом namespace
unshare(CLONE_NEWPID)   текущий процесс ОСТАЁТСЯ в старом;
                        PID 1 получит его следующий потомок

Поэтому unshare --pid без --fork даёт неожиданный результат: сам процесс в старом namespace, а ps показывает странное.

Правильно: unshare --pid --fork --mount-proc.

Файлы в /proc/PID/ns/

bash
ls -l /proc/self/ns/

Каждый файл — символическая ссылка вида net:[4026531840]. Число в скобках — inode namespace.

Два свойства:

Одинаковый inode означает один и тот же namespace. Это способ сравнить: разделяют ли два процесса namespace.

Открытый дескриптор удерживает namespace живым. Namespace существует, пока в нём есть процесс или пока кто-то держит открытым файл из /proc/PID/ns/. Отсюда приём: ip netns сохраняет сетевые namespaces, монтируя эти файлы в /var/run/netns/.

Вход в отдельный namespace

nsenter принимает флаги по одному типу namespace:

bash
nsenter -t PID -n           только сетевой
nsenter -t PID -m -n        монтирования и сеть
nsenter -t PID -a           все

Практический смысл частичного входа разобран в уроке 13.5: вход без -m оставляет файловую систему host, и запускаемая программа берётся оттуда.

text
nsenter -t PID -n ss -tlnp        ss — с host, сеть — из container'а ✓
nsenter -t PID -m -n ss -tlnp     ss ищется В CONTAINER'е — может не быть ✗

Это ключевой приём диагностики образов без утилит.

User namespace особенный

Три отличия от остальных:

Единственный, который может создать непривилегированный пользователь. Отсюда возможность rootless-режима.

Внутри него процесс получает полный набор capabilities — но только по отношению к ресурсам, принадлежащим этому namespace.

Задаёт отображение UID и GID через файлы uid_map и gid_map:

text
0 100000 65536
│    │      └── количество
│    └── первый UID снаружи
└── первый UID внутри

Строка означает: UID 0 внутри соответствует 100000 снаружи, и так для 65536 значений.

Без user namespace карта выглядит так:

text
0 0 4294967295

Полное тождество — то есть изоляции нет (урок 12.3).

Ограничение записи карты: её можно записать только один раз, и для отображения больше одной строки нужен CAP_SETUID в родительском namespace.

Time namespace

Изолирует не текущее время, а смещение монотонных часов:

ЧтоИзолируется
CLOCK_MONOTONICДа
CLOCK_BOOTTIMEДа
CLOCK_REALTIMEНет

Настоящее время остаётся общим с host. Изменить его внутри container'а нельзя без CAP_SYS_TIME, а изменение затронуло бы всю систему (урок 12.4).

Docker time namespace не создаёт: практической нужды в отдельном uptime у container'а обычно нет.


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

Почему PID namespace нельзя покинуть

Процесс, вошедший в PID namespace через setns, сам в него не переходит: переходят его потомки.

Причина в устройстве: PID процесса фиксируется при создании. Изменить его — значит изменить то, по чему процесс уже известен ядру и родителю.

Отсюда: nsenter -t PID -p без --fork даёт процесс, который видит новый /proc после --mount-proc, но сам числится в старом namespace. Практически всегда нужен -f.

Почему namespace исчезает не сразу

Namespace живёт, пока на него есть ссылка. Ссылками служат:

  • процессы внутри него;
  • открытые дескрипторы файлов /proc/PID/ns/*;
  • точки монтирования этих файлов (приём ip netns).

Отсюда наблюдение: container остановлен, а сетевой namespace ещё существует, если кто-то держит дескриптор. Это же позволяет войти в namespace процесса, который вот-вот завершится.


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

Что создаёт Docker

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

docker run -d --name ns-demo alpine:3.21 sleep 300 > /dev/null
sleep 2
pid="$(docker inspect ns-demo --format '{{.State.Pid}}')"

echo "═══ namespaces container'а и host ═══"
python3 - <<'PY'
import os
import subprocess
from pathlib import Path

TYPES = ["mnt", "uts", "ipc", "pid", "net", "user", "cgroup", "time"]

pid = subprocess.run(
    ["docker", "inspect", "ns-demo", "--format", "{{.State.Pid}}"],
    capture_output=True, text=True).stdout.strip()


def ns_id(target: str, kind: str) -> str:
    path = Path(f"/proc/{target}/ns/{kind}")
    try:
        return os.readlink(path)
    except OSError as exc:
        return f"(нет доступа: {exc.errno})"


print(f"  {'тип':<8} {'host':<24} {'container':<24} изолирован")
print("  " + "─" * 68)
isolated = 0
for kind in TYPES:
    host = ns_id("self", kind)
    cont = ns_id(pid, kind)
    if "нет доступа" in cont or "нет доступа" in host:
        mark = "?"
    elif host == cont:
        mark = "нет"
    else:
        mark = "ДА"
        isolated += 1
    print(f"  {kind:<8} {host:<24} {cont:<24} {mark}")

print(f"\n  изолировано: {isolated} из {len(TYPES)}")
PY

echo "═══ разбор ═══"
cat <<'TXT'
  Docker создаёт шесть namespaces: mnt, uts, ipc, pid, net, cgroup.

  НЕ создаёт:
    user  без userns-remap — отсюда совпадение UID внутри и снаружи
    time  практической нужды в отдельном uptime обычно нет

  Одинаковый inode означает ОДИН И ТОТ ЖЕ namespace.
  Это способ проверить, что процессы что-то разделяют.
TXT

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

text
═══ namespaces container'а и host ═══
  тип      host                     container                изолирован
  ────────────────────────────────────────────────────────────────────
  mnt      mnt:[4026531841]         mnt:[4026532567]         ДА
  uts      uts:[4026531838]         uts:[4026532568]         ДА
  ipc      ipc:[4026531839]         ipc:[4026532569]         ДА
  pid      pid:[4026531836]         pid:[4026532571]         ДА
  net      net:[4026531840]         net:[4026532574]         ДА
  user     user:[4026531837]        user:[4026531837]        нет
  cgroup   cgroup:[4026531835]      cgroup:[4026532570]      ДА
  time     time:[4026531834]        time:[4026531834]        нет

  изолировано: 6 из 8
═══ разбор ═══
  Docker создаёт шесть namespaces: mnt, uts, ipc, pid, net, cgroup.
  ...

Совпадающие inode у user и time — прямое подтверждение: эти namespaces не создавались.

Отображение UID

bash
cd /tmp/ns
echo "═══ карта UID без user namespace ═══"
docker exec ns-demo cat /proc/self/uid_map | sed 's/^/  /'
echo "  формат: <UID внутри> <UID снаружи> <количество>"

echo "═══ что это значит ═══"
python3 - <<'PY'
import subprocess

raw = subprocess.run(["docker", "exec", "ns-demo", "cat", "/proc/self/uid_map"],
                     capture_output=True, text=True).stdout.strip()
parts = raw.split()
if len(parts) >= 3:
    inside, outside, count = int(parts[0]), int(parts[1]), int(parts[2])
    print(f"  UID {inside} внутри → UID {outside} снаружи, диапазон {count}")
    if inside == outside == 0 and count >= 2 ** 32 - 1:
        print("  Полное тождество: отображения НЕТ.")
        print("  root внутри container'а — это root на host.")
    else:
        print(f"  Отображение задано: root внутри = UID {outside} снаружи.")
PY

echo "═══ проверка на файловой системе ═══"
mkdir -p shared
docker run --rm -v "$PWD/shared:/data" alpine:3.21 \
    sh -c 'touch /data/from-container; echo "  внутри: uid=$(id -u)"' 2>/dev/null
printf '  снаружи: '
ls -ln shared/from-container | awk '{printf "владелец uid=%s gid=%s\n", $3, $4}'
rm -rf shared

echo "═══ вывод ═══"
cat <<'TXT'
  Файл, созданный от root внутри container'а, принадлежит
  root на host. Это и есть отсутствие user namespace.

  С userns-remap карта была бы иной:
    0 100000 65536
  и файл принадлежал бы UID 100000 ([урок 12.3]).
TXT

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

text
═══ карта UID без user namespace ═══
           0          0 4294967295
  формат: <UID внутри> <UID снаружи> <количество>
═══ что это значит ═══
  UID 0 внутри → UID 0 снаружи, диапазон 4294967295
  Полное тождество: отображения НЕТ.
  root внутри container'а — это root на host.
═══ проверка на файловой системе ═══
  внутри: uid=0
  снаружи: владелец uid=0 gid=0
═══ вывод ═══
  Файл, созданный от root внутри container'а, принадлежит
  root на host. Это и есть отсутствие user namespace.
  ...

Три системных вызова

bash
cd /tmp/ns
echo "═══ unshare без --fork: PID namespace ведёт себя неожиданно ═══"
if unshare --help > /dev/null 2>&1; then
    echo "  unshare --uts (доступен непривилегированному? проверяем):"
    unshare --uts sh -c 'hostname изменённое-имя 2>/dev/null && hostname' 2>&1 \
        | head -1 | sed 's/^/    /'
else
    echo "  утилита unshare недоступна"
fi

echo "═══ разница clone и unshare для PID ═══"
cat <<'TXT'
  clone(CLONE_NEWPID)
    новый процесс сразу становится PID 1 в новом namespace

  unshare(CLONE_NEWPID)
    ТЕКУЩИЙ процесс остаётся в старом namespace;
    PID 1 получит его следующий потомок

  Отсюда: unshare --pid без --fork даёт странности —
  сам процесс числится снаружи, а ps показывает не то.

  Правильно:
    unshare --pid --fork --mount-proc ps aux
TXT

echo "═══ проверка на практике ═══"
if unshare --user --pid --fork --mount-proc true 2>/dev/null; then
    echo "  создание namespaces непривилегированным пользователем: доступно"
    unshare --user --pid --fork --mount-proc sh -c '
        echo "    PID процесса внутри: $$"
        echo "    процессов видно: $(ps -e --no-headers 2>/dev/null | wc -l)"
        echo "    uid внутри: $(id -u)"
        echo "    uid_map: $(cat /proc/self/uid_map | tr -s " ")"
    ' 2>/dev/null
else
    cat <<'TXT'
  создание namespaces непривилегированным пользователем НЕДОСТУПНО
  (может быть отключено настройкой kernel.unprivileged_userns_clone)

  Проверить: sysctl kernel.unprivileged_userns_clone
             sysctl user.max_user_namespaces
TXT
fi

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

text
═══ unshare без --fork: PID namespace ведёт себя неожиданно ═══
  unshare --uts (доступен непривилегированному? проверяем):
    unshare: unshare failed: Operation not permitted
═══ разница clone и unshare для PID ═══
  clone(CLONE_NEWPID)
    новый процесс сразу становится PID 1 в новом namespace

  unshare(CLONE_NEWPID)
    ТЕКУЩИЙ процесс остаётся в старом namespace;
    PID 1 получит его следующий потомок
  ...
═══ проверка на практике ═══
  создание namespaces непривилегированным пользователем: доступно
    PID процесса внутри: 1
    процессов видно: 1
    uid внутри: 0
    uid_map: 0 1000 1

Строка uid_map: 0 1000 1 — user namespace в действии: UID 0 внутри соответствует UID 1000 снаружи, диапазон в одно значение.

Это то же отображение, которое настраивает userns-remap, только созданное вручную и без привилегий.

Изолированное окружение вручную

bash
cd /tmp/ns
cat > manual-container.sh <<'SH'
#!/usr/bin/env bash
# Изолированное окружение без Docker: unshare плюс корневая файловая система.
#
# Показывает, что «container» — это процесс с namespaces, cgroups
# и подменённым корнем, а не особая сущность.
set -uo pipefail

ROOTFS="${1:-./rootfs}"

if [ ! -d "$ROOTFS" ]; then
    printf '  подготовка корневой файловой системы...\n'
    mkdir -p "$ROOTFS"
    cid="$(docker create alpine:3.21 true)"
    docker export "$cid" | tar -x -C "$ROOTFS" 2>/dev/null
    docker rm "$cid" > /dev/null
fi
printf '  rootfs: %s каталогов\n' "$(ls "$ROOTFS" | wc -l)"

printf '\n  запуск изолированного окружения:\n'
unshare --user --map-root-user \
        --pid --fork \
        --mount --uts --ipc \
        sh -c "
    hostname изолированный-хост 2>/dev/null || true
    mount --bind '$ROOTFS' '$ROOTFS' 2>/dev/null || true
    printf '    hostname:  %s\n' \"\$(hostname)\"
    printf '    PID:       %s\n' \"\$\$\"
    printf '    uid:       %s\n' \"\$(id -u)\"
    printf '    uid_map:   %s\n' \"\$(tr -s ' ' < /proc/self/uid_map)\"
    printf '    namespaces:\n'
    for kind in mnt uts ipc pid net user cgroup; do
        link=\$(readlink /proc/self/ns/\$kind 2>/dev/null || echo '—')
        printf '      %-8s %s\n' \"\$kind\" \"\$link\"
    done
" 2>&1
SH
chmod +x manual-container.sh

echo "═══ окружение без Docker ═══"
./manual-container.sh ./rootfs 2>&1 | head -20

echo "═══ сравнение с host ═══"
printf '  hostname на host: %s\n' "$(hostname)"
printf '  uid на host:      %s\n' "$(id -u)"
printf '  namespaces host:\n'
for kind in mnt uts ipc pid net user cgroup; do
    printf '    %-8s %s\n' "$kind" "$(readlink /proc/self/ns/$kind 2>/dev/null || echo '—')"
done

echo "═══ что это показывает ═══"
cat <<'TXT'
  Изолированное окружение создано без Docker: одна команда unshare
  и корневая файловая система, полученная через docker export.

  Чего здесь НЕТ по сравнению с Docker:
    cgroups          ограничения ресурсов не настроены
    pivot_root       корень не подменён по-настоящему
    seccomp          фильтр системных вызовов не применён
    сеть             интерфейсы не настроены
    образы, слои     нет управления содержимым

  Docker — это оркестровка этих механизмов, а не отдельная технология.
  Всё, что он делает, делается системными вызовами ядра.
TXT

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

text
═══ окружение без Docker ═══
  rootfs: 19 каталогов

  запуск изолированного окружения:
    hostname:  изолированный-хост
    PID:       1
    uid:       0
    uid_map:   0 1000 1
    namespaces:
      mnt      mnt:[4026532601]
      uts      uts:[4026532602]
      ipc      ipc:[4026532603]
      pid      pid:[4026532605]
      net      net:[4026531840]
      user     user:[4026532600]
      cgroup   cgroup:[4026531835]
═══ сравнение с host ═══
  hostname на host: workstation
  uid на host:      1000
  namespaces host:
    mnt      mnt:[4026531841]
    uts      uts:[4026531838]
    ipc      ipc:[4026531839]
    pid      pid:[4026531836]
    net      net:[4026531840]
    user     user:[4026531837]
    cgroup   cgroup:[4026531835]
═══ что это показывает ═══
  Изолированное окружение создано без Docker: одна команда unshare
  и корневая файловая система, полученная через docker export.
  ...

PID: 1 и uid: 0 при том, что снаружи пользователь имеет UID 1000, — изоляция работает.

Обратите внимание на net: его inode совпадает с host, потому что --net не указан. Сетевой namespace не создан.

Частичный вход через nsenter

bash
cd /tmp/ns
pid="$(docker inspect ns-demo --format '{{.State.Pid}}')"

echo "═══ зачем входить не во все namespaces ═══"
cat <<'TXT'
  nsenter -t PID -n        только сетевой
     программа берётся С HOST, сеть — из container'а
     работает для образов без утилит

  nsenter -t PID -m -n     монтирования и сеть
     программа ищется В CONTAINER'е
     в distroless не сработает: там нет ни ss, ни sh

  Это ключевой приём диагностики ([урок 13.5]).
TXT

echo "═══ проверка ═══"
if sudo -n true 2>/dev/null && command -v nsenter > /dev/null 2>&1; then
    printf '  nsenter -n (утилита с host):\n'
    sudo nsenter -t "$pid" -n ip -o addr 2>/dev/null | head -3 | sed 's/^/    /' \
        || sudo nsenter -t "$pid" -n hostname -i 2>/dev/null | sed 's/^/    /' \
        || echo "    не удалось"
    printf '  nsenter -m -n (утилита из container'"'"'а):\n'
    sudo nsenter -t "$pid" -m -n ip -o addr 2>&1 | head -2 | sed 's/^/    /'
else
    cat <<'TXT'
  nsenter недоступен или нет прав — команды НЕ ВЫПОЛНЯЛИСЬ.

  Для справки, разница была бы такой:

    sudo nsenter -t PID -n ip -o addr
      1: lo    inet 127.0.0.1/8
      2: eth0  inet 172.17.0.2/16
      → ip взят с host, адреса из container'а

    sudo nsenter -t PID -m -n ip -o addr
      nsenter: failed to execute ip: No such file or directory
      → ip ищется в файловой системе container'а, где его нет
TXT
fi

echo "═══ то же без прав root: sidecar ═══"
docker run --rm --network "container:ns-demo" alpine:3.21 \
    sh -c 'ip -o addr 2>/dev/null | head -3' 2>/dev/null | sed 's/^/  /'
echo "  (общий сетевой namespace, своя файловая система)"

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

text
═══ зачем входить не во все namespaces ═══
  nsenter -t PID -n        только сетевой
     программа берётся С HOST, сеть — из container'а
     работает для образов без утилит
  ...
═══ проверка ═══
  nsenter недоступен или нет прав — команды НЕ ВЫПОЛНЯЛИСЬ.

  Для справки, разница была бы такой:

    sudo nsenter -t PID -n ip -o addr
      1: lo    inet 127.0.0.1/8
      2: eth0  inet 172.17.0.2/16
      → ip взят с host, адреса из container'а

    sudo nsenter -t PID -m -n ip -o addr
      nsenter: failed to execute ip: No such file or directory
      → ip ищется в файловой системе container'а, где его нет
═══ то же без прав root: sidecar ═══
  1: lo    inet 127.0.0.1/8 scope host lo
  2: eth0  inet 172.17.0.2/16 scope global eth0
  (общий сетевой namespace, своя файловая система)

Sidecar даёт тот же результат без прав root: сетевой namespace общий, файловая система своя (урок 13.5).

Namespace живёт, пока на него есть ссылка

bash
cd /tmp/ns
echo "═══ inode сетевого namespace ═══"
pid="$(docker inspect ns-demo --format '{{.State.Pid}}')"
ns_before="$(readlink "/proc/$pid/ns/net" 2>/dev/null || echo '(нет доступа)')"
printf '  до остановки: %s\n' "$ns_before"

echo "═══ два container'а с общим namespace ═══"
docker run -d --name ns-shared --network "container:ns-demo" \
    alpine:3.21 sleep 60 > /dev/null
sleep 2
pid2="$(docker inspect ns-shared --format '{{.State.Pid}}')"
ns2="$(readlink "/proc/$pid2/ns/net" 2>/dev/null || echo '(нет доступа)')"
printf '  ns-demo:   %s\n' "$ns_before"
printf '  ns-shared: %s\n' "$ns2"
if [ "$ns_before" = "$ns2" ]; then
    echo "  ✓ inode совпадает — namespace общий"
else
    echo "  inode различается"
fi

echo "═══ что удерживает namespace ═══"
cat <<'TXT'
  Namespace существует, пока на него есть ссылка:

    процессы внутри него
    открытые дескрипторы /proc/PID/ns/*
    точки монтирования этих файлов

  Третий пункт — приём утилиты ip netns: она монтирует
  файл namespace в /var/run/netns/, и namespace живёт
  даже без единого процесса внутри.

  Отсюда наблюдение: container остановлен, а сетевой namespace
  ещё существует, если кто-то держит дескриптор.
TXT

docker rm -f ns-demo ns-shared > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ns

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

text
═══ inode сетевого namespace ═══
  до остановки: net:[4026532574]
═══ два container'а с общим namespace ═══
  ns-demo:   net:[4026532574]
  ns-shared: net:[4026532574]
  ✓ inode совпадает — namespace общий
═══ что удерживает namespace ═══
  Namespace существует, пока на него есть ссылка:

    процессы внутри него
    открытые дескрипторы /proc/PID/ns/*
    точки монтирования этих файлов
  ...

Совпадение inode — доказательство того, что --network container: действительно помещает процессы в один namespace, а не создаёт похожую конфигурацию.


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

Задание. Разберите изоляцию через namespaces и воспроизведите её вручную.

Требования:

  1. Определить, какие из восьми namespaces Docker создаёт, а какие нет, — по inode.
  2. Показать, что означает карта 0 0 4294967295, и подтвердить проверкой на файловой системе.
  3. Объяснить разницу clone, unshare и setns; показать, почему unshare --pid нужен --fork.
  4. Создать изолированное окружение вручную и перечислить, чего в нём нет по сравнению с Docker.
  5. Показать, что --network container: даёт тот же namespace, а не похожий.
  6. Отметить проверки, не выполненные из-за отсутствия прав.

Подсказки

Подсказка 1

Сравнивать namespaces следует по значению readlink /proc/PID/ns/ТИП — совпадение строки означает один namespace.

Подсказка 2

unshare --user --map-root-user работает без привилегий, если ядро это разрешает.

Подсказка 3

Корневую файловую систему для ручного окружения даёт docker export.

Решение

Показать решение
bash
mkdir -p /tmp/nslab && cd /tmp/nslab

cat > nsinspect.py <<'PY'
"""Разбор namespaces процесса и сравнение с host."""
from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

TYPES = ("mnt", "uts", "ipc", "pid", "net", "user", "cgroup", "time")

DESCRIPTIONS = {
    "mnt": "точки монтирования",
    "uts": "имя хоста и домена",
    "ipc": "очереди, семафоры, разделяемая память",
    "pid": "пространство идентификаторов процессов",
    "net": "интерфейсы, маршруты, порты",
    "user": "отображение UID и GID, capabilities",
    "cgroup": "корень иерархии cgroups",
    "time": "смещение монотонных часов",
}


def ns_link(pid: str | int, kind: str) -> str | None:
    try:
        return os.readlink(Path(f"/proc/{pid}/ns/{kind}"))
    except OSError:
        return None


def container_pid(name: str) -> int:
    out = subprocess.run(
        ["docker", "inspect", name, "--format", "{{.State.Pid}}"],
        capture_output=True, text=True).stdout.strip()
    return int(out) if out.isdigit() else 0


def compare(name: str) -> dict[str, object]:
    pid = container_pid(name)
    rows = []
    isolated = shared = unknown = 0
    for kind in TYPES:
        host = ns_link("self", kind)
        cont = ns_link(pid, kind)
        if host is None or cont is None:
            state = "неизвестно"
            unknown += 1
        elif host == cont:
            state = "общий"
            shared += 1
        else:
            state = "изолирован"
            isolated += 1
        rows.append({"тип": kind, "host": host, "container": cont,
                     "состояние": state})
    return {"pid": pid, "строки": rows, "изолировано": isolated,
            "общих": shared, "неизвестно": unknown}


def main(name: str) -> int:
    result = compare(name)
    print(f"  PID container'а: {result['pid']}\n")
    print(f"  {'тип':<8} {'состояние':<12} {'что изолирует':<40} inode")
    print("  " + "─" * 86)
    for row in result["строки"]:
        cont = row["container"] or "—"
        inode = cont.split("[")[-1].rstrip("]") if "[" in cont else cont
        print(f"  {row['тип']:<8} {row['состояние']:<12} "
              f"{DESCRIPTIONS[row['тип']]:<40} {inode}")

    print()
    print(f"  изолировано: {result['изолировано']}, "
          f"общих с host: {result['общих']}, "
          f"неизвестно: {result['неизвестно']}")

    not_created = [r["тип"] for r in result["строки"] if r["состояние"] == "общий"]
    if not_created:
        print(f"  НЕ созданы: {', '.join(not_created)}")
        if "user" in not_created:
            print("    user → UID внутри и снаружи совпадают ([урок 12.3])")
        if "time" in not_created:
            print("    time → uptime container'а совпадает с host")

    print()
    print(json.dumps({
        "изолировано": result["изолировано"],
        "общих": result["общих"],
        "не_созданы": not_created,
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "nslab-demo"))
PY

cat > manual-env.sh <<'SH'
#!/usr/bin/env bash
# Изолированное окружение без Docker.
set -uo pipefail

ROOTFS="${1:-./rootfs}"

if [ ! -d "$ROOTFS" ] || [ -z "$(ls -A "$ROOTFS" 2>/dev/null)" ]; then
    mkdir -p "$ROOTFS"
    cid="$(docker create alpine:3.21 true)"
    docker export "$cid" | tar -x -C "$ROOTFS" 2>/dev/null
    docker rm "$cid" > /dev/null
fi

unshare --user --map-root-user --pid --fork --mount --uts --ipc \
    sh -c '
    hostname manual-env 2>/dev/null || true
    printf "%s\n" "hostname=$(hostname)"
    printf "%s\n" "pid=$$"
    printf "%s\n" "uid=$(id -u)"
    printf "%s\n" "uid_map=$(tr -s " " < /proc/self/uid_map | tr -d "\n")"
    for kind in mnt uts ipc pid net user cgroup; do
        printf "%s\n" "ns_${kind}=$(readlink /proc/self/ns/$kind 2>/dev/null || echo none)"
    done
' 2>/dev/null
SH
chmod +x manual-env.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка ═══\n'
docker rm -f nslab-demo nslab-shared > /dev/null 2>&1
docker run -d --name nslab-demo alpine:3.21 sleep 300 > /dev/null
sleep 2
printf '    container запущен\n'

printf '\n═══ Требование 1: какие namespaces создаёт Docker ═══\n'
python3 nsinspect.py nslab-demo > ns.log 2>&1
sed -n '1,/^  {/p' ns.log | head -18
summary="$(tail -1 ns.log)"
isolated="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['изолировано'])")"
not_created="$(echo "$summary" | python3 -c "
import json,sys; print(','.join(json.load(sys.stdin)['не_созданы']))")"
printf '  изолировано: %s, не созданы: %s\n' "$isolated" "$not_created"
[ "${isolated:-0}" -ge 5 ] \
    && ok "Docker создаёт шесть namespaces; user общий с хостом" \
    || bad "изолировано: $isolated"

printf '\n═══ Требование 2: карта UID ═══\n'
uid_map="$(docker exec nslab-demo cat /proc/self/uid_map | tr -s ' ')"
printf '    uid_map: %s\n' "$uid_map"
python3 - <<'PY'
import subprocess

raw = subprocess.run(["docker", "exec", "nslab-demo", "cat", "/proc/self/uid_map"],
                     capture_output=True, text=True).stdout.split()
if len(raw) >= 3:
    inside, outside, count = map(int, raw[:3])
    print(f"    UID {inside} внутри → UID {outside} снаружи, диапазон {count}")
    if inside == outside == 0 and count > 2 ** 31:
        print("    Полное тождество: отображения НЕТ.")
PY

mkdir -p shared
docker run --rm -v "$PWD/shared:/data" alpine:3.21 \
    sh -c 'touch /data/probe; echo "    внутри uid=$(id -u)"' 2>/dev/null
owner="$(ls -ln shared/probe | awk '{print $3}')"
printf '    снаружи владелец uid=%s\n' "$owner"
rm -rf shared
[ "$owner" = "0" ] \
    && ok "файл от root внутри принадлежит root снаружи — отображения нет" \
    || bad "владелец: $owner"

printf '\n═══ Требование 3: clone, unshare, setns ═══\n'
python3 - <<'PY'
CALLS = [
    ("clone(CLONE_NEW*)", "создаёт НОВЫЙ процесс сразу в новых namespaces",
     "runc при запуске container'а",
     "новый процесс становится PID 1 в новом PID namespace"),
    ("unshare(CLONE_NEW*)", "переводит ТЕКУЩИЙ процесс в новые namespaces",
     "утилита unshare",
     "для PID: текущий процесс ОСТАЁТСЯ в старом namespace"),
    ("setns(fd, type)", "помещает текущий процесс в СУЩЕСТВУЮЩИЙ namespace",
     "nsenter, docker exec",
     "для PID: переходят потомки, а не сам процесс"),
]
print(f"    {'вызов':<22} {'что делает':<48} кто применяет")
print("    " + "─" * 96)
for call, what, who, _ in CALLS:
    print(f"    {call:<22} {what:<48} {who}")
print()
print("    Особенность PID namespace:")
for call, _, _, note in CALLS:
    print(f"      {call:<22} {note}")
print()
print("    Отсюда: unshare --pid БЕЗ --fork даёт процесс, который сам")
print("    остаётся снаружи, а ps показывает не то, что ожидается.")
print("    Правильно: unshare --pid --fork --mount-proc")
PY
ok "различие трёх вызовов объяснено через поведение PID namespace"

printf '\n═══ Требование 4: окружение вручную ═══\n'
if ./manual-env.sh ./rootfs > manual.log 2>&1 && [ -s manual.log ]; then
    python3 - <<'PY'
from pathlib import Path

data = {}
for line in Path("manual.log").read_text().splitlines():
    if "=" in line:
        k, _, v = line.partition("=")
        data[k] = v

print(f"    hostname внутри: {data.get('hostname', '—')}")
print(f"    PID внутри:      {data.get('pid', '—')}")
print(f"    uid внутри:      {data.get('uid', '—')}")
print(f"    uid_map:         {data.get('uid_map', '—')}")
print()
print("    namespaces созданного окружения:")
import os
for kind in ("mnt", "uts", "ipc", "pid", "net", "user", "cgroup"):
    inner = data.get(f"ns_{kind}", "—")
    try:
        outer = os.readlink(f"/proc/self/ns/{kind}")
    except OSError:
        outer = "—"
    state = "общий с host" if inner == outer else "изолирован"
    print(f"      {kind:<8} {state}")
PY
    manual_ok=1
else
    echo "    создание namespaces НЕ УДАЛОСЬ"
    echo "    (может быть запрещено: sysctl kernel.unprivileged_userns_clone)"
    manual_ok=0
fi

printf '\n    Чего НЕТ в ручном окружении по сравнению с Docker:\n'
python3 - <<'PY'
MISSING = [
    ("cgroups", "ограничения памяти, CPU и PID не настроены"),
    ("pivot_root", "корень не подменён; видна файловая система host"),
    ("seccomp", "фильтр системных вызовов не применён"),
    ("сеть", "интерфейсы, адреса и маршруты не настроены"),
    ("capabilities", "набор не сокращён"),
    ("образы и слои", "нет управления содержимым файловой системы"),
]
for name, note in MISSING:
    print(f"      {name:<16} {note}")
print()
print("    Docker — оркестровка этих механизмов, а не отдельная технология.")
print("    Всё, что он делает, делается системными вызовами ядра.")
PY
[ "${manual_ok:-0}" -eq 1 ] \
    && ok "окружение создано вручную; перечислено, чего в нём нет" \
    || ok "создание namespaces недоступно — отмечено; перечень приведён"

printf '\n═══ Требование 5: --network container: даёт ТОТ ЖЕ namespace ═══\n'
docker run -d --name nslab-shared --network "container:nslab-demo" \
    alpine:3.21 sleep 60 > /dev/null
sleep 2
pid1="$(docker inspect nslab-demo --format '{{.State.Pid}}')"
pid2="$(docker inspect nslab-shared --format '{{.State.Pid}}')"
net1="$(readlink "/proc/$pid1/ns/net" 2>/dev/null || echo '?')"
net2="$(readlink "/proc/$pid2/ns/net" 2>/dev/null || echo '?')"
mnt1="$(readlink "/proc/$pid1/ns/mnt" 2>/dev/null || echo '?')"
mnt2="$(readlink "/proc/$pid2/ns/mnt" 2>/dev/null || echo '?')"
printf '    %-12s %-24s %-24s совпадает\n' "тип" "nslab-demo" "nslab-shared"
printf '    %s\n' "──────────────────────────────────────────────────────────────────────"
printf '    %-12s %-24s %-24s %s\n' "net" "$net1" "$net2" \
    "$([ "$net1" = "$net2" ] && echo ДА || echo нет)"
printf '    %-12s %-24s %-24s %s\n' "mnt" "$mnt1" "$mnt2" \
    "$([ "$mnt1" = "$mnt2" ] && echo ДА || echo нет)"
printf '\n    Сетевой namespace ОБЩИЙ, файловая система — своя.\n'
printf '    Это и делает sidecar рабочим приёмом ([урок 13.5]).\n'
[ "$net1" = "$net2" ] && [ "$mnt1" != "$mnt2" ] \
    && ok "тот же сетевой namespace, разные mount namespace" \
    || bad "net: $net1 / $net2, mnt: $mnt1 / $mnt2"

printf '\n═══ Требование 6: что не выполнялось ═══\n'
python3 - <<'PY'
import shutil
import subprocess
from pathlib import Path

def has_sudo() -> bool:
    return subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0

def userns_allowed() -> bool:
    return subprocess.run(
        ["unshare", "--user", "--map-root-user", "true"],
        capture_output=True).returncode == 0

CHECKS = [
    ("nsenter -t PID -n с утилитой host", shutil.which("nsenter") and has_sudo(),
     "нужен nsenter и права root"),
    ("nsenter -t PID -m -n (отказ в distroless)", shutil.which("nsenter") and has_sudo(),
     "то же"),
    ("создание namespaces непривилегированным", userns_allowed(),
     "может быть запрещено настройкой ядра"),
    ("pivot_root в ручном окружении", has_sudo(),
     "требует CAP_SYS_ADMIN"),
]
print(f"    {'проверка':<42} {'выполнена':<12} причина")
print("    " + "─" * 92)
not_run = 0
for name, done, why in CHECKS:
    if not done:
        not_run += 1
    print(f"    {name:<42} {'да' if done else 'НЕТ':<12} {'' if done else why}")
print(f"\n    не выполнено: {not_run} из {len(CHECKS)}")
PY
ok "невыполненные проверки перечислены с причиной"

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

docker rm -f nslab-demo nslab-shared > /dev/null 2>&1
cd /tmp && rm -rf /tmp/nslab
exit "$fail"

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

text
═══ Подготовка ═══
    container запущен

═══ Требование 1: какие namespaces создаёт Docker ═══
  PID container'а: 53412

  тип      состояние    что изолирует                            inode
  ──────────────────────────────────────────────────────────────────────────────────────
  mnt      изолирован   точки монтирования                       4026532567
  uts      изолирован   имя хоста и домена                       4026532568
  ipc      изолирован   очереди, семафоры, разделяемая память    4026532569
  pid      изолирован   пространство идентификаторов процессов   4026532571
  net      изолирован   интерфейсы, маршруты, порты              4026532574
  user     общий        отображение UID и GID, capabilities      4026531837
  cgroup   изолирован   корень иерархии cgroups                  4026532570
  time     общий        смещение монотонных часов                4026531834

  изолировано: 6, общих с host: 2, неизвестно: 0
  НЕ созданы: user, time
    user → UID внутри и снаружи совпадают ([урок 12.3])
    time → uptime container'а совпадает с host

  изолировано: 6, не созданы: user,time
  ✓ Docker создаёт шесть namespaces; user общий с хостом

═══ Требование 2: карта UID ═══
    uid_map:  0 0 4294967295
    UID 0 внутри → UID 0 снаружи, диапазон 4294967295
    Полное тождество: отображения НЕТ.
    внутри uid=0
    снаружи владелец uid=0
  ✓ файл от root внутри принадлежит root снаружи — отображения нет

═══ Требование 3: clone, unshare, setns ═══
    вызов                  что делает                                       кто применяет
    ────────────────────────────────────────────────────────────────────────────────────────────────
    clone(CLONE_NEW*)      создаёт НОВЫЙ процесс сразу в новых namespaces   runc при запуске container'а
    unshare(CLONE_NEW*)    переводит ТЕКУЩИЙ процесс в новые namespaces     утилита unshare
    setns(fd, type)        помещает текущий процесс в СУЩЕСТВУЮЩИЙ namespace nsenter, docker exec

    Особенность PID namespace:
      clone(CLONE_NEW*)      новый процесс становится PID 1 в новом PID namespace
      unshare(CLONE_NEW*)    для PID: текущий процесс ОСТАЁТСЯ в старом namespace
      setns(fd, type)        для PID: переходят потомки, а не сам процесс

    Отсюда: unshare --pid БЕЗ --fork даёт процесс, который сам
    остаётся снаружи, а ps показывает не то, что ожидается.
    Правильно: unshare --pid --fork --mount-proc
  ✓ различие трёх вызовов объяснено через поведение PID namespace

═══ Требование 4: окружение вручную ═══
    hostname внутри: manual-env
    PID внутри:      1
    uid внутри:      0
    uid_map:         0 1000 1

    namespaces созданного окружения:
      mnt      изолирован
      uts      изолирован
      ipc      изолирован
      pid      изолирован
      net      общий с host
      user     изолирован
      cgroup   общий с host

    Чего НЕТ в ручном окружении по сравнению с Docker:
      cgroups          ограничения памяти, CPU и PID не настроены
      pivot_root       корень не подменён; видна файловая система host
      seccomp          фильтр системных вызовов не применён
      сеть             интерфейсы, адреса и маршруты не настроены
      capabilities     набор не сокращён
      образы и слои    нет управления содержимым файловой системы

    Docker — оркестровка этих механизмов, а не отдельная технология.
    Всё, что он делает, делается системными вызовами ядра.
  ✓ окружение создано вручную; перечислено, чего в нём нет

═══ Требование 5: --network container: даёт ТОТ ЖЕ namespace ═══
    тип          nslab-demo               nslab-shared             совпадает
    ──────────────────────────────────────────────────────────────────────
    net          net:[4026532574]         net:[4026532574]         ДА
    mnt          mnt:[4026532567]         mnt:[4026532601]         нет

    Сетевой namespace ОБЩИЙ, файловая система — своя.
    Это и делает sidecar рабочим приёмом ([урок 13.5]).
  ✓ тот же сетевой namespace, разные mount namespace

═══ Требование 6: что не выполнялось ═══
    проверка                                   выполнена    причина
    ────────────────────────────────────────────────────────────────────────────────────────────
    nsenter -t PID -n с утилитой host          НЕТ          нужен nsenter и права root
    nsenter -t PID -m -n (отказ в distroless)  НЕТ          то же
    создание namespaces непривилегированным    да           
    pivot_root в ручном окружении              НЕТ          требует CAP_SYS_ADMIN

    не выполнено: 3 из 4
  ✓ невыполненные проверки перечислены с причиной

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

Все требования выполнены; три проверки из четырёх требуют прав root и не выполнялись.

Требование 5 даёт самое сильное подтверждение: net:[4026532574] у обоих container'ов и разные mnt. Это не «похожая настройка сети», а буквально один и тот же объект ядра.

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

Сравнение namespaces выполняется по inode, а не по признакам поведения. Можно было проверять косвенно: совпадает ли IP-адрес, виден ли процесс. Такие проверки дают вероятностный ответ. Совпадение строки net:[4026532574] — тождество объекта ядра, и опровергнуть его нечем.

Требование 4 перечисляет, чего нет в ручном окружении. Показать unshare и остановиться значило бы создать впечатление, что Docker — тонкая обёртка. Список из шести отсутствующих механизмов — cgroups, pivot_root, seccomp, сеть, capabilities, слои — возвращает масштаб на место.

Отсутствие user namespace подтверждается дважды: картой UID и владельцем файла. Карта 0 0 4294967295 требует знания формата; владелец созданного файла — наблюдаемый факт, не требующий пояснений. Вместе они не оставляют места для толкования.

Чего решение не делает. nsenter не запускался: он требует прав root, и разница между входом с -m и без него описана командами, а не измерена. pivot_root в ручном окружении не выполнялся по той же причине — корень остался общим с host, и это отмечено в перечне отсутствующего. Time namespace не создавался: Docker его не использует, а ручная демонстрация потребовала бы отдельного разбора смещения часов. Наконец, поведение unshare --pid без --fork объяснено, но не воспроизведено: его результат зависит от настроек ядра и на разных машинах выглядит по-разному.

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

bash
ls -l /proc/self/ns/
pid=$(docker inspect ИМЯ --format '{{.State.Pid}}'); ls -l /proc/$pid/ns/
docker exec ИМЯ cat /proc/self/uid_map
readlink /proc/$pid/ns/net
unshare --user --map-root-user --pid --fork id -u

Совпадение вывода readlink для двух процессов означает общий namespace.

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

ОшибкаПричинаИсправление
Считать, что Docker создаёт все namespacesЛогично предположитьuser и time не создаются
unshare --pid без --forkКажется достаточнымТекущий процесс остаётся в старом namespace
Ожидать, что setns переместит сам процесс в PID namespaceПо аналогии с остальнымиПереходят потомки
Сравнивать namespaces по поведениюПроще наблюдатьСравнивать inode из readlink
nsenter -m для образа без утилитКажется полнееПрограмма ищется в container'е
Считать CLONE_NEWNS относящимся к сетиПохоже на «network»Это mount namespace, историческое имя
Ожидать изоляции реального времени в time namespaceНазвание наводитИзолируется только смещение монотонных часов
Считать namespace исчезнувшим сразуПроцессов же нетЖивёт, пока есть ссылка
Принимать ручное окружение за containerИзоляция похожаНет cgroups, seccomp, сети, слоёв

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

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

  1. Сколько namespaces создаёт Docker и какие не создаёт?
  2. Чем clone отличается от unshare применительно к PID namespace?
  3. Почему setns не переводит сам процесс в новый PID namespace?
  4. Что означает карта 0 0 4294967295?
  5. Что удерживает namespace от исчезновения?

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

  1. Как проверить, что два процесса разделяют сетевой namespace?
  2. Зачем входить в namespace без флага -m?
  3. Как создать изолированное окружение без Docker?

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

  1. unshare --pid ps aux показывает все процессы host. Причина?
  2. Container остановлен, а сетевой namespace существует. Как это возможно?

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

  1. Восемь типов namespaces; Docker создаёт шесть своих. user — хостовый. time на Docker 29.7.1 не хостовый, но общий для всех container'ов: своего container не получает (verify/FACTS.md).
  2. CLONE_NEWNS — это mount namespace: имя историческое.
  3. clone создаёт новый процесс в новых namespaces; unshare переводит текущий; setns входит в существующий.
  4. Для PID namespace unshare и setns действуют на потомков, а не на сам процесс.
  5. unshare --pid практически всегда требует --fork и --mount-proc.
  6. Файлы /proc/PID/ns/* — ссылки вида net:[inode]; совпадение inode означает один namespace.
  7. Namespace живёт, пока есть процесс внутри, открытый дескриптор или точка монтирования.
  8. Вход без -m оставляет файловую систему host — приём для образов без утилит.
  9. User namespace — единственный, создаваемый без привилегий; на нём основан rootless.
  10. Карта 0 0 4294967295 означает отсутствие отображения: root внутри — root снаружи.
  11. Time namespace изолирует смещение монотонных часов, но не реальное время.
  12. Изолированное окружение создаётся одной командой unshare; Docker добавляет cgroups, seccomp, сеть и слои.

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

ИсточникСсылкаЧто подтверждает
Linux: namespaces(7)https://man7.org/linux/man-pages/man7/namespaces.7.htmlВсе типы и общая модель
Linux: user_namespaces(7)https://man7.org/linux/man-pages/man7/user_namespaces.7.htmlКарты UID, capabilities
Linux: pid_namespaces(7)https://man7.org/linux/man-pages/man7/pid_namespaces.7.htmlПоведение при unshare и setns
Linux: time_namespaces(7)https://man7.org/linux/man-pages/man7/time_namespaces.7.htmlЧто изолируется
Linux: clone(2)https://man7.org/linux/man-pages/man2/clone.2.htmlФлаги CLONE_NEW*
Linux: unshare(2)https://man7.org/linux/man-pages/man2/unshare.2.htmlОтличия от clone
Linux: setns(2)https://man7.org/linux/man-pages/man2/setns.2.htmlВход в namespace
Linux: nsenter(1)https://man7.org/linux/man-pages/man1/nsenter.1.htmlФлаги по типам

Навигация

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

Markdown на GitHub ↗