Главная/Основы Containerization/Урок

2.1. Процессы и containers

Цели

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

  • объяснить, что такое process в Linux и какими атрибутами он обладает;
  • сформулировать, что container — это процесс host-системы, а не отдельная машина;
  • найти процесс container в списке процессов host и объяснить, почему PID различаются;
  • объяснить, что именно ядро подменяет процессу, чтобы получился container;
  • назвать ресурсы, которые container разделяет с host, и те, что изолирует;
  • доказать командами, что kernel у host и container общий.

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

  • Раздел 01. Подготовка среды — работающий Docker;
  • базовое понимание процессов: PID, родительский процесс, ps, kill;
  • умение читать вывод команд в терминале.

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

ТерминОбъяснение
processВыполняющаяся программа со своим адресным пространством, дескрипторами файлов и контекстом выполнения
PIDProcess ID — числовой идентификатор процесса, уникальный в пределах своего PID namespace
PPIDParent PID — идентификатор родительского процесса
namespaceМеханизм ядра, изолирующий представление процесса об одном из видов системных ресурсов
cgroupМеханизм ядра, ограничивающий потребление ресурсов группой процессов
rootfsКорневая файловая система, которую процесс видит как /
kernelЯдро операционной системы. У host и всех его containers оно одно

Теория

Что такое process

Процесс в Linux — это выполняющаяся программа плюс всё, что ядро хранит о ней. У процесса есть:

АтрибутЧто это
PIDНомер процесса
PPIDНомер родителя
UID / GIDОт чьего имени выполняется
Рабочий каталогГде он «находится» в файловой системе
Корневой каталогЧто процесс считает корнем /
Дескрипторы файловОткрытые файлы, сокеты, каналы
Адресное пространствоВыделенная память
Переменные окруженияPATH, HOME и остальные
CapabilitiesКакие привилегированные операции разрешены

Всё это ядро хранит в структурах, доступных через файловую систему /proc. Каталог /proc/<pid>/ — это окно в состояние процесса.

Ключевое наблюдение: большинство атрибутов процесса — не свойства программы, а свойства записи в ядре. Программа не решает, какой у неё PID; ядро назначает его. Программа не решает, что считать корнем файловой системы; ядро сообщает ей это.

Отсюда следует главная идея контейнеризации.

Идея контейнеризации

Если ядро само сообщает процессу, что он видит, то ядро может сообщить другое.

Container — это обычный процесс, которому ядро подменило ответы на несколько вопросов:

Вопрос процессаОбычный ответОтвет для container
Какие процессы есть в системе?Все процессы hostТолько процессы этого container
Какой у меня PID?Настоящий, например 1842131
Что является корнем /?Корень hostРаспакованные слои образа
Какие сетевые интерфейсы есть?Интерфейсы hostСобственный eth0 и lo
Как называется хост?my-laptopID container
Сколько памяти доступно?Вся память машиныСтолько, сколько задано лимитом

Механизмы, реализующие эту подмену:

  • namespaces — изолируют то, что процесс видит (урок 2.3);
  • cgroups — ограничивают то, что процесс потребляет (урок 2.4);
  • capabilities — ограничивают то, что процессу разрешено (урок 2.5);
  • смена корня — определяет, какую файловую систему процесс видит как /.

Никакой отдельной сущности «container» в ядре Linux нет. Есть процесс с определённым набором namespaces, помещённый в определённую cgroup, с определённым корневым каталогом и ограниченным набором capabilities. «Container» — это термин уровня инструментов, а не уровня ядра.

Что это означает практически

Из «container — это процесс» следуют выводы, объясняющие большую часть поведения Docker.

1. Container живёт, пока живёт его главный процесс. Не «пока не выключим», а именно пока процесс не завершится. Отсюда — «container сразу упал» для команд, которые просто отработали и вышли.

2. Запуск container стоит примерно столько же, сколько запуск процесса. Миллисекунды. Никакой загрузки операционной системы не происходит, потому что операционная система уже загружена — это ваш host.

3. Kernel общий. Container не может использовать другую версию ядра, загрузить свой модуль или изменить системные параметры (без специальных привилегий). Всё, что делает ядро — оно делает одинаково для host и для containers.

4. Изоляция ограничена возможностями ядра. Если в ядре есть ошибка, она есть и для containers. Это фундаментальное отличие от виртуальных машин и основание для раздела 12.

5. Обычные инструменты Linux применимы. ps, top, strace, lsof на host видят процессы containers. Это основа диагностики в разделе 13.


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

Что происходит при запуске container

Упрощённая последовательность (полная — в уроке 2.6):

  1. Собирается корневая файловая система: слои образа объединяются в единое дерево.
  2. Создаются namespaces нужных типов.
  3. Создаётся cgroup и в неё записываются лимиты.
  4. Вызывается clone() или unshare() — порождается процесс, помещённый в созданные namespaces.
  5. В новом процессе выполняется pivot_root(): корнем становится подготовленное дерево.
  6. Отбираются лишние capabilities, применяется профиль seccomp.
  7. Выполняется execve() — образ процесса заменяется на программу из container.

Шаг 7 важен: процесс не порождает приложение как дочернее, а становится им. Поэтому приложение получает PID 1 внутри своего namespace — со всеми последствиями, которые разбираются в разделе 04.

Два PID у одного процесса

После создания PID namespace процесс имеет два номера одновременно:

  • PID внутри своего namespace (обычно 1 для главного процесса container);
  • PID в namespace host (обычное большое число).

Это не копия и не отображение — это один процесс, у которого ядро хранит идентификатор в каждом namespace, к которому он принадлежит. PID namespaces вложены: host видит все процессы, container — только свои.

text
   PID namespace host
   ┌────────────────────────────────────────────┐
   │  PID 1      systemd                        │
   │  PID 1842   dockerd                        │
   │  PID 184213 sleep 3600  ◄─────┐            │
   │                               │  один и    │
   │   PID namespace container     │  тот же    │
   │   ┌───────────────────────────┼────────┐   │
   │   │  PID 1     sleep 3600  ◄──┘        │   │
   │   └────────────────────────────────────┘   │
   └────────────────────────────────────────────┘

Практическое следствие: kill 184213 на host и kill 1 внутри container адресуют один процесс. Но результат различается — PID 1 внутри namespace имеет особый статус, о чём в разделе 04.


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

Процесс на host

Запустим долгоживущий процесс обычным способом:

bash
sleep 3600 &
text
[1] 187422

Число — PID процесса. Посмотрим его:

bash
ps -o pid,ppid,user,comm -p 187422
text
    PID    PPID USER     COMMAND
 187422  186930 evg      sleep

Что видно: PID, родитель (ваша оболочка), пользователь, имя команды.

Завершим:

bash
kill 187422

Тот же процесс в container

bash
docker run -d --name proc-demo alpine sleep 3600
text
c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1

Что видит процесс внутри container:

bash
docker exec proc-demo ps -o pid,ppid,user,comm
text
PID   PPID  USER     COMMAND
    1     0 root     sleep
    7     0 root     ps

Обратите внимание: sleep имеет PID 1, а список содержит только процессы container. Всей остальной системы для него не существует.

Теперь найдём тот же процесс на host:

bash
HOST_PID="$(docker inspect proc-demo --format '{{.State.Pid}}')"
echo "PID на host: $HOST_PID"
ps -o pid,ppid,user,comm -p "$HOST_PID"
text
PID на host: 188104
    PID    PPID USER     COMMAND
 188104  188082 root     sleep

Один процесс, два номера. Внутри container — 1, снаружи — 188104. Это ключевая демонстрация всего урока.

Убедимся, что это действительно один процесс, а не два разных:

bash
docker exec proc-demo cat /proc/1/cmdline | tr '\0' ' '; echo
cat "/proc/$HOST_PID/cmdline" | tr '\0' ' '; echo
text
sleep 3600 
sleep 3600 

Команда tr '\0' ' ' заменяет нулевые байты пробелами: в /proc/<pid>/cmdline аргументы разделены нулями.

Проверим, что процесс на host действительно принадлежит нашему container:

bash
cat "/proc/$HOST_PID/cgroup"
text
0::/system.slice/docker-c8f4a1b2d3e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1.scope

В пути cgroup видно полный ID container. Связь однозначна.

Дерево процессов

bash
ps -eo pid,ppid,comm --forest | grep -A2 -B2 'sleep' | head -20

Или нагляднее:

bash
pstree -p "$HOST_PID" -s
text
systemd(1)───containerd-shim(188082)───sleep(188104)

Родитель нашего sleep — не dockerd, а containerd-shim. Почему именно так, разбирается в уроке 2.6 и подробно в разделе 17.

Общий kernel

bash
uname -r
docker exec proc-demo uname -r
text
6.8.0-88-generic
6.8.0-88-generic

Одинаково — и не может быть иначе. Container не содержит ядра и не может его содержать: он использует ядро host.

Проверим на образе другого дистрибутива:

bash
docker run --rm ubuntu:24.04 uname -r
docker run --rm alpine uname -r

Оба вернут ядро вашего host, а не «ядро Ubuntu» и «ядро Alpine». Образ содержит пользовательское окружение — библиотеки, утилиты, файловую структуру дистрибутива, — но не ядро.

Это объясняет, почему на Linux с ядром 6.8 работают и Ubuntu-образы, и Alpine-образы, и Debian-образы одновременно: им всем нужно ядро Linux, а какое именно — определяет host.

Что разделяется, а что изолируется

Проверим по пунктам.

Разделяется: kernel.

bash
docker exec proc-demo uname -r   # то же, что на host

Разделяется: время системы.

bash
date +%s
docker exec proc-demo date +%s

Значения совпадут (с точностью до секунды выполнения). Time namespace существует, но Docker его по умолчанию не использует для смещения часов.

Разделяется: информация о CPU и памяти в /proc.

bash
nproc
docker exec proc-demo nproc
text
8
8

Container видит все CPU host, даже если ему выставлен лимит. Это ровно та проблема, из-за которой Gunicorn с автоматическим расчётом worker-процессов создаёт их по числу CPU машины, а не по выделенной доле. Разбирается в разделе 06.

Изолируется: список процессов.

bash
ps -e | wc -l
docker exec proc-demo sh -c 'ps -e | wc -l'
text
412
3

Изолируется: файловая система.

bash
ls /
docker exec proc-demo ls /

Разные деревья. У container — содержимое образа Alpine.

Изолируется: имя хоста.

bash
hostname
docker exec proc-demo hostname
text
my-laptop
c8f4a1b2d3e5

По умолчанию именем хоста в container становится сокращённый ID.

Изолируется: сеть.

bash
ip -o addr show | awk '{print $2, $4}'
docker exec proc-demo ip -o addr show | awk '{print $2, $4}'
text
lo 127.0.0.1/8
wlp0s20f3 192.168.1.14/24
docker0 172.17.0.1/16
---
lo 127.0.0.1/8
eth0 172.17.0.2/16

У container собственный сетевой стек. Подробно — в разделе 08.

Уборка

bash
docker rm -f proc-demo

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

Задание. Напишите скрипт compare-process.sh, который наглядно демонстрирует, что container — это процесс host.

Скрипт должен:

  1. Запустить container с долгоживущим процессом.
  2. Показать PID процесса внутри container и на host.
  3. Доказать, что это один и тот же процесс (сравнением командной строки и через cgroup).
  4. Показать родителя процесса на host.
  5. Сравнить версию ядра внутри и снаружи.
  6. Сравнить количество видимых процессов.
  7. Удалить container за собой.

Скрипт должен работать при любом имени container и не оставлять мусора даже при ошибке.

Подсказки

Подсказка 1

PID процесса на host извлекается так:

bash
docker inspect <name> --format '{{.State.Pid}}'
Подсказка 2

Чтобы гарантировать уборку даже при ошибке, используйте trap:

bash
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
Подсказка 3

Связь процесса с container видна в /proc/<pid>/cgroup — там присутствует полный ID container. Сравните его с выводом docker inspect --format '{{.Id}}'.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
#!/usr/bin/env bash
# compare-process.sh — демонстрация того, что container является процессом host.
set -euo pipefail

NAME="proc-compare-$$"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT

echo "1. Запуск container"
docker run -d --name "$NAME" alpine sleep 3600 > /dev/null
CID="$(docker inspect "$NAME" --format '{{.Id}}')"
HOST_PID="$(docker inspect "$NAME" --format '{{.State.Pid}}')"
echo "   container: ${CID:0:12}"

echo
echo "2. PID процесса"
IN_PID="$(docker exec "$NAME" sh -c 'echo $$; ' >/dev/null; docker exec "$NAME" ps -o pid,comm | awk '$2=="sleep"{print $1}')"
printf '   внутри container: %s\n' "$IN_PID"
printf '   на host:          %s\n' "$HOST_PID"

echo
echo "3. Это один процесс?"
in_cmd="$(docker exec "$NAME" tr '\0' ' ' < /proc/1/cmdline 2>/dev/null || docker exec "$NAME" sh -c "tr '\\0' ' ' < /proc/1/cmdline")"
host_cmd="$(tr '\0' ' ' < "/proc/$HOST_PID/cmdline")"
printf '   cmdline внутри: %s\n' "$in_cmd"
printf '   cmdline на host: %s\n' "$host_cmd"

if grep -q "$CID" "/proc/$HOST_PID/cgroup"; then
    echo "   [+] /proc/$HOST_PID/cgroup содержит ID container — связь подтверждена"
else
    echo "   [!] связь через cgroup не подтверждена"
fi

echo
echo "4. Родитель процесса на host"
ppid="$(awk '{print $4}' "/proc/$HOST_PID/stat")"
printf '   PPID %s: %s\n' "$ppid" "$(tr -d '\0' < "/proc/$ppid/comm" 2>/dev/null || echo '?')"

echo
echo "5. Версия ядра"
printf '   host:      %s\n' "$(uname -r)"
printf '   container: %s\n' "$(docker exec "$NAME" uname -r)"

echo
echo "6. Видимых процессов"
printf '   host:      %s\n' "$(ps -e --no-headers | wc -l)"
printf '   container: %s\n' "$(docker exec "$NAME" sh -c 'ps -e --no-headers 2>/dev/null | wc -l')"

echo
echo "Вывод: один и тот же процесс имеет PID 1 внутри своего namespace"
echo "и обычный PID на host. Ядро общее. Изолирован список процессов."

Запуск:

bash
chmod +x compare-process.sh
./compare-process.sh

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

text
1. Запуск container
   container: c8f4a1b2d3e5

2. PID процесса
   внутри container: 1
   на host:          188104

3. Это один процесс?
   cmdline внутри: sleep 3600 
   cmdline на host: sleep 3600 
   [+] /proc/188104/cgroup содержит ID container — связь подтверждена

4. Родитель процесса на host
   PPID 188082: containerd-shim

5. Версия ядра
   host:      6.8.0-88-generic
   container: 6.8.0-88-generic

6. Видимых процессов
   host:      412
   container: 2

trap ... EXIT гарантирует удаление container, даже если скрипт прервётся посередине.

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

Убедитесь, что можете ответить на три вопроса про свой вывод:

  1. Почему PID внутри и снаружи различаются, хотя процесс один?
  2. Почему версии ядра одинаковы, хотя образ Alpine, а host — Ubuntu?
  3. Почему родителем процесса на host является containerd-shim, а не dockerd?

Первые два ответа есть в этом уроке, третий — в уроке 2.6.

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

ОшибкаПричинаИсправление
«Container — это лёгкая виртуальная машина»Аналогия удобна, но ведёт к неверным ожиданиям об изоляции и безопасностиContainer — процесс. Разница подробно разбирается в уроке 2.2
Ожидание, что в container можно загрузить модуль ядраЯдро общее, и модули загружаются на уровне hostЗагружать модуль на host; container им воспользуется
Расчёт worker-процессов по nproc внутри containerContainer видит все CPU host, а не свой лимитЧитать лимит из cgroup — см. раздел 06
Поиск процессов container в ps внутри самого container при диагностике зависанийВнутри виден только собственный списокСмотреть с host: docker top или ps по PID из docker inspect
Попытка «выключить» container вместо завершения процессаContainer не имеет отдельного состояния «выключен»Завершается главный процесс — завершается container
Ожидание, что образ Alpine даст «ядро Alpine»Образ содержит пользовательское окружение, но не ядроuname -r всегда покажет ядро host

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

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

  1. Почему в ядре Linux нет объекта «container»?
  2. Почему главный процесс container обычно имеет PID 1?
  3. Почему запуск container занимает миллисекунды, а запуск виртуальной машины — секунды?
  4. Что означает утверждение «kernel разделяется» и какие практические последствия у него есть?
  5. Почему nproc внутри container может показать больше CPU, чем ему выделено?

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

  1. Как найти PID процесса container на host?
  2. Как убедиться, что конкретный процесс на host принадлежит конкретному container?
  3. Как посмотреть командную строку главного процесса container, не заходя внутрь?

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

  1. Container «завис» и не отвечает на docker exec. Как посмотреть, что делает его процесс, средствами host?
  2. Приложение внутри container сообщает о 16 доступных CPU, хотя запущено с --cpus=2. Объясните причину.

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

  1. Процесс в Linux — это программа плюс запись о ней в ядре; большинство «свойств» процесса задаёт ядро.
  2. Container — обычный процесс, которому ядро подменило представление о системе.
  3. Подмена реализуется namespaces (что видно), cgroups (сколько можно), capabilities (что разрешено) и сменой корня.
  4. Отдельной сущности «container» в ядре нет — это термин уровня инструментов.
  5. Один процесс имеет PID 1 внутри своего namespace и обычный PID на host.
  6. Kernel у host и всех containers общий; образ не содержит ядра.
  7. Container живёт ровно столько, сколько живёт его главный процесс.
  8. Изоляция ограничена возможностями ядра — это основание для отдельного разговора о безопасности.
  9. Часть информации (/proc/cpuinfo, /proc/meminfo) не изолируется и отражает host.
  10. Обычные инструменты Linux на host видят процессы containers — это база диагностики.

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

ИсточникСсылкаЧто подтверждает
Docker overviewhttps://docs.docker.com/get-started/docker-overview/Определение container и его отличие от VM
namespaces(7) man pagehttps://man7.org/linux/man-pages/man7/namespaces.7.htmlМеханизм namespaces и их вложенность
pid_namespaces(7) man pagehttps://man7.org/linux/man-pages/man7/pid_namespaces.7.htmlДва PID у одного процесса, особый статус PID 1
proc(5) man pagehttps://man7.org/linux/man-pages/man5/proc.5.htmlСодержимое /proc/<pid>/: cmdline, cgroup, stat
clone(2) man pagehttps://man7.org/linux/man-pages/man2/clone.2.htmlФлаги создания namespaces при порождении процесса
docker inspect referencehttps://docs.docker.com/reference/cli/docker/inspect/Поле .State.Pid и шаблоны --format
docker top referencehttps://docs.docker.com/reference/cli/docker/container/top/Просмотр процессов container с точки зрения host

Навигация

Вернуться к разделу
Следующий материал → Containers против virtual machines
Главное оглавление

Markdown на GitHub ↗