Раздел 2. Практические задания
Задания выполняются в реальной системе. Разбор открывайте только после самостоятельной попытки.
Обозначения: [обяз.] — обязательное, [доп.] — дополнительное, [★] — повышенной сложности, [диаг.] — диагностическое.
Подготовка:
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, и доказательство их принадлежности одному объекту.
Проверка:
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
Разбор
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"
внутри container:
PID COMMAND
1 sleep
7 ps
на host:
PID PPID USER COMMAND
188104 188082 root sleep
Доказательство тождества — три независимых способа:
# 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}}'
sleep 3600
sleep 3600
c8f4a1b2d3e5
Exited (137) 1 second ago
Третий способ — самый убедительный: убив процесс на host, мы остановили container. Это один объект.
docker rm -f p1
Задание 2. Namespaces процесса [обяз.]
Постановка. Для работающего container покажите все его namespaces двумя способами: через /proc/<pid>/ns/ и через lsns. Определите, какие namespaces созданы, а какие разделяются с host.
Ожидаемый результат. Таблица из восьми строк с указанием «создан» / «разделяется».
Проверка:
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.
Проверка:
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 полный путь выглядит как
/sys/fs/cgroup/system.slice/docker-<id>.scope/memory.max
Изнутри container — просто
/sys/fs/cgroup/memory.max
Причина — cgroup namespace (урок 2.3). Он показывает процессу его собственную cgroup как корень иерархии. Процесс не видит структуру host и не знает своего реального положения в дереве.
Практическая ценность: приложение может прочитать свой лимит по фиксированному пути, не зная ничего об окружении. Это правильный способ определить доступную память — в отличие от /proc/meminfo, который показывает память host:
docker run --rm --memory=256m alpine sh -c '
echo "cgroup: $(cat /sys/fs/cgroup/memory.max) байт"
echo "meminfo: $(grep MemTotal /proc/meminfo)"
'
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) и рабочая демонстрация.
Проверка:
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
Разбор
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")"
root на host: 000001ffffffffff → 41 capabilities
обычный container: 00000000a80425fb → 14 capabilities
privileged: 000001ffffffffff → 41 capabilities
--privileged возвращает полный набор — container перестаёт быть ограниченным.
Практическая демонстрация. Монтирование требует CAP_SYS_ADMIN:
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 "смонтировано"'
без 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 с объяснением каждого компонента.
Проверка:
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.
Проверка:
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
Разбор
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, формируемый ядром:
diff <(cat /proc/version) <(docker run --rm alpine cat /proc/version) && echo "идентично"
идентично
Почему нельзя запустить Windows-приложение. Программа обращается к ядру через системные вызовы. У Linux и Windows совершенно разные наборы вызовов и разные соглашения о них. Windows-программа вызовет NtCreateFile, которого в Linux не существует.
Container не предоставляет собственного ядра — он использует ядро host. Значит, в Linux-container может работать только то, что умеет обращаться к ядру Linux.
Для запуска Windows-приложения нужна виртуальная машина с Windows либо слой совместимости, транслирующий системные вызовы (Wine) — но это уже не контейнеризация.
Задание 7. Вход в network namespace [★]
Постановка. Не заходя внутрь container и не устанавливая в него пакеты, посмотрите с host:
- Сетевые интерфейсы container и его IP-адрес.
- Таблицу маршрутизации container.
- Открытые сокеты container с указанием процессов.
Используйте инструменты host.
Ожидаемый результат. Три вывода, полученные без docker exec и без изменения образа.
Подсказка 1
nsenter -t <pid> -n <команда> выполняет команду в сетевом namespace целевого процесса, используя бинарные файлы host.
Подсказка 2
Для проверки сокетов запустите container, который действительно что-то слушает, — например, nginx.
Разбор
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
=== 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— это адрес bridgedocker0; - nginx слушает
0.0.0.0:80, то есть доступен со всех интерфейсов container. Если бы он слушал127.0.0.1:80, публикация порта не помогла бы — это debugging challenge 4.
Найдём парный veth на host:
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.
docker rm -f net1
Задание 8. Диагностика: --pid=host [диаг.]
Постановка. Запустите container с флагом --pid=host и сравните его с обычным. Объясните:
- Что изменилось в том, что видит container.
- Какой namespace перестал создаваться.
- Почему это опасно и в каких случаях всё же применяется.
Ожидаемый результат. Сравнение выводов и объяснение риска на конкретном примере.
Разбор
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'
=== Обычный container ===
2
=== С --pid=host ===
412
1. Что изменилось. Container видит все процессы host, включая процессы других containers и системные службы.
2. Какой namespace. PID namespace не создаётся — container помещается в PID namespace host:
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
pid:[4026531836]
pid:[4026531836]
Номера совпали — это тот же namespace, что у host.
3. Почему опасно. Три конкретных последствия.
Раскрытие информации. Командные строки процессов видны всем:
docker run --rm --pid=host alpine sh -c 'ps -eo pid,args' | head -20
Если какая-то программа на host запущена с паролем или токеном в аргументах — а это, к сожалению, встречается, — container их прочитает.
Возможность посылать сигналы. В сочетании с CAP_KILL (входит в набор по умолчанию) процесс может завершать процессы host:
# НЕ ВЫПОЛНЯЙТЕ на рабочей машине: остановит службу на 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.
docker rm -f pidhost
Очистка после раздела
# ВНИМАНИЕ: НЕ `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
Главное оглавление