19.1. Где containers усложняют систему
Цели
После этого материала вы сможете:
- назвать цену дополнительного слоя абстракции в конкретных статьях;
- показать, во что превращается путь от симптома к причине при отладке;
- объяснить, почему persistent state противоречит модели container'а;
- назвать, где производительность страдает, а где расходов практически нет;
- оценить постоянную стоимость инфраструктуры сборки;
- объяснить, какую часть свойств Docker даёт
systemdтеми же механизмами ядра.
Предварительные знания
- 17.5. OverlayFS — copy-up;
- 17.4. Cgroups v2 — ограничения ресурсов;
- 8.2. Bridge-сеть — путь пакета;
- 13.5. Глубокая отладка.
Docker на машине курса не установлен. Все команды, требующие daemon, приводятся для справки и помечены как не выполнявшиеся. Проверки, выполнимые без него, выполняются по-настоящему.
Ключевые термины
| Термин | Объяснение |
|---|---|
слой абстракции | Уровень, скрывающий детали и добавляющий собственные |
эфемерность | Свойство container'а исчезать вместе с записанными в него данными |
passthrough | Проброс устройства хоста внутрь container'а без виртуализации |
связывание с хостом | Зависимость образа от версии драйвера или ядра хоста |
unit | Описание сервиса для systemd |
Теория
Цена, о которой не говорят
Восемнадцать разделов курса объясняли, что Docker даёт. Этот объясняет, что он берёт.
Ни один пункт ниже не является доводом против Docker. Каждый — статья расходов, которую нужно знать, чтобы посчитать итог. Инженер отличается от сторонника технологии тем, что может назвать цену.
| Статья | Где проявляется |
|---|---|
| Удлинение пути отладки | Каждый инцидент |
| Противоречие с persistent state | Любые данные |
| Расходы на вводе-выводе и сети | Нагруженные системы |
| Связывание с хостом при работе с оборудованием | GPU, устройства |
| Инфраструктура сборки | Постоянно |
| Обучение и наём | Постоянно |
Отладка: путь от симптома к причине
Без container'а путь короткий:
симптом → лог приложения → процесс → система
С container'ом между кодом и системой появляются уровни:
симптом → лог приложения → процесс в PID namespace →
→ cgroup (лимиты) → OverlayFS (файлы) →
→ veth → bridge → NAT → сеть хоста
Практическое следствие удобно показать на одном сообщении. Connection refused в Compose означает по меньшей мере шесть разных вещей:
| Причина | Где искать |
|---|---|
| Процесс в целевом container'е не запустился | Логи целевого сервиса |
Процесс слушает 127.0.0.1, а не 0.0.0.0 | Конфигурация приложения |
| Сервис ещё не готов, хотя container запущен | healthcheck и порядок старта |
| Container'ы в разных сетях | docker network inspect |
| Опечатка в имени сервиса | Разрешение имён внутри сети |
| Порт опубликован, но приложение слушает другой | docker port и конфигурация |
Без container'а из этого списка остаются первые три. Три добавились вместе с абстракцией.
Отсюда требование, которое стоит записать прямо: контейнеризация повышает планку к observability. Система, отлаживавшаяся strace и tail -f, требует структурированных логов, healthcheck и понимания сети — иначе диагностика становится дороже, чем была (раздел 13).
Persistent state против эфемерности
Container устроен как процесс плюс записываемый слой, который исчезает при удалении. Это не недостаток реализации, а модель: она позволяет заменять экземпляры не думая.
Данные в эту модель не помещаются. Том — исключение, пробитое в ней намеренно, и всё, что связано с данными, оказывается вне Docker:
| Задача | Кто решает |
|---|---|
| Резервное копирование | Вы |
| Восстановление на определённый момент | Вы |
| Права доступа к файлам тома | Вы, вручную |
| Перенос между машинами | Вы |
| Обновление версии СУБД с миграцией данных | Вы |
| Отказоустойчивость хранилища | Вы |
Docker даёт только подключение каталога. Всё перечисленное он не упрощает и не усложняет — он просто этим не занимается, а ощущение «данные тоже в Docker» создаёт впечатление обратного.
Отсюда практический вывод: база данных в container — отдельное решение, а не умолчание. Оно бывает верным (разработка, тесты, небольшая нагрузка с настроенным копированием) и бывает неверным (эксплуатация без ответа на шесть вопросов выше).
Производительность: где расходы есть, а где их нет
Распространённое заблуждение — что container «медленнее» вообще. Это неточно, и неточность мешает принимать решения.
Процесс в container'е — обычный процесс ядра. Виртуализации инструкций нет, планировщик тот же, память та же. Расходов на вычислениях практически нет.
Расходы концентрируются в четырёх местах:
| Место | Механизм | Порядок |
|---|---|---|
| Системные вызовы | Фильтр seccomp проверяет каждый вызов | Заметно на нагрузке с интенсивными syscall |
| Файловая система | OverlayFS: стек поиска при чтении, copy-up при первой записи | Заметно на записи в файлы образа |
| Сеть | veth → bridge → NAT: обработка на каждом пакете | Заметно на высоком pps |
| Публикация портов | В части конфигураций трафик идёт через процесс-посредник | Заметно на пропускной способности |
Каждый расход снимается — и каждый снимается ценой изоляции:
| Приём | Что снимает | Что теряет |
|---|---|---|
--network host | Bridge и NAT | Сетевую изоляцию целиком |
| Bind mount вместо слоя | Copy-up | Независимость от каталогов хоста |
--security-opt seccomp=unconfined | Фильтр вызовов | Существенную часть защиты |
--pid host | Изоляцию PID | Видимость всех процессов хоста |
Закономерность видна: все способы ускорить container отключают то, ради чего он взят. Если для достижения нужной производительности требуется отключить всё перечисленное, вопрос стоит поставить иначе — нужен ли container вообще.
Оборудование: где ломается переносимость
GPU и специализированные устройства подключаются проходом (passthrough): Docker не виртуализирует устройство, а даёт к нему доступ.
Следствие важнее самого факта:
образ с CUDA ←→ версия драйвера на хосте
Пользовательская часть библиотек лежит в образе, драйвер — на хосте, и они должны быть совместимы. Образ, работавший на одной машине, на другой может не запуститься — с драйвером другой версии.
То же с --device: устройство пробрасывается, но модуль ядра остаётся на хосте.
Вывод формулируется точно: container с оборудованием перестаёт быть переносимым. Основное обещание — «работает везде одинаково» — здесь не выполняется, и это ограничение принципиальное, а не временное.
Стоимость инфраструктуры сборки
Одна машина с одним приложением требует: установить зависимости, запустить сервис. С Docker к этому добавляется постоянная работа:
| Что | Периодичность |
|---|---|
| Хранилище образов и его доступность | Постоянно |
| Обновление базовых образов при выходе исправлений | Ежемесячно и чаще |
| Пересборка при уязвимостях в зависимостях | По событию |
| Кэш сборки: настройка и поддержание | Постоянно |
| Сканирование и его результаты | Каждая сборка (урок 16.4) |
| Очистка старых образов и слоёв | Постоянно |
| Обучение и наём людей, знающих это | Постоянно |
Для системы из десяти сервисов это оправдано. Для одного сервиса на одной машине — часто нет.
Когда systemd-сервис проще
Самое неудобное для сторонника технологии наблюдение: значительная часть того, ради чего берут Docker, доступна из systemd на тех же механизмах ядра.
| Свойство | Docker | systemd |
|---|---|---|
| Автозапуск и перезапуск | restart: | Restart= |
| Ограничение памяти | mem_limit | MemoryMax= |
| Ограничение CPU | cpus | CPUQuota= |
Запуск не от root | USER | User= или DynamicUser=yes |
| Только для чтения корень | read_only | ProtectSystem=strict |
Свой /tmp | tmpfs | PrivateTmp=yes |
| Запрет повышения привилегий | no-new-privileges | NoNewPrivileges=yes |
| Скрытие устройств | --device по списку | PrivateDevices=yes |
| Своя сеть | сеть Docker | PrivateNetwork=yes |
| Ограничение вызовов | seccomp | SystemCallFilter= |
| Зависимости при старте | depends_on | After=, Requires= |
| Логи | драйвер логирования | journald |
| Изоляция файловой системы | образ | ProtectHome=, ReadWritePaths= |
Тринадцать строк совпадают по смыслу. Не совпадают три вещи, и они существенны:
Чего systemd не даёт | Почему это важно |
|---|---|
| Упаковку зависимостей | Библиотеки берутся из системы хоста |
| Одинаковость окружения на разных машинах | Главная ценность образа |
| Единообразие доставки | Один способ для всех приложений |
Отсюда точная формулировка границы: systemd заменяет изоляцию и управление сервисом, но не заменяет упаковку. Если приложение — один статически собранный бинарь или скрипт с зависимостями из репозитория дистрибутива, упаковка не нужна, и Docker решает задачу, которой нет.
Внутренний механизм
Почему systemd даёт ту же изоляцию
Причина в том, что оба используют одни и те же средства ядра (раздел 17):
| Средство ядра | Docker использует | systemd использует |
|---|---|---|
| cgroups v2 | Через containerd и runc | Напрямую, каждый unit — своя cgroup |
| namespaces | Шесть из восьми | По директивам: PrivateTmp, PrivateNetwork, ProtectSystem |
| seccomp | Профиль по умолчанию | SystemCallFilter= |
| capabilities | --cap-drop | CapabilityBoundingSet= |
Каждый unit systemd уже находится в собственной cgroup — это видно в systemd-cgls. То есть ограничения ресурсов работают там не «похоже», а буквально тем же механизмом.
Различие в другом: Docker упаковывает файловую систему приложения в образ, а systemd работает с файловой системой хоста. Отсюда и граница применимости.
Почему расходов на вычислениях нет, а на вводе-выводе есть
Container не перехватывает вычисления: код исполняется процессором напрямую, планировщик ядра тот же.
Ввод-вывод проходит через добавленные уровни:
- чтение файла — поиск по стеку слоёв OverlayFS вместо одного каталога;
- первая запись — копирование файла целиком в верхний слой (урок 17.5);
- сетевой пакет —
veth, bridge, правила NAT, и то же на обратном пути; - системный вызов — проверка фильтром seccomp.
Отсюда правило для оценки: чем больше доля вычислений, тем меньше цена контейнеризации; чем больше доля ввода-вывода — тем больше. Приложение, считающее числа, теряет почти ничего. Приложение, обрабатывающее сотни тысяч пакетов в секунду, теряет заметно.
Команды и примеры
Учёт того, что добавилось
mkdir -p /tmp/cost && cd /tmp/cost
cat > layers.py <<'PY'
"""Путь от симптома к причине: до и после контейнеризации."""
from __future__ import annotations
import json
PLAIN = [
("лог приложения", "tail -f /var/log/app.log"),
("процесс", "ps, strace, gdb"),
("система", "netstat, df, dmesg"),
]
CONTAINERIZED = [
("лог приложения", "docker logs"),
("процесс в PID namespace", "docker top; ps на хосте показывает другой PID"),
("cgroup", "лимиты могут убить процесс без записи в лог приложения"),
("OverlayFS", "файл записан, но в слой, а не туда, куда ожидалось"),
("veth и bridge", "пакет не дошёл до соседнего container'а"),
("NAT", "адрес источника подменён; в логах не тот IP"),
("сеть хоста", "порт опубликован не туда"),
]
REFUSED = [
("процесс не запустился", "есть без container'а"),
("слушает 127.0.0.1 вместо 0.0.0.0", "есть без container'а"),
("сервис ещё не готов", "есть без container'а"),
("container'ы в разных сетях", "ДОБАВЛЕНО контейнеризацией"),
("опечатка в имени сервиса", "ДОБАВЛЕНО контейнеризацией"),
("опубликован не тот порт", "ДОБАВЛЕНО контейнеризацией"),
]
def main() -> None:
print(" ── Путь от симптома к причине ──\n")
print(f" {'без container':<28} {'инструмент'}")
print(" " + "─" * 60)
for level, tool in PLAIN:
print(f" {level:<28} {tool}")
print(f"\n уровней: {len(PLAIN)}")
print()
print(f" {'с container':<28} {'что может пойти не так'}")
print(" " + "─" * 84)
for level, note in CONTAINERIZED:
print(f" {level:<28} {note}")
print(f"\n уровней: {len(CONTAINERIZED)} "
f"(+{len(CONTAINERIZED) - len(PLAIN)})")
print("\n ── Одно сообщение: Connection refused ──\n")
print(f" {'причина':<40} {'происхождение'}")
print(" " + "─" * 72)
added = 0
for cause, origin in REFUSED:
if "ДОБАВЛЕНО" in origin:
added += 1
print(f" {cause:<40} {origin}")
print(f"\n причин всего: {len(REFUSED)}, из них добавлено "
f"контейнеризацией: {added}")
print()
print(" Это и есть цена абстракции, выраженная измеримо:")
print(" то же сообщение теперь означает вдвое больше вещей.")
print()
print(json.dumps({"уровней_до": len(PLAIN),
"уровней_после": len(CONTAINERIZED),
"причин_добавлено": added}, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ что добавила абстракция ═══"
python3 layers.py
Ожидаемый вывод:
═══ что добавила абстракция ═══
── Путь от симптома к причине ──
без container инструмент
────────────────────────────────────────────────────────────
лог приложения tail -f /var/log/app.log
процесс ps, strace, gdb
система netstat, df, dmesg
уровней: 3
с container что может пойти не так
────────────────────────────────────────────────────────────────────────────────────
лог приложения docker logs
процесс в PID namespace docker top; ps на хосте показывает другой PID
cgroup лимиты могут убить процесс без записи в лог приложения
OverlayFS файл записан, но в слой, а не туда, куда ожидалось
veth и bridge пакет не дошёл до соседнего container'а
NAT адрес источника подменён; в логах не тот IP
сеть хоста порт опубликован не туда
уровней: 7 (+4)
── Одно сообщение: Connection refused ──
причина происхождение
────────────────────────────────────────────────────────────────────────
процесс не запустился есть без container'а
слушает 127.0.0.1 вместо 0.0.0.0 есть без container'а
сервис ещё не готов есть без container'а
container'ы в разных сетях ДОБАВЛЕНО контейнеризацией
опечатка в имени сервиса ДОБАВЛЕНО контейнеризацией
опубликован не тот порт ДОБАВЛЕНО контейнеризацией
причин всего: 6, из них добавлено контейнеризацией: 3
Проверка утверждения про systemd
Здесь можно проверить по-настоящему: systemd на машине есть, а Docker — нет.
cd /tmp/cost
echo "═══ версия и иерархия cgroup ═══"
systemctl --version | head -1
grep -o 'default-hierarchy=[a-z]*' <(systemctl --version) || true
echo "контроллеры cgroup v2:"
cat /sys/fs/cgroup/cgroup.controllers
cat > webapp.service <<'EOF'
[Unit]
Description=Веб-приложение без container
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/opt/webapp/venv/bin/python -m webapp.main
# ── то же, что даёт Docker, средствами systemd ──
DynamicUser=yes # непривилегированный пользователь, создаётся на лету
ProtectSystem=strict # вся ФС только для чтения
ProtectHome=yes # домашние каталоги скрыты
PrivateTmp=yes # свой /tmp
PrivateDevices=yes # устройства скрыты
NoNewPrivileges=yes # запрет повышения привилегий
RestrictSUIDSGID=yes # запрет создания suid-файлов
CapabilityBoundingSet= # все capabilities сняты
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StateDirectory=webapp # /var/lib/webapp — единственное место для записи
MemoryMax=256M # аналог mem_limit
MemoryHigh=200M # торможение ДО отказа: у Docker аналога нет
CPUQuota=50% # аналог cpus: 0.5
TasksMax=64 # аналог pids_limit
Restart=on-failure # аналог restart: unless-stopped
RestartSec=5
TimeoutStopSec=30 # аналог stop_grace_period
KillSignal=SIGTERM
Environment=LOG_LEVEL=info
EnvironmentFile=-/etc/webapp/env
[Install]
WantedBy=multi-user.target
EOF
echo
echo "═══ проверка unit-файла ═══"
systemd-analyze verify ./webapp.service 2>&1 | grep -F "webapp.service" \
|| echo " замечаний по webapp.service нет"
Ожидаемый вывод:
═══ версия и иерархия cgroup ═══
systemd 255 (255.4-1ubuntu8.11)
default-hierarchy=unified
контроллеры cgroup v2:
cpuset cpu io memory hugetlb pids rdma misc
═══ проверка unit-файла ═══
webapp.service: Command /opt/webapp/venv/bin/python is not executable: No such file or directory
Этот вывод получен на машине курса по-настоящему: systemd 255, единая иерархия cgroup v2, семь контроллеров.
Замечание в последней строке — не ошибка примера, а показательный результат. Каталога /opt/webapp на машине курса нет, и systemd-analyze verify проверяет существование исполняемого файла, а не только синтаксис.
У Docker прямого аналога такой проверки нет: опечатка в command: внутри compose.yaml выявляется только при запуске, а не при разборе конфигурации. Это редкий случай, где менее «продвинутый» инструмент проверяет больше.
Заменив путь на существующий, легко убедиться, что остальные директивы возражений не вызывают:
cd /tmp/cost
sed -i 's|^ExecStart=.*|ExecStart=/usr/bin/python3 -m http.server 8000|' webapp.service
systemd-analyze verify ./webapp.service 2>&1 | grep -F "webapp.service" \
|| echo " замечаний по webapp.service нет"
замечаний по webapp.service нет
Все двадцать директив — DynamicUser, ProtectSystem=strict, SystemCallFilter, MemoryMax, MemoryHigh, CPUQuota и остальные — приняты systemd 255 без единого возражения.
Отдельного внимания заслуживает строка MemoryHigh=200M. Это торможение при приближении к пределу, до срабатывания OOM (урок 17.4). У Docker прямого аналога нет: он задаёт только memory.max. Здесь systemd даёт больше, а не меньше.
Сопоставление свойств
cd /tmp/cost
cat > compare.py <<'PY'
"""Сопоставление свойств Docker и systemd по механизму ядра."""
from __future__ import annotations
import json
# (свойство, Docker, systemd, общий механизм ядра)
PROPS = [
("Автозапуск и перезапуск", "restart:", "Restart=", "—"),
("Ограничение памяти", "mem_limit", "MemoryMax=", "cgroups v2"),
("Торможение до OOM", "НЕТ", "MemoryHigh=", "cgroups v2"),
("Ограничение CPU", "cpus", "CPUQuota=", "cgroups v2"),
("Ограничение числа задач", "pids_limit", "TasksMax=", "cgroups v2"),
("Запуск не от root", "USER", "DynamicUser=yes", "—"),
("Корень только для чтения", "read_only", "ProtectSystem=strict", "mount ns"),
("Свой /tmp", "tmpfs", "PrivateTmp=yes", "mount ns"),
("Скрытие устройств", "--device", "PrivateDevices=yes", "mount ns"),
("Своя сеть", "сеть Docker", "PrivateNetwork=yes", "network ns"),
("Запрет повышения привилегий", "no-new-privileges", "NoNewPrivileges=yes",
"prctl"),
("Снятие capabilities", "--cap-drop ALL", "CapabilityBoundingSet=",
"capabilities"),
("Ограничение системных вызовов", "seccomp", "SystemCallFilter=", "seccomp"),
("Зависимости при старте", "depends_on", "After=, Requires=", "—"),
("Логи", "драйвер логирования", "journald", "—"),
]
ONLY_DOCKER = [
("Упаковка зависимостей", "библиотеки едут вместе с приложением",
"systemd берёт их из системы хоста"),
("Одинаковость окружения", "на любой машине та же файловая система",
"unit зависит от того, что установлено на хосте"),
("Единообразие доставки", "один способ для всех приложений",
"unit пишется под каждое приложение отдельно"),
]
def main() -> None:
print(f" {'свойство':<32} {'Docker':<20} {'systemd':<24} общий механизм")
print(" " + "─" * 96)
same = 0
for prop, docker, systemd, mech in PROPS:
if docker != "НЕТ":
same += 1
print(f" {prop:<32} {docker:<20} {systemd:<24} {mech}")
print()
print(f" совпадает по смыслу: {same} из {len(PROPS)}")
print(f" где systemd даёт больше: {len(PROPS) - same}")
print()
print(f" {'только у Docker':<28} {'что это значит':<44} чего нет у systemd")
print(" " + "─" * 116)
for prop, what, lacking in ONLY_DOCKER:
print(f" {prop:<28} {what:<44} {lacking}")
print()
print(" Граница проходит по упаковке, а не по изоляции:")
print(" systemd заменяет изоляцию и управление сервисом,")
print(" но не заменяет упаковку зависимостей.")
print()
print(" Следствие: если приложение — один бинарь или скрипт")
print(" с зависимостями из репозитория дистрибутива, упаковка")
print(" не нужна, и Docker решает задачу, которой нет.")
print()
print(json.dumps({"совпадает": same, "всего": len(PROPS),
"только_docker": len(ONLY_DOCKER)}, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ сопоставление ═══"
python3 compare.py
Ожидаемый вывод:
═══ сопоставление ═══
свойство Docker systemd общий механизм
────────────────────────────────────────────────────────────────────────────────────────────────
Автозапуск и перезапуск restart: Restart= —
Ограничение памяти mem_limit MemoryMax= cgroups v2
Торможение до OOM НЕТ MemoryHigh= cgroups v2
Ограничение CPU cpus CPUQuota= cgroups v2
Ограничение числа задач pids_limit TasksMax= cgroups v2
Запуск не от root USER DynamicUser=yes —
Корень только для чтения read_only ProtectSystem=strict mount ns
Свой /tmp tmpfs PrivateTmp=yes mount ns
Скрытие устройств --device PrivateDevices=yes mount ns
Своя сеть сеть Docker PrivateNetwork=yes network ns
Запрет повышения привилегий no-new-privileges NoNewPrivileges=yes prctl
Снятие capabilities --cap-drop ALL CapabilityBoundingSet= capabilities
Ограничение системных вызовов seccomp SystemCallFilter= seccomp
Зависимости при старте depends_on After=, Requires= —
Логи драйвер логирования journald —
совпадает по смыслу: 14 из 15
где systemd даёт больше: 1
только у Docker что это значит чего нет у systemd
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Упаковка зависимостей библиотеки едут вместе с приложением systemd берёт их из системы хоста
Одинаковость окружения на любой машине та же файловая система unit зависит от того, что установлено на хосте
Единообразие доставки один способ для всех приложений unit пишется под каждое приложение отдельно
Граница проходит по упаковке, а не по изоляции:
systemd заменяет изоляцию и управление сервисом,
но не заменяет упаковку зависимостей.
Где расходы есть, а где их нет
cd /tmp/cost
cat > overhead.py <<'PY'
"""Где контейнеризация стоит производительности, а где нет."""
from __future__ import annotations
import json
# (место, механизм, где заметно, чем снимается, что теряется при снятии)
OVERHEAD = [
("Вычисления", "обычный процесс ядра, планировщик тот же",
"нигде", "—", "—"),
("Память", "те же страницы, тот же аллокатор",
"нигде", "—", "—"),
("Системные вызовы", "фильтр seccomp проверяет каждый вызов",
"нагрузка с интенсивными syscall", "seccomp=unconfined",
"существенную часть защиты"),
("Чтение файлов", "OverlayFS ищет по стеку слоёв",
"много мелких файлов", "bind mount", "независимость от каталогов хоста"),
("Запись в файлы образа", "copy-up копирует файл целиком",
"запись в большие файлы", "том или bind mount", "эфемерность"),
("Сеть", "veth, bridge, правила NAT на каждый пакет",
"высокий pps", "--network host", "сетевую изоляцию целиком"),
("Публикация портов", "в части конфигураций — процесс-посредник",
"пропускная способность", "--network host", "сетевую изоляцию целиком"),
]
def main() -> None:
print(f" {'место':<26} {'механизм':<44} где заметно")
print(" " + "─" * 104)
free = 0
for place, mech, where, _, _ in OVERHEAD:
if where == "нигде":
free += 1
print(f" {place:<26} {mech:<44} {where}")
print()
print(f" мест без расходов: {free} из {len(OVERHEAD)}")
print()
print(f" {'способ ускорить':<28} {'что снимает':<30} что теряется")
print(" " + "─" * 100)
seen: set[str] = set()
for place, _, _, fix, loses in OVERHEAD:
if fix in ("—", "") or fix in seen:
continue
seen.add(fix)
print(f" {fix:<28} {place:<30} {loses}")
print()
print(" Закономерность: КАЖДЫЙ способ ускорить container отключает")
print(" то, ради чего container взят.")
print()
print(" Правило оценки: чем больше доля вычислений — тем меньше цена;")
print(" чем больше доля ввода-вывода — тем больше.")
print()
print(" Приложение, считающее числа, теряет почти ничего.")
print(" Приложение на сотнях тысяч пакетов в секунду теряет заметно.")
print()
print(json.dumps({"мест": len(OVERHEAD), "без_расходов": free,
"способов_ускорить": len(seen)}, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ расходы производительности ═══"
python3 overhead.py
echo
echo "═══ измерения, которые НЕ выполнялись ═══"
python3 - <<'PY'
NOT_RUN = [
("расходы на системных вызовах",
"docker run --rm IMAGE python bench_syscall.py против того же на хосте"),
("copy-up на большом файле",
"docker run --rm IMAGE sh -c 'dd if=/dev/zero of=/big bs=1M count=128'"),
("пропускная способность bridge против host",
"iperf3 в двух режимах --network"),
("время старта против systemd",
"docker run против systemctl start, оба с --property"),
]
print(f" {'измерение':<38} команда")
print(" " + "─" * 104)
for name, cmd in NOT_RUN:
print(f" {name:<38} {cmd}")
print(f"\n не выполнялось: {len(NOT_RUN)} — Docker на машине курса не установлен")
print()
print(" Числовые значения расходов в этом уроке НЕ приводятся намеренно:")
print(" они зависят от ядра, драйвера хранилища, нагрузки и оборудования.")
print(" Приведены механизмы — они не зависят от стенда.")
PY
cd /tmp && rm -rf /tmp/cost
Ожидаемый вывод:
═══ расходы производительности ═══
место механизм где заметно
────────────────────────────────────────────────────────────────────────────────────────────────────────
Вычисления обычный процесс ядра, планировщик тот же нигде
Память те же страницы, тот же аллокатор нигде
Системные вызовы фильтр seccomp проверяет каждый вызов нагрузка с интенсивными syscall
Чтение файлов OverlayFS ищет по стеку слоёв много мелких файлов
Запись в файлы образа copy-up копирует файл целиком запись в большие файлы
Сеть veth, bridge, правила NAT на каждый пакет высокий pps
Публикация портов в части конфигураций — процесс-посредник пропускная способность
мест без расходов: 2 из 7
способ ускорить что снимает что теряется
────────────────────────────────────────────────────────────────────────────────────────────────────
seccomp=unconfined Системные вызовы существенную часть защиты
bind mount Чтение файлов независимость от каталогов хоста
том или bind mount Запись в файлы образа эфемерность
--network host Сеть сетевую изоляцию целиком
Закономерность: КАЖДЫЙ способ ускорить container отключает
то, ради чего container взят.
═══ измерения, которые НЕ выполнялись ═══
измерение команда
────────────────────────────────────────────────────────────────────────────────────────────────────────
расходы на системных вызовах docker run --rm IMAGE python bench_syscall.py против того же на хосте
copy-up на большом файле docker run --rm IMAGE sh -c 'dd if=/dev/zero of=/big bs=1M count=128'
пропускная способность bridge против host iperf3 в двух режимах --network
время старта против systemd docker run против systemctl start, оба с --property
не выполнялось: 4 — Docker на машине курса не установлен
Числовые значения расходов в этом уроке НЕ приводятся намеренно:
они зависят от ядра, драйвера хранилища, нагрузки и оборудования.
Приведены механизмы — они не зависят от стенда.
Практическое упражнение
Задание. Постройте инструмент учёта цены контейнеризации и примените его к трём задачам.
Требования:
- Показать удлинение пути отладки числом уровней и числом причин одного сообщения.
- Сопоставить свойства Docker и
systemd, указав общий механизм ядра. - Сгенерировать unit-файл, дающий те же ограничения, и проверить его синтаксис по-настоящему.
- Проверить сам проверяющий: подать заведомо испорченный unit и убедиться, что замечание найдено.
- Перечислить места расходов производительности и способы их снять с указанием потерь.
- Применить к трём задачам с разными исходами и обосновать каждый.
- Отметить всё, что не проверялось, и назвать причину.
Подсказки
Подсказка 1
systemd-analyze verify ФАЙЛ проверяет unit без его установки. В песочнице команда дополнительно жалуется на посторонние units — фильтруйте вывод по имени своего файла.
Подсказка 2
Код возврата systemd-analyze verify не годится как признак: он равен нулю и на испорченном файле. Признак — наличие строк с именем файла в выводе.
Подсказка 3
Признак, по которому решение расходится: нужна ли упаковка. Изоляция и управление сервисом есть у обоих.
Решение
Показать решение
mkdir -p /tmp/limits && cd /tmp/limits
cat > cost.py <<'PY'
"""Учёт цены контейнеризации для конкретной задачи.
Инструмент не отвечает «нужен Docker или нет» одним словом.
Он раскладывает решение на статьи: что добавится к отладке,
что даст systemd теми же средствами ядра, где будут расходы,
и какой признак в итоге разводит два варианта.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# ── Путь от симптома к причине ────────────────────────────────────────
PLAIN_LEVELS = ["лог приложения", "процесс", "система"]
CONTAINER_LEVELS = [
"лог приложения", "процесс в PID namespace", "cgroup",
"OverlayFS", "veth и bridge", "NAT", "сеть хоста",
]
# (причина, добавлена ли контейнеризацией)
REFUSED_CAUSES = [
("процесс не запустился", False),
("слушает 127.0.0.1 вместо 0.0.0.0", False),
("сервис ещё не готов", False),
("container'ы в разных сетях", True),
("опечатка в имени сервиса", True),
("опубликован не тот порт", True),
]
# (свойство, Docker, systemd, механизм ядра)
PROPS = [
("Ограничение памяти", "mem_limit", "MemoryMax=", "cgroups v2"),
("Торможение до OOM", "НЕТ", "MemoryHigh=", "cgroups v2"),
("Ограничение CPU", "cpus", "CPUQuota=", "cgroups v2"),
("Ограничение числа задач", "pids_limit", "TasksMax=", "cgroups v2"),
("Запуск не от root", "USER", "DynamicUser=yes", "—"),
("Корень только для чтения", "read_only", "ProtectSystem=strict", "mount ns"),
("Свой /tmp", "tmpfs", "PrivateTmp=yes", "mount ns"),
("Скрытие устройств", "--device", "PrivateDevices=yes", "mount ns"),
("Запрет повышения привилегий", "no-new-privileges", "NoNewPrivileges=yes",
"prctl"),
("Снятие capabilities", "--cap-drop ALL", "CapabilityBoundingSet=",
"capabilities"),
("Ограничение системных вызовов", "seccomp", "SystemCallFilter=", "seccomp"),
("Автозапуск и перезапуск", "restart:", "Restart=", "—"),
("Мягкая остановка", "stop_grace_period", "TimeoutStopSec=", "—"),
("Логи", "драйвер логирования", "journald", "—"),
]
ONLY_DOCKER = [
"Упаковка зависимостей вместе с приложением",
"Одинаковость окружения на разных машинах",
"Единообразие доставки для всех приложений",
]
# (место, механизм, где заметно, чем снимается, что теряется)
OVERHEAD = [
("Вычисления", "обычный процесс ядра", "нигде", "", ""),
("Память", "те же страницы", "нигде", "", ""),
("Системные вызовы", "фильтр seccomp на каждый вызов",
"интенсивные syscall", "seccomp=unconfined", "существенную часть защиты"),
("Чтение файлов", "поиск по стеку слоёв OverlayFS",
"много мелких файлов", "bind mount", "независимость от каталогов хоста"),
("Запись в файлы образа", "copy-up копирует файл целиком",
"запись в большие файлы", "том", "эфемерность"),
("Сеть", "veth, bridge, NAT на каждый пакет",
"высокий pps", "--network host", "сетевую изоляцию целиком"),
]
def unit_for(task: dict[str, object]) -> str:
"""Unit-файл с ограничениями, равными указанным в задаче."""
name = str(task["имя"])
mem = task.get("память", "256M")
cpu = task.get("cpu", "50%")
return f"""[Unit]
Description={task['описание']}
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart={task['команда']}
DynamicUser=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
NoNewPrivileges=yes
RestrictSUIDSGID=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StateDirectory={name}
MemoryMax={mem}
MemoryHigh={task.get('память_мягкая', mem)}
CPUQuota={cpu}
TasksMax=64
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
"""
def analyze(task: dict[str, object]) -> dict[str, object]:
"""Признак, разводящий варианты: нужна ли упаковка зависимостей."""
needs_packaging = bool(task.get("нужна_упаковка"))
many_machines = int(task.get("машин", 1)) > 1
io_bound = bool(task.get("интенсивный_ввод_вывод"))
hardware = bool(task.get("оборудование"))
reasons_for: list[str] = []
reasons_against: list[str] = []
if needs_packaging:
reasons_for.append("зависимости не ставятся из репозитория дистрибутива")
else:
reasons_against.append("зависимости есть в дистрибутиве: упаковка не нужна")
if many_machines:
reasons_for.append(f"машин: {task['машин']} — одинаковость окружения важна")
else:
reasons_against.append("одна машина: одинаковость нечему обеспечивать")
if int(task.get("сервисов", 1)) > 3:
reasons_for.append(f"сервисов: {task['сервисов']} — единообразие доставки")
if io_bound:
reasons_against.append("интенсивный ввод-вывод: расходы на OverlayFS и сети")
if hardware:
reasons_against.append("работа с оборудованием: образ привяжется к хосту")
if not task.get("есть_кому_поддерживать", True):
reasons_against.append("некому поддерживать инфраструктуру сборки")
verdict = ("Docker оправдан" if len(reasons_for) > len(reasons_against)
else "systemd-сервис проще")
return {
"задача": task["описание"],
"за": reasons_for,
"против": reasons_against,
"вердикт": verdict,
"признак": ("упаковка нужна" if needs_packaging else "упаковка не нужна"),
}
def main(path: str) -> int:
tasks = json.loads(Path(path).read_text())
out: list[dict[str, object]] = []
for task in tasks:
unit_path = Path(f"{task['имя']}.service")
unit_path.write_text(unit_for(task), encoding="utf-8")
r = analyze(task)
r["unit"] = unit_path.name
out.append(r)
print(json.dumps({
"уровней_до": len(PLAIN_LEVELS),
"уровней_после": len(CONTAINER_LEVELS),
"причин_всего": len(REFUSED_CAUSES),
"причин_добавлено": sum(1 for _, added in REFUSED_CAUSES if added),
"свойств": len(PROPS),
"совпадает": sum(1 for _, d, _, _ in PROPS if d != "НЕТ"),
"только_docker": len(ONLY_DOCKER),
"мест_расходов": len(OVERHEAD),
"без_расходов": sum(1 for _, _, w, _, _ in OVERHEAD if w == "нигде"),
"задачи": out,
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tasks.json"))
PY
cat > tasks.json <<'EOF'
[
{
"имя": "logrotate-helper",
"описание": "Скрипт ротации логов на одной машине",
"команда": "/usr/bin/python3 /opt/rotate/main.py",
"нужна_упаковка": false,
"машин": 1,
"сервисов": 1,
"интенсивный_ввод_вывод": true,
"оборудование": false,
"есть_кому_поддерживать": false,
"память": "64M",
"cpu": "20%"
},
{
"имя": "video-encoder",
"описание": "Кодировщик видео с аппаратным ускорением",
"команда": "/opt/encoder/bin/encoder --device /dev/dri/renderD128",
"нужна_упаковка": true,
"машин": 1,
"сервисов": 1,
"интенсивный_ввод_вывод": true,
"оборудование": true,
"есть_кому_поддерживать": true,
"память": "2G",
"cpu": "400%"
},
{
"имя": "api-service",
"описание": "API на Python с зависимостями из PyPI, шесть сервисов, четыре машины",
"команда": "/opt/api/venv/bin/python -m api.main",
"нужна_упаковка": true,
"машин": 4,
"сервисов": 6,
"интенсивный_ввод_вывод": false,
"оборудование": false,
"есть_кому_поддерживать": true,
"память": "512M",
"память_мягкая": "400M",
"cpu": "100%"
}
]
EOF
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
python3 cost.py tasks.json > result.json 2>&1
get() { python3 -c "import json;print(json.load(open('result.json'))['$1'])"; }
printf '\n═══ Требование 1: удлинение пути отладки ═══\n'
python3 - <<'PY'
from cost import PLAIN_LEVELS, CONTAINER_LEVELS, REFUSED_CAUSES
print(f" {'без container':<28} {'с container'}")
print(" " + "─" * 60)
for i in range(max(len(PLAIN_LEVELS), len(CONTAINER_LEVELS))):
a = PLAIN_LEVELS[i] if i < len(PLAIN_LEVELS) else ""
b = CONTAINER_LEVELS[i] if i < len(CONTAINER_LEVELS) else ""
print(f" {a:<28} {b}")
print()
print(f" уровней: {len(PLAIN_LEVELS)} → {len(CONTAINER_LEVELS)} "
f"(+{len(CONTAINER_LEVELS) - len(PLAIN_LEVELS)})")
print()
print(" Одно сообщение «Connection refused»:")
print(f" {'причина':<40} происхождение")
print(" " + "─" * 72)
for cause, added in REFUSED_CAUSES:
origin = "ДОБАВЛЕНО контейнеризацией" if added else "есть и без container'а"
print(f" {cause:<40} {origin}")
added = sum(1 for _, a in REFUSED_CAUSES if a)
print()
print(f" причин: {len(REFUSED_CAUSES)}, добавлено: {added} "
f"({added / len(REFUSED_CAUSES) * 100:.0f} %)")
print()
print(" Цена абстракции измерима: то же сообщение означает вдвое больше вещей.")
PY
before="$(get уровней_до)"; after="$(get уровней_после)"
added="$(get причин_добавлено)"; total="$(get причин_всего)"
printf '\n уровней: %s → %s; причин добавлено: %s из %s\n' \
"$before" "$after" "$added" "$total"
[ "$after" -gt "$before" ] && [ "$added" -gt 0 ] \
&& ok "удлинение показано числами, а не рассуждением" \
|| bad "уровней $before→$after, добавлено $added"
printf '\n═══ Требование 2: сопоставление с systemd ═══\n'
python3 - <<'PY'
from cost import PROPS, ONLY_DOCKER
print(f" {'свойство':<32} {'Docker':<20} {'systemd':<24} механизм ядра")
print(" " + "─" * 96)
for prop, docker, systemd, mech in PROPS:
print(f" {prop:<32} {docker:<20} {systemd:<24} {mech}")
same = sum(1 for _, d, _, _ in PROPS if d != "НЕТ")
shared = sum(1 for _, _, _, m in PROPS if m != "—")
print()
print(f" совпадает по смыслу: {same} из {len(PROPS)}")
print(f" на ОБЩЕМ механизме ядра: {shared}")
print(f" где systemd даёт больше: {len(PROPS) - same} (MemoryHigh)")
print()
print(" Только у Docker:")
for item in ONLY_DOCKER:
print(f" · {item}")
print()
print(" Граница проходит по УПАКОВКЕ, а не по изоляции.")
PY
props="$(get свойств)"; same="$(get совпадает)"; only="$(get только_docker)"
printf '\n свойств: %s, совпадает: %s, только у Docker: %s\n' "$props" "$same" "$only"
[ "$same" -ge 10 ] && [ "$only" -eq 3 ] \
&& ok "большинство свойств совпадает; различие сведено к упаковке" \
|| bad "совпадает $same, только Docker $only"
printf '\n═══ Требование 3: unit-файлы и настоящая проверка ═══\n'
systemctl --version | head -1 | sed 's/^/ /'
printf ' контроллеры cgroup v2: %s\n' "$(cat /sys/fs/cgroup/cgroup.controllers)"
echo
verify_unit() {
# Код возврата systemd-analyze verify равен нулю и на испорченном файле,
# поэтому признаком служит наличие строк с именем проверяемого файла.
systemd-analyze verify "./$1" 2>&1 | grep -F "$1" || true
}
units_total=0; syntax_bad=0; missing_exec=0
for u in *.service; do
units_total=$((units_total + 1))
out="$(verify_unit "$u")"
if [ -z "$out" ]; then
printf ' %-26s замечаний нет\n' "$u"
elif printf '%s' "$out" | grep -q "is not executable"; then
# Отдельный класс: путь из ExecStart не существует НА ЭТОЙ машине.
# Это не ошибка unit-файла, а проверка, которой у Docker нет вовсе.
missing_exec=$((missing_exec + 1))
printf ' %-26s нет исполняемого файла (директивы приняты)\n' "$u"
else
syntax_bad=$((syntax_bad + 1))
printf ' %-26s %s\n' "$u" "$out"
fi
done
printf '\n unit-файлов: %s; ошибок синтаксиса: %s; путь ExecStart отсутствует: %s\n' \
"$units_total" "$syntax_bad" "$missing_exec"
echo
echo " Находка: systemd-analyze проверяет СУЩЕСТВОВАНИЕ исполняемого файла,"
echo " а не только синтаксис. У Docker аналога нет: опечатка в command:"
echo " внутри compose.yaml выявляется при запуске, а не при разборе конфигурации."
[ "$units_total" -eq 3 ] && [ "$syntax_bad" -eq 0 ] \
&& ok "все директивы приняты systemd; ошибок синтаксиса нет" \
|| bad "ошибок синтаксиса: $syntax_bad из $units_total"
printf '\n═══ Требование 4: проверка проверяющего ═══\n'
cat > broken.service <<'EOF'
[Service]
ExecStart=/bin/true
ProtectSystem=nonsense
EOF
broken_out="$(verify_unit broken.service)"
if [ -n "$broken_out" ]; then
printf ' испорченный unit: %s\n' "$broken_out"
ok "проверяющий находит заведомую ошибку — значит, «замечаний нет» что-то значит"
else
bad "испорченный unit прошёл проверку: инструмент бесполезен"
fi
printf ' код возврата на испорченном файле: '
systemd-analyze verify ./broken.service >/dev/null 2>&1; printf '%s ' "$?"
printf '(поэтому признаком служит вывод, а не код)\n'
rm -f broken.service
printf '\n═══ Требование 5: расходы производительности ═══\n'
python3 - <<'PY'
from cost import OVERHEAD
print(f" {'место':<26} {'механизм':<38} где заметно")
print(" " + "─" * 96)
for place, mech, where, _, _ in OVERHEAD:
print(f" {place:<26} {mech:<38} {where}")
free = sum(1 for _, _, w, _, _ in OVERHEAD if w == "нигде")
print()
print(f" мест без расходов: {free} из {len(OVERHEAD)}")
print()
print(f" {'способ снять':<26} {'что снимает':<26} что теряется")
print(" " + "─" * 92)
seen = set()
for place, _, _, fix, loses in OVERHEAD:
if not fix or fix in seen:
continue
seen.add(fix)
print(f" {fix:<26} {place:<26} {loses}")
print()
print(" Закономерность: каждый способ ускорить container отключает")
print(" то, ради чего container взят. Если нужны все четыре —")
print(" вопрос стоит поставить иначе.")
PY
places="$(get мест_расходов)"; free="$(get без_расходов)"
printf '\n мест: %s, из них без расходов: %s\n' "$places" "$free"
[ "$free" -ge 2 ] \
&& ok "названы и места расходов, и места, где их нет" \
|| bad "мест без расходов: $free"
printf '\n═══ Требование 6: три задачи ═══\n'
python3 - <<'PY'
import json
data = json.load(open("result.json"))
for t in data["задачи"]:
print(f" ── {t['задача']}")
print(f" признак: {t['признак']}")
for r in t["за"]:
print(f" + {r}")
for r in t["против"]:
print(f" − {r}")
print(f" ВЫВОД: {t['вердикт']} (unit: {t['unit']})")
print()
verdicts = {t["вердикт"] for t in data["задачи"]}
print(f" различных выводов: {len(verdicts)}")
print()
print(" Вторая задача показательна: упаковка нужна, но образ с")
print(" аппаратным ускорением привязывается к версии драйвера на хосте.")
print(" Обещание «работает везде одинаково» здесь не выполняется,")
print(" и главный довод за Docker исчезает.")
PY
n_verdicts="$(python3 -c "
import json
d = json.load(open('result.json'))
print(len({t['вердикт'] for t in d['задачи']}))")"
[ "$n_verdicts" -ge 2 ] \
&& ok "задачи дали разные выводы, каждый с обоснованием" \
|| bad "различных выводов: $n_verdicts"
printf '\n═══ Требование 7: что НЕ проверялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
("любые измерения расходов", "Docker на машине курса не установлен"),
("фактический запуск unit-файлов", "нет сессионной шины и прав на установку"),
("действие ProtectSystem и seccomp", "требует запуска сервиса"),
("поведение при passthrough устройства", "требует оборудования и драйвера"),
]
print(f" {'проверка':<40} причина")
print(" " + "─" * 84)
for name, why in NOT_RUN:
print(f" {name:<40} {why}")
print(f"\n не выполнялось: {len(NOT_RUN)}")
print()
print(" ВЫПОЛНЕНО по-настоящему: версия systemd, состав контроллеров")
print(" cgroup v2, синтаксическая проверка трёх unit-файлов и проверка")
print(" самого проверяющего на заведомо испорченном файле.")
print()
print(" Числовые значения расходов не приводятся намеренно: они зависят")
print(" от ядра, драйвера хранилища и нагрузки. Механизмы от стенда не зависят.")
PY
ok "разделено выполненное и невыполненное; числа не выдуманы"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
echo " примечание: Docker не использовался; проверено то, что проверяется без него"
cd /tmp && rm -rf /tmp/limits
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: удлинение пути отладки ═══
без container с container
────────────────────────────────────────────────────────────
лог приложения лог приложения
процесс процесс в PID namespace
система cgroup
OverlayFS
veth и bridge
NAT
сеть хоста
уровней: 3 → 7 (+4)
Одно сообщение «Connection refused»:
причина происхождение
────────────────────────────────────────────────────────────────────────
процесс не запустился есть и без container'а
слушает 127.0.0.1 вместо 0.0.0.0 есть и без container'а
сервис ещё не готов есть и без container'а
container'ы в разных сетях ДОБАВЛЕНО контейнеризацией
опечатка в имени сервиса ДОБАВЛЕНО контейнеризацией
опубликован не тот порт ДОБАВЛЕНО контейнеризацией
причин: 6, добавлено: 3 (50 %)
уровней: 3 → 7; причин добавлено: 3 из 6
✓ удлинение показано числами, а не рассуждением
═══ Требование 2: сопоставление с systemd ═══
свойство Docker systemd механизм ядра
────────────────────────────────────────────────────────────────────────────────────────────────
Ограничение памяти mem_limit MemoryMax= cgroups v2
Торможение до OOM НЕТ MemoryHigh= cgroups v2
Ограничение CPU cpus CPUQuota= cgroups v2
...
Ограничение системных вызовов seccomp SystemCallFilter= seccomp
совпадает по смыслу: 13 из 14
на ОБЩЕМ механизме ядра: 10
где systemd даёт больше: 1 (MemoryHigh)
Только у Docker:
· Упаковка зависимостей вместе с приложением
· Одинаковость окружения на разных машинах
· Единообразие доставки для всех приложений
Граница проходит по УПАКОВКЕ, а не по изоляции.
свойств: 14, совпадает: 13, только у Docker: 3
✓ большинство свойств совпадает; различие сведено к упаковке
Десять свойств из четырнадцати опираются на буквально один и тот же
механизм ядра — cgroups v2, mount namespace, seccomp, capabilities.
Это не сходство подходов, а общая реализация.
═══ Требование 3: unit-файлы и настоящая проверка ═══
systemd 255 (255.4-1ubuntu8.11)
контроллеры cgroup v2: cpuset cpu io memory hugetlb pids rdma misc
api-service.service нет исполняемого файла (директивы приняты)
logrotate-helper.service замечаний нет
video-encoder.service нет исполняемого файла (директивы приняты)
unit-файлов: 3; ошибок синтаксиса: 0; путь ExecStart отсутствует: 2
Находка: systemd-analyze проверяет СУЩЕСТВОВАНИЕ исполняемого файла,
а не только синтаксис. У Docker аналога нет: опечатка в command:
внутри compose.yaml выявляется при запуске, а не при разборе конфигурации.
✓ все директивы приняты systemd; ошибок синтаксиса нет
═══ Требование 4: проверка проверяющего ═══
испорченный unit: /tmp/limits/broken.service:3: Failed to parse protect system value, ignoring: nonsense
✓ проверяющий находит заведомую ошибку — значит, «замечаний нет» что-то значит
код возврата на испорченном файле: 0 (поэтому признаком служит вывод, а не код)
═══ Требование 5: расходы производительности ═══
место механизм где заметно
────────────────────────────────────────────────────────────────────────────────────────────────
Вычисления обычный процесс ядра нигде
Память те же страницы нигде
Системные вызовы фильтр seccomp на каждый вызов интенсивные syscall
Чтение файлов поиск по стеку слоёв OverlayFS много мелких файлов
Запись в файлы образа copy-up копирует файл целиком запись в большие файлы
Сеть veth, bridge, NAT на каждый пакет высокий pps
мест без расходов: 2 из 6
способ снять что снимает что теряется
────────────────────────────────────────────────────────────────────────────────────────────
seccomp=unconfined Системные вызовы существенную часть защиты
bind mount Чтение файлов независимость от каталогов хоста
том Запись в файлы образа эфемерность
--network host Сеть сетевую изоляцию целиком
Закономерность: каждый способ ускорить container отключает
то, ради чего container взят.
мест: 6, из них без расходов: 2
✓ названы и места расходов, и места, где их нет
═══ Требование 6: три задачи ═══
── Скрипт ротации логов на одной машине
признак: упаковка не нужна
− зависимости есть в дистрибутиве: упаковка не нужна
− одна машина: одинаковость нечему обеспечивать
− интенсивный ввод-вывод: расходы на OverlayFS и сети
− некому поддерживать инфраструктуру сборки
ВЫВОД: systemd-сервис проще (unit: logrotate-helper.service)
── Кодировщик видео с аппаратным ускорением
признак: упаковка нужна
+ зависимости не ставятся из репозитория дистрибутива
− одна машина: одинаковость нечему обеспечивать
− интенсивный ввод-вывод: расходы на OverlayFS и сети
− работа с оборудованием: образ привяжется к хосту
ВЫВОД: systemd-сервис проще (unit: video-encoder.service)
── API на Python с зависимостями из PyPI, шесть сервисов, четыре машины
признак: упаковка нужна
+ зависимости не ставятся из репозитория дистрибутива
+ машин: 4 — одинаковость окружения важна
+ сервисов: 6 — единообразие доставки
ВЫВОД: Docker оправдан (unit: api-service.service)
различных выводов: 2
Вторая задача показательна: упаковка нужна, но образ с
аппаратным ускорением привязывается к версии драйвера на хосте.
✓ задачи дали разные выводы, каждый с обоснованием
═══ Требование 7: что НЕ проверялось ═══
проверка причина
────────────────────────────────────────────────────────────────────────────────────
любые измерения расходов Docker на машине курса не установлен
фактический запуск unit-файлов нет сессионной шины и прав на установку
действие ProtectSystem и seccomp требует запуска сервиса
поведение при passthrough устройства требует оборудования и драйвера
не выполнялось: 4
ВЫПОЛНЕНО по-настоящему: версия systemd, состав контроллеров
cgroup v2, синтаксическая проверка трёх unit-файлов и проверка
самого проверяющего на заведомо испорченном файле.
✓ разделено выполненное и невыполненное; числа не выдуманы
═══ ИТОГ ═══
все требования выполнены
примечание: Docker не использовался; проверено то, что проверяется без него
Все требования выполнены. Часть проверок — настоящие: версия systemd, состав контроллеров cgroup v2 и синтаксическая проверка трёх unit-файлов выполнены на машине курса.
Три решения, определяющие качество.
Проверяющий проверен на заведомо испорченном файле. systemd-analyze verify возвращает код 0 и на корректном, и на сломанном unit'е — то есть код возврата непригоден как признак. Обнаружить это можно было только подав ошибку намеренно. Без требования 4 «замечаний нет» ничего бы не значило, и это тот же приём, что в уроке 16.4: «не проверено» должно отличаться от «проверено и чисто».
Замечания разделены на два класса. Первый прогон дал неожиданный результат: два unit'а из трёх получили замечание Command ... is not executable. Соблазн был подставить в задачи существующие пути и получить три чистых строки. Это скрыло бы находку: systemd-analyze verify проверяет существование исполняемого файла, а не только синтаксис. У Docker аналога нет — опечатка в command: внутри compose.yaml разбор конфигурации проходит и выявляется только при запуске. Инструмент теперь различает «ошибка в unit'е» и «путь отсутствует на этой машине», и вторая строка сообщает читателю больше, чем сообщила бы чистая проверка.
Признак, разводящий варианты, — упаковка, а не изоляция. Тринадцать свойств из четырнадцати совпадают, десять — на буквально одном механизме ядра. Если бы инструмент сравнивал по изоляции, он не различал бы задачи вовсе. Различие сведено к одному вопросу: ставятся ли зависимости из репозитория дистрибутива.
Числовые значения расходов не приводятся. Соблазн был назвать проценты — «сеть медленнее на N %». Но такие числа зависят от ядра, драйвера хранилища, нагрузки и оборудования, а измерить их здесь нечем. Названы механизмы: они от стенда не зависят и позволяют читателю измерить самому.
Чего решение не делает. Docker не использовался ни разу: расходы не измерены, поведение --network host и copy-up не воспроизведено. Unit-файлы проверены синтаксически, но не запущены — действие ProtectSystem=strict и SystemCallFilter= не подтверждено. Разбор второй задачи опирается на утверждение о привязке к версии драйвера; оно взято из документации, а не проверено на оборудовании. Наконец, вердикт инструмента получается сравнением числа доводов — это грубо: доводы разного веса, и на пограничных случаях итог зависит от формулировок. Инструмент годится, чтобы разложить решение на статьи, а не чтобы вынести его за вас.
Проверка результата
systemctl --version | head -1
cat /sys/fs/cgroup/cgroup.controllers
systemd-analyze verify ./ваш.service 2>&1 | grep -F "ваш.service" || echo "замечаний нет"
Проверьте свой проект вопросом: что именно перестанет работать, если убрать Docker? Если ответ — «ничего, кроме удобства сборки», это повод посчитать цену.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| «Container медленнее» вообще | Аналогия с виртуальной машиной | Вычисления идут напрямую; расходы — в вводе-выводе и сети |
Ускорение через --network host без учёта потерь | Помогает и просто | Снимает сетевую изоляцию целиком |
| База данных в container по умолчанию | Так делают в примерах | Копирование, восстановление, миграции — вне Docker |
| Ожидание, что GPU-образ переносим | Обещание «работает везде» | Привязка к версии драйвера на хосте |
| Один скрипт на одной машине в container | Единообразие | systemd-unit даёт те же ограничения без инфраструктуры |
| Считать изоляцию свойством только Docker | Так подаётся | systemd использует те же cgroups, namespaces, seccomp |
| Не учитывать постоянную стоимость сборки | Видна только первая установка | Базовые образы, кэш, сканирование, очистка — навсегда |
| Отладка теми же приёмами, что без container'а | Привычка | Требуется observability: логи, healthcheck, знание сети |
Считать docker logs достаточным | Обычно хватает | Cgroup может убить процесс без записи в лог приложения |
Контрольные вопросы
На понимание:
- Почему вычисления в container'е идут без расходов, а ввод-вывод — с расходами?
- Что общего у механизмов ограничения ресурсов в Docker и
systemd? - Почему persistent state противоречит модели container'а?
- Почему образ с GPU перестаёт быть переносимым?
- Что именно
systemdне может заменить?
На применение:
- Как оценить, сколько уровней добавилось к отладке?
- Какой признак разводит «нужен Docker» и «хватит
systemd»? - Что проверить, прежде чем поместить базу данных в container?
На диагностику:
- Приложение в container'е медленнее в три раза. Какие четыре места проверить?
- Процесс исчез, в
docker logsпусто. Где искать?
Краткое резюме
- Каждый пункт раздела — статья расходов, а не довод против Docker.
- Путь от симптома к причине удлиняется с трёх уровней до семи.
- Одно сообщение
Connection refusedначинает означать вдвое больше вещей. - Контейнеризация повышает планку к observability, а не снижает её.
- Данные не помещаются в модель container'а; всё, что с ними связано, остаётся вне Docker.
- База данных в container — отдельное решение, а не умолчание.
- Расходов на вычислениях и памяти практически нет: это обычный процесс ядра.
- Расходы концентрируются в системных вызовах, OverlayFS, сети и публикации портов.
- Каждый способ ускорить container отключает то, ради чего он взят.
- Образ с оборудованием привязывается к версии драйвера на хосте и теряет переносимость.
- Инфраструктура сборки — постоянная работа, а не разовая настройка.
systemdдаёт изоляцию и управление сервисом теми же механизмами ядра, но не даёт упаковки.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: драйверы хранилища | https://docs.docker.com/storage/storagedriver/ | Copy-up и расходы на запись |
| Docker: сеть | https://docs.docker.com/network/ | Bridge, NAT, --network host |
| Docker: seccomp | https://docs.docker.com/engine/security/seccomp/ | Профиль по умолчанию |
| Docker: GPU | https://docs.docker.com/desktop/features/gpu/ | Проход устройства, требования к драйверу |
| systemd.exec | https://www.freedesktop.org/software/systemd/man/systemd.exec.html | ProtectSystem, PrivateTmp, SystemCallFilter |
| systemd.resource-control | https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html | MemoryMax, MemoryHigh, CPUQuota |
| Kernel: cgroup v2 | https://docs.kernel.org/admin-guide/cgroup-v2.html | Разница memory.high и memory.max |
| Kernel: OverlayFS | https://docs.kernel.org/filesystems/overlayfs.html | Стек слоёв и copy-up |
Навигация
← Вернуться к разделу
Следующий материал → Границы изоляции
Главное оглавление