Главная/Ограничения Docker/Урок

19.1. Где containers усложняют систему

Цели

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

  • назвать цену дополнительного слоя абстракции в конкретных статьях;
  • показать, во что превращается путь от симптома к причине при отладке;
  • объяснить, почему persistent state противоречит модели container'а;
  • назвать, где производительность страдает, а где расходов практически нет;
  • оценить постоянную стоимость инфраструктуры сборки;
  • объяснить, какую часть свойств Docker даёт systemd теми же механизмами ядра.

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

Docker на машине курса не установлен. Все команды, требующие daemon, приводятся для справки и помечены как не выполнявшиеся. Проверки, выполнимые без него, выполняются по-настоящему.

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

ТерминОбъяснение
слой абстракцииУровень, скрывающий детали и добавляющий собственные
эфемерностьСвойство container'а исчезать вместе с записанными в него данными
passthroughПроброс устройства хоста внутрь container'а без виртуализации
связывание с хостомЗависимость образа от версии драйвера или ядра хоста
unitОписание сервиса для systemd

Теория

Цена, о которой не говорят

Восемнадцать разделов курса объясняли, что Docker даёт. Этот объясняет, что он берёт.

Ни один пункт ниже не является доводом против Docker. Каждый — статья расходов, которую нужно знать, чтобы посчитать итог. Инженер отличается от сторонника технологии тем, что может назвать цену.

СтатьяГде проявляется
Удлинение пути отладкиКаждый инцидент
Противоречие с persistent stateЛюбые данные
Расходы на вводе-выводе и сетиНагруженные системы
Связывание с хостом при работе с оборудованиемGPU, устройства
Инфраструктура сборкиПостоянно
Обучение и наёмПостоянно

Отладка: путь от симптома к причине

Без container'а путь короткий:

text
симптом → лог приложения → процесс → система

С container'ом между кодом и системой появляются уровни:

text
симптом → лог приложения → процесс в 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 hostBridge и NATСетевую изоляцию целиком
Bind mount вместо слояCopy-upНезависимость от каталогов хоста
--security-opt seccomp=unconfinedФильтр вызововСущественную часть защиты
--pid hostИзоляцию PIDВидимость всех процессов хоста

Закономерность видна: все способы ускорить container отключают то, ради чего он взят. Если для достижения нужной производительности требуется отключить всё перечисленное, вопрос стоит поставить иначе — нужен ли container вообще.

Оборудование: где ломается переносимость

GPU и специализированные устройства подключаются проходом (passthrough): Docker не виртуализирует устройство, а даёт к нему доступ.

Следствие важнее самого факта:

text
образ с CUDA  ←→  версия драйвера на хосте

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

То же с --device: устройство пробрасывается, но модуль ядра остаётся на хосте.

Вывод формулируется точно: container с оборудованием перестаёт быть переносимым. Основное обещание — «работает везде одинаково» — здесь не выполняется, и это ограничение принципиальное, а не временное.

Стоимость инфраструктуры сборки

Одна машина с одним приложением требует: установить зависимости, запустить сервис. С Docker к этому добавляется постоянная работа:

ЧтоПериодичность
Хранилище образов и его доступностьПостоянно
Обновление базовых образов при выходе исправленийЕжемесячно и чаще
Пересборка при уязвимостях в зависимостяхПо событию
Кэш сборки: настройка и поддержаниеПостоянно
Сканирование и его результатыКаждая сборка (урок 16.4)
Очистка старых образов и слоёвПостоянно
Обучение и наём людей, знающих этоПостоянно

Для системы из десяти сервисов это оправдано. Для одного сервиса на одной машине — часто нет.

Когда systemd-сервис проще

Самое неудобное для сторонника технологии наблюдение: значительная часть того, ради чего берут Docker, доступна из systemd на тех же механизмах ядра.

СвойствоDockersystemd
Автозапуск и перезапускrestart:Restart=
Ограничение памятиmem_limitMemoryMax=
Ограничение CPUcpusCPUQuota=
Запуск не от rootUSERUser= или DynamicUser=yes
Только для чтения кореньread_onlyProtectSystem=strict
Свой /tmptmpfsPrivateTmp=yes
Запрет повышения привилегийno-new-privilegesNoNewPrivileges=yes
Скрытие устройств--device по спискуPrivateDevices=yes
Своя сетьсеть DockerPrivateNetwork=yes
Ограничение вызововseccompSystemCallFilter=
Зависимости при стартеdepends_onAfter=, 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-dropCapabilityBoundingSet=

Каждый unit systemd уже находится в собственной cgroup — это видно в systemd-cgls. То есть ограничения ресурсов работают там не «похоже», а буквально тем же механизмом.

Различие в другом: Docker упаковывает файловую систему приложения в образ, а systemd работает с файловой системой хоста. Отсюда и граница применимости.

Почему расходов на вычислениях нет, а на вводе-выводе есть

Container не перехватывает вычисления: код исполняется процессором напрямую, планировщик ядра тот же.

Ввод-вывод проходит через добавленные уровни:

  • чтение файла — поиск по стеку слоёв OverlayFS вместо одного каталога;
  • первая запись — копирование файла целиком в верхний слой (урок 17.5);
  • сетевой пакет — veth, bridge, правила NAT, и то же на обратном пути;
  • системный вызов — проверка фильтром seccomp.

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


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

Учёт того, что добавилось

bash
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

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

text
═══ что добавила абстракция ═══
  ── Путь от симптома к причине ──

    без 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 — нет.

bash
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 нет"

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

text
═══ версия и иерархия 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 выявляется только при запуске, а не при разборе конфигурации. Это редкий случай, где менее «продвинутый» инструмент проверяет больше.

Заменив путь на существующий, легко убедиться, что остальные директивы возражений не вызывают:

bash
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 нет"
text
  замечаний по webapp.service нет

Все двадцать директив — DynamicUser, ProtectSystem=strict, SystemCallFilter, MemoryMax, MemoryHigh, CPUQuota и остальные — приняты systemd 255 без единого возражения.

Отдельного внимания заслуживает строка MemoryHigh=200M. Это торможение при приближении к пределу, до срабатывания OOM (урок 17.4). У Docker прямого аналога нет: он задаёт только memory.max. Здесь systemd даёт больше, а не меньше.

Сопоставление свойств

bash
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

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

text
═══ сопоставление ═══
  свойство                         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 заменяет изоляцию и управление сервисом,
  но не заменяет упаковку зависимостей.

Где расходы есть, а где их нет

bash
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

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

text
═══ расходы производительности ═══
  место                      механизм                                     где заметно
  ────────────────────────────────────────────────────────────────────────────────────────────────────────
  Вычисления                 обычный процесс ядра, планировщик тот же     нигде
  Память                     те же страницы, тот же аллокатор             нигде
  Системные вызовы           фильтр 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 на машине курса не установлен

    Числовые значения расходов в этом уроке НЕ приводятся намеренно:
    они зависят от ядра, драйвера хранилища, нагрузки и оборудования.
    Приведены механизмы — они не зависят от стенда.

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

Задание. Постройте инструмент учёта цены контейнеризации и примените его к трём задачам.

Требования:

  1. Показать удлинение пути отладки числом уровней и числом причин одного сообщения.
  2. Сопоставить свойства Docker и systemd, указав общий механизм ядра.
  3. Сгенерировать unit-файл, дающий те же ограничения, и проверить его синтаксис по-настоящему.
  4. Проверить сам проверяющий: подать заведомо испорченный unit и убедиться, что замечание найдено.
  5. Перечислить места расходов производительности и способы их снять с указанием потерь.
  6. Применить к трём задачам с разными исходами и обосновать каждый.
  7. Отметить всё, что не проверялось, и назвать причину.

Подсказки

Подсказка 1

systemd-analyze verify ФАЙЛ проверяет unit без его установки. В песочнице команда дополнительно жалуется на посторонние units — фильтруйте вывод по имени своего файла.

Подсказка 2

Код возврата systemd-analyze verify не годится как признак: он равен нулю и на испорченном файле. Признак — наличие строк с именем файла в выводе.

Подсказка 3

Признак, по которому решение расходится: нужна ли упаковка. Изоляция и управление сервисом есть у обоих.

Решение

Показать решение
bash
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"

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

text
═══ Требование 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= не подтверждено. Разбор второй задачи опирается на утверждение о привязке к версии драйвера; оно взято из документации, а не проверено на оборудовании. Наконец, вердикт инструмента получается сравнением числа доводов — это грубо: доводы разного веса, и на пограничных случаях итог зависит от формулировок. Инструмент годится, чтобы разложить решение на статьи, а не чтобы вынести его за вас.

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

bash
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 может убить процесс без записи в лог приложения

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

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

  1. Почему вычисления в container'е идут без расходов, а ввод-вывод — с расходами?
  2. Что общего у механизмов ограничения ресурсов в Docker и systemd?
  3. Почему persistent state противоречит модели container'а?
  4. Почему образ с GPU перестаёт быть переносимым?
  5. Что именно systemd не может заменить?

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

  1. Как оценить, сколько уровней добавилось к отладке?
  2. Какой признак разводит «нужен Docker» и «хватит systemd»?
  3. Что проверить, прежде чем поместить базу данных в container?

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

  1. Приложение в container'е медленнее в три раза. Какие четыре места проверить?
  2. Процесс исчез, в docker logs пусто. Где искать?

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

  1. Каждый пункт раздела — статья расходов, а не довод против Docker.
  2. Путь от симптома к причине удлиняется с трёх уровней до семи.
  3. Одно сообщение Connection refused начинает означать вдвое больше вещей.
  4. Контейнеризация повышает планку к observability, а не снижает её.
  5. Данные не помещаются в модель container'а; всё, что с ними связано, остаётся вне Docker.
  6. База данных в container — отдельное решение, а не умолчание.
  7. Расходов на вычислениях и памяти практически нет: это обычный процесс ядра.
  8. Расходы концентрируются в системных вызовах, OverlayFS, сети и публикации портов.
  9. Каждый способ ускорить container отключает то, ради чего он взят.
  10. Образ с оборудованием привязывается к версии драйвера на хосте и теряет переносимость.
  11. Инфраструктура сборки — постоянная работа, а не разовая настройка.
  12. systemd даёт изоляцию и управление сервисом теми же механизмами ядра, но не даёт упаковки.

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

ИсточникСсылкаЧто подтверждает
Docker: драйверы хранилищаhttps://docs.docker.com/storage/storagedriver/Copy-up и расходы на запись
Docker: сетьhttps://docs.docker.com/network/Bridge, NAT, --network host
Docker: seccomphttps://docs.docker.com/engine/security/seccomp/Профиль по умолчанию
Docker: GPUhttps://docs.docker.com/desktop/features/gpu/Проход устройства, требования к драйверу
systemd.exechttps://www.freedesktop.org/software/systemd/man/systemd.exec.htmlProtectSystem, PrivateTmp, SystemCallFilter
systemd.resource-controlhttps://www.freedesktop.org/software/systemd/man/systemd.resource-control.htmlMemoryMax, MemoryHigh, CPUQuota
Kernel: cgroup v2https://docs.kernel.org/admin-guide/cgroup-v2.htmlРазница memory.high и memory.max
Kernel: OverlayFShttps://docs.kernel.org/filesystems/overlayfs.htmlСтек слоёв и copy-up

Навигация

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

Markdown на GitHub ↗