Главная/Основы Containerization/Практика

Раздел 2. Практические задания

Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.

Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.

Подготовка:

bash
mkdir -p ~/docker-course/02-basics
cd ~/docker-course/02-basics
sudo apt install -y libcap2-bin util-linux psmisc   # capsh, lsns, nsenter, pstree

Задание 1. Один процесс, два PID [обяз.]

Постановка. Запустите sleep 3600 на host и такой же процесс в container. Найдите оба в ps на host. Покажите, что процесс container имеет PID 1 внутри и обычный PID снаружи. Докажите, что это один процесс, а не два.

Ожидаемый результат. Вывод, где для одного процесса указаны два разных PID, и доказательство их принадлежности одному объекту.

Проверка:

bash
docker run -d --name p1 alpine sleep 3600
docker exec p1 ps -o pid,comm
docker inspect p1 --format '{{.State.Pid}}'
docker rm -f p1
Разбор
bash
docker run -d --name p1 alpine sleep 3600
CPID="$(docker inspect p1 --format '{{.State.Pid}}')"
CID="$(docker inspect p1 --format '{{.Id}}')"

echo "внутри container:"; docker exec p1 ps -o pid,comm
echo "на host:";          ps -o pid,ppid,user,comm -p "$CPID"
text
внутри container:
PID   COMMAND
    1 sleep
    7 ps
на host:
    PID    PPID USER     COMMAND
 188104  188082 root     sleep

Доказательство тождества — три независимых способа:

bash
# 1. Совпадение командной строки
docker exec p1 sh -c "tr '\0' ' ' < /proc/1/cmdline"; echo
tr '\0' ' ' < /proc/"$CPID"/cmdline; echo

# 2. cgroup процесса содержит ID container
grep -o "${CID:0:12}" /proc/"$CPID"/cgroup

# 3. Завершение процесса на host останавливает container
sudo kill "$CPID"
sleep 1
docker ps -a --filter name=p1 --format '{{.Status}}'
text
sleep 3600 
sleep 3600 
c8f4a1b2d3e5
Exited (137) 1 second ago

Третий способ — самый убедительный: убив процесс на host, мы остановили container. Это один объект.

bash
docker rm -f p1

Задание 2. Namespaces процесса [обяз.]

Постановка. Для работающего container покажите все его namespaces двумя способами: через /proc/<pid>/ns/ и через lsns. Определите, какие namespaces созданы, а какие разделяются с host.

Ожидаемый результат. Таблица из восьми строк с указанием «создан» / «разделяется».

Проверка:

bash
docker run -d --name ns1 alpine sleep 300
CPID=$(docker inspect ns1 --format '{{.State.Pid}}')
sudo readlink /proc/$CPID/ns/user
sudo readlink /proc/1/ns/user
docker rm -f ns1

Оба значения должны совпасть — user namespace не создан.

Полное решение — в уроке 2.3.


Задание 3. Лимит памяти в cgroup [обяз.]

Постановка. Запустите container с --memory=256m. Найдите его cgroup в файловой системе и прочитайте фактический лимит. Сверьте значение в байтах с заданным.

Затем прочитайте тот же лимит изнутри container и объясните, почему пути различаются.

Ожидаемый результат. Значение 268435456 из двух источников — с host и изнутри container.

Проверка:

bash
docker run -d --name cg1 --memory=256m alpine sleep 300
CPID=$(docker inspect cg1 --format '{{.State.Pid}}')
cat "/sys/fs/cgroup$(awk -F: '{print $3}' /proc/$CPID/cgroup)/memory.max"
docker exec cg1 cat /sys/fs/cgroup/memory.max
docker rm -f cg1
Разбор

Оба вывода — 268435456, что равно 256 × 1024 × 1024.

Почему пути различаются. С host полный путь выглядит как

text
/sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max

Изнутри container — просто

text
/sys/fs/cgroup/memory.max

Причина — cgroup namespace (урок 2.3). Он показывает процессу его собственную cgroup как корень иерархии. Процесс не видит структуру host и не знает своего реального положения в дереве.

Практическая ценность: приложение может прочитать свой лимит по фиксированному пути, не зная ничего об окружении. Это правильный способ определить доступную память — в отличие от /proc/meminfo, который показывает память host:

bash
docker run --rm --memory=256m alpine sh -c '
echo "cgroup:   $(cat /sys/fs/cgroup/memory.max) байт"
echo "meminfo:  $(grep MemTotal /proc/meminfo)"
'
text
cgroup:   268435456 байт
meminfo:  MemTotal:       16063220 kB

268 MB против 16 GB. Приложение, ориентирующееся на /proc/meminfo, будет считать, что памяти в 60 раз больше, чем есть.


Задание 4. Сравнение capabilities [обяз.]

Постановка. Сравните набор capabilities в трёх случаях: процесс root на host, обычный container, container с --privileged. Для каждого выведите количество capabilities в bounding set.

Затем продемонстрируйте практическое последствие: операцию, которая не работает в обычном container, но работает при добавлении одной capability.

Ожидаемый результат. Три числа (примерно 41, 14, 41) и рабочая демонстрация.

Проверка:

bash
sudo grep CapBnd /proc/self/status
docker run --rm alpine grep CapBnd /proc/self/status
docker run --rm --privileged alpine grep CapBnd /proc/self/status
Разбор
bash
decode() { capsh --decode="$1" | tr ',' '\n' | grep -c cap_; }

host="$(sudo awk '/CapBnd/{print $2}' /proc/self/status)"
cont="$(docker run --rm alpine awk '/CapBnd/{print $2}' /proc/self/status)"
priv="$(docker run --rm --privileged alpine awk '/CapBnd/{print $2}' /proc/self/status)"

printf 'root на host:     %s → %s capabilities\n' "$host" "$(decode "$host")"
printf 'обычный container: %s → %s capabilities\n' "$cont" "$(decode "$cont")"
printf 'privileged:        %s → %s capabilities\n' "$priv" "$(decode "$priv")"
text
root на host:     000001ffffffffff → 41 capabilities
обычный container: 00000000a80425fb → 14 capabilities
privileged:        000001ffffffffff → 41 capabilities

--privileged возвращает полный набор — container перестаёт быть ограниченным.

Практическая демонстрация. Монтирование требует CAP_SYS_ADMIN:

bash
echo "без capability:"
docker run --rm alpine sh -c 'mkdir -p /mnt/t && mount -t tmpfs none /mnt/t' 2>&1 | tail -1

echo "с CAP_SYS_ADMIN:"
docker run --rm --cap-add=SYS_ADMIN alpine \
    sh -c 'mkdir -p /mnt/t && mount -t tmpfs none /mnt/t && echo "смонтировано"'
text
без capability:
mount: mounting none on /mnt/t failed: Operation not permitted
с CAP_SYS_ADMIN:
смонтировано

Одна capability изменила результат. Оба процесса при этом работают от UID 0 — разница только в наборе полномочий.

Проверьте сами: docker run --rm alpine id покажет uid=0(root) в обоих случаях.


Задание 5. Цепочка процессов runtime [доп.]

Постановка. Проследите дерево процессов от systemd до процесса приложения в container. Объясните роль каждого узла и ответьте, почему в цепочке нет dockerd и runc.

Ожидаемый результат. Вывод pstree с объяснением каждого компонента.

Проверка:

bash
docker run -d --name rt1 alpine sleep 300
CPID=$(docker inspect rt1 --format '{{.State.Pid}}')
pstree -sp $CPID
pgrep -a runc || echo "runc не запущен"
docker rm -f rt1

Полное решение — в уроке 2.6.


Задание 6. Общий kernel [доп.]

Постановка. Докажите, что host и containers на базе разных дистрибутивов используют одно ядро. Проверьте минимум на трёх образах.

Дополнительно: объясните, почему из этого следует невозможность запуска Windows-приложения в Linux-container.

Проверка:

bash
uname -r
for img in alpine ubuntu:24.04 debian:trixie-slim; do
    printf '%-22s %s\n' "$img" "$(docker run --rm $img uname -r)"
done
Разбор
text
6.8.0-88-generic
alpine                 6.8.0-88-generic
ubuntu:24.04           6.8.0-88-generic
debian:trixie-slim     6.8.0-88-generic

Все значения совпадают. Образ содержит пользовательское окружение дистрибутива — библиотеки, утилиты, структуру каталогов, — но не ядро. Ядро одно, и оно принадлежит host.

Ещё одно подтверждение — файл /proc/version, формируемый ядром:

bash
diff <(cat /proc/version) <(docker run --rm alpine cat /proc/version) && echo "идентично"
text
идентично

Почему нельзя запустить Windows-приложение. Программа обращается к ядру через системные вызовы. У Linux и Windows совершенно разные наборы вызовов и разные соглашения о них. Windows-программа вызовет NtCreateFile, которого в Linux не существует.

Container не предоставляет собственного ядра — он использует ядро host. Значит, в Linux-container может работать только то, что умеет обращаться к ядру Linux.

Для запуска Windows-приложения нужна виртуальная машина с Windows либо слой совместимости, транслирующий системные вызовы (Wine) — но это уже не контейнеризация.


Задание 7. Вход в network namespace [★]

Постановка. Не заходя внутрь container и не устанавливая в него пакеты, посмотрите с host:

  1. Сетевые интерфейсы container и его IP-адрес.
  2. Таблицу маршрутизации container.
  3. Открытые сокеты container с указанием процессов.

Используйте инструменты host.

Ожидаемый результат. Три вывода, полученные без docker exec и без изменения образа.

Подсказка 1

nsenter -t <pid> -n <команда> выполняет команду в сетевом namespace целевого процесса, используя бинарные файлы host.

Подсказка 2

Для проверки сокетов запустите container, который действительно что-то слушает, — например, nginx.

Разбор
bash
docker run -d --name net1 nginx:alpine
CPID="$(docker inspect net1 --format '{{.State.Pid}}')"

echo "=== 1. Интерфейсы ==="
sudo nsenter -t "$CPID" -n ip -brief addr show

echo
echo "=== 2. Маршруты ==="
sudo nsenter -t "$CPID" -n ip route show

echo
echo "=== 3. Сокеты ==="
sudo nsenter -t "$CPID" -n ss -tlnp
text
=== 1. Интерфейсы ===
lo               UNKNOWN        127.0.0.1/8 
eth0@if17        UP             172.17.0.2/16 

=== 2. Маршруты ===
default via 172.17.0.1 dev eth0 
172.17.0.0/16 dev eth0 scope link  src 172.17.0.2 

=== 3. Сокеты ===
State   Recv-Q  Send-Q  Local Address:Port  Peer Address:Port  Process
LISTEN  0       511           0.0.0.0:80         0.0.0.0:*      users:(("nginx",pid=189412,fd=6))

Почему это ценно. Мы получили полную картину сети container, ничего не устанавливая в образ. Приём работает даже для образов на базе distroless и scratch, где нет ни оболочки, ни утилит — а именно такие образы рекомендуются для production (раздел 11).

Читаем результат:

  • eth0@if17 — один конец veth pair; if17 указывает на парный интерфейс на host;
  • маршрут по умолчанию ведёт на 172.17.0.1 — это адрес bridge docker0;
  • nginx слушает 0.0.0.0:80, то есть доступен со всех интерфейсов container. Если бы он слушал 127.0.0.1:80, публикация порта не помогла бы — это debugging challenge 4.

Найдём парный veth на host:

bash
IFINDEX="$(sudo nsenter -t "$CPID" -n cat /sys/class/net/eth0/iflink)"
ip -brief link show | awk -v i="$IFINDEX" -F'[@:]' '$0 ~ "^"i":" {print}'

Механизм подробно разбирается в разделе 08.

bash
docker rm -f net1

Задание 8. Диагностика: --pid=host [диаг.]

Постановка. Запустите container с флагом --pid=host и сравните его с обычным. Объясните:

  1. Что изменилось в том, что видит container.
  2. Какой namespace перестал создаваться.
  3. Почему это опасно и в каких случаях всё же применяется.

Ожидаемый результат. Сравнение выводов и объяснение риска на конкретном примере.

Разбор
bash
echo "=== Обычный container ==="
docker run --rm alpine sh -c 'ps -e --no-headers | wc -l'

echo "=== С --pid=host ==="
docker run --rm --pid=host alpine sh -c 'ps -e --no-headers | wc -l'
text
=== Обычный container ===
2
=== С --pid=host ===
412

1. Что изменилось. Container видит все процессы host, включая процессы других containers и системные службы.

2. Какой namespace. PID namespace не создаётся — container помещается в PID namespace host:

bash
docker run -d --name pidhost --pid=host alpine sleep 300
CPID="$(docker inspect pidhost --format '{{.State.Pid}}')"
sudo readlink /proc/"$CPID"/ns/pid
sudo readlink /proc/1/ns/pid
text
pid:[4026531836]
pid:[4026531836]

Номера совпали — это тот же namespace, что у host.

3. Почему опасно. Три конкретных последствия.

Раскрытие информации. Командные строки процессов видны всем:

bash
docker run --rm --pid=host alpine sh -c 'ps -eo pid,args' | head -20

Если какая-то программа на host запущена с паролем или токеном в аргументах — а это, к сожалению, встречается, — container их прочитает.

Возможность посылать сигналы. В сочетании с CAP_KILL (входит в набор по умолчанию) процесс может завершать процессы host:

bash
# НЕ ВЫПОЛНЯЙТЕ на рабочей машине: остановит службу на host
# docker run --rm --pid=host alpine kill <pid-процесса-host>

Чтение памяти процессов. В сочетании с --cap-add=SYS_PTRACE доступ к /proc/<pid>/mem чужих процессов даёт чтение их памяти — включая расшифрованные секреты.

Когда применяется законно. Мониторинговые агенты, которым нужно видеть процессы host, и отладочные инструменты. В этих случаях container должен быть максимально ограничен другими средствами: read-only rootfs, --cap-drop=ALL плюс минимум необходимого, non-root user.

Общий принцип. Флаги, отменяющие создание namespace (--pid=host, --net=host, --ipc=host, --uts=host), снимают изоляцию по соответствующему измерению. Каждый требует явного обоснования. Подробно — в разделе 12.

bash
docker rm -f pidhost

Очистка после раздела

bash
# ВНИМАНИЕ: НЕ `docker ps -aq | xargs -r docker rm -f`.
# Такая строка удаляет ВСЕ container'ы на машине, включая чужие:
# базу коллеги, кластер kind, работающий стенд. Проверено дорого —
# при подготовке курса она снесла кластер, поднятый для раздела 18.
# Удаляем только то, что создали в этом разделе.
for img in alpine ubuntu:24.04 debian:trixie-slim nginx:alpine; do
    docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
docker rmi alpine ubuntu:24.04 debian:trixie-slim nginx:alpine 2>/dev/null || true
docker system df

Критерии завершения

Раздел закрыт, когда выполнены обязательные задания 1–4 и вы можете без подсказок ответить на вопросы из MAIN.md раздела.

Дальше: Quiz 02 и Checkpoint 1.


Навигация

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

Markdown на GitHub ↗