🐳 Раздел 14 · Вопрос #1

Что такое контейнеризация и зачем она нужна

Запущенный экземпляр образа называется контейнером (Container).


🟢 Junior Level

30-секундный ответ

Контейнеризация — это метод виртуализации на уровне операционной системы, позволяющий упаковать приложение вместе со всеми его зависимостями (библиотеками, системными утилитами, конфигурацией и JVM-рантаймом) в единый изолированный программный блок — образ (Image).

Запущенный экземпляр образа называется контейнером (Container).

  • Контейнер — это НЕ виртуальная машина и НЕ отдельный файл архива.
  • С точки зрения хост-системы контейнер — это обычный процесс Linux, которому ядро хоста (Host Kernel) изолировало видимость процессов, файловой системы и сети с помощью пространств имён (Namespaces) и жёстко ограничило потребление процессора и памяти с помощью контрольных групп (cgroups).

Проблема «На моей машине всё работает!»

Без контейнеризации:

  • Разработчик пишет код на Java 21 под macOS.
  • На тестовом стенде установлен Linux с Java 17 и устаревшими системными библиотеками glibc.
  • На продакшене приложение падает из-за конфликта версий или различий в системных кодировках.

С контейнеризацией:

  • Разработчик создаёт Docker-образ с гарантированной версией OpenJDK 21 и всеми зависимостями.
  • Тот же самый неизменяемый артефакт запускается на ноутбуке, в CI-пайплайне и на кластере Kubernetes с идентичным поведением на 100%.

Наглядная аналогия из жизни

Контейнеризация в IT аналогична стандартизированным морским контейнерам (Intermodal Shipping Containers):

  • До появления стандарта в порт привозили мешки, ящики и бочки разного размера, требующие индивидуальной ручной погрузки.
  • Морской контейнер сделал форму упаковки единой: крану, грузовику и контейнеровозу безразлично, что лежит внутри — автомобили, одежда или электроника.
  • В IT контейнерный движок (Docker/containerd) одинаково запускает Java-бэкенд, Python-скрипт или базу данных Postgres.

Минимальный пример для Java 21 (Dockerfile)

# Сборка легковесного контейнера Java 21
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY target/application.jar app.jar

# Запуск приложения от непривилегированного пользователя:
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Сборка и запуск контейнера:
docker build -t payment-service:1.0 .
docker run -d -p 8080:8080 --memory=1g --cpus=2 payment-service:1.0

🟡 Middle Level

Как устроена изоляция: Фундамент ядра Linux

Контейнеризация опирается на две базовые подсистемы ядра Linux:

1. Namespaces (Пространства имён) — Изоляция видимости

Ограничивают то, что процесс может видеть: | Namespace | Флаг клонирования | Что изолирует | | :— | :— | :— | | PID | CLONE_NEWPID | Дерево процессов (внутри контейнера процесс видит себя как PID 1) | | NET | CLONE_NEWNET | Сетевой стек (свои IP-адреса, порты, таблицы маршрутизации) | | MNT | CLONE_NEWNS | Точки монтирования файловой системы | | UTS | CLONE_NEWUTS | Имя хоста (Hostname) и доменное имя | | IPC | CLONE_NEWIPC | Очереди сообщений, семафоры и разделяемую память | | USER | CLONE_NEWUSER | Сопоставление UID/GID (пользователь root в контейнере может быть непривилегированным на хосте) | | CGROUP | CLONE_NEWCGROUP| Видимость структуры контрольных групп | | TIME | CLONE_NEWTIME | Системные таймеры и смещение часов (Linux 5.6+) |

2. Control Groups (cgroups v1 / cgroups v2) — Ограничение ресурсов

Ограничивают то, сколько ресурсов процесс может потреблять:

  • CPU: квоты времени исполнения процессора (CPU CFS Quota / Period).
  • Memory: лимит оперативной памяти (memory.max). При его превышении ядро Linux мгновенно убивает процесс через OOM Killer (Exit Code 137 / SIGKILL).
  • Disk I/O: ограничение пропускной способности чтения/записи на диск (IOPS).

Слоистая файловая система (OverlayFS / Overlay2)

Docker-образ состоит из набора неизменяемых слоёв только для чтения (Read-Only Layers):

  1. LowerDir: базовые слои образа (ОС, JRE, JAR-файл). Они разделяются между всеми запущенными контейнерами на одном сервере, экономя оперативную память и дисковое пространство.
  2. UpperDir (Container Layer): тонкий слой чтения/записи (Read-Write), уникальный для каждого конкретного экземпляра контейнера.
  3. Copy-on-Write (CoW): при модификации существующего системного файла он сначала копируется из нижнего слоя в UpperDir, и только затем изменяется.
[Контейнер 1: RW Layer]      [Контейнер 2: RW Layer]
           \                        /
            ▼                      ▼
  [Разделяемый слой приложения: app.jar (RO)]
  [Разделяемый слой Java 21 JRE (RO)]
  [Разделяемый базовый слой Ubuntu Jammy (RO)]

🔴 Senior Level

Осведомлённость JVM о контейнерах (JVM Container Awareness)

Исторически JVM создавалась в предположении, что она единолично владеет всем сервером:

  • В версиях Java до 8u191 вызовы Runtime.getRuntime().availableProcessors() и расчёт размера Heap опирались на системные вызовы ядра хоста (/proc/cpuinfo, /proc/meminfo).
  • Катастрофа ранних версий: если контейнеру выделяли 1 ГБ RAM на хосте с 64 ГБ, JVM выделяла под кучу 25% от 64 ГБ = 16 ГБ! При первом всплеске трафика ядро Linux принудительно убивало контейнер сигналом OOMKilled (код 137).
  • Современный статус (Java 11, 17, 21): Флаг -XX:+UseContainerSupport включён по умолчанию. JVM читает ограничения напрямую из псевдофайловой системы cgroups (/sys/fs/cgroup/memory.max и cpu.max), корректно настраивая размер кучи (MaxRAMPercentage, по умолчанию 25% от лимита контейнера) и размер пула ForkJoin / GC-потоков.

Проблема PID 1 и задержки остановки (Zombie Reaping & SIGTERM)

В Linux процесс с PID 1 (init или systemd) имеет специальные обязанности:

  1. Он обязан перехватывать и хоронить зомби-процессы (Zombie Reaping), чьи родительские процессы завершились.
  2. По умолчанию ядро Linux НЕ применяет стандартное действие для сигналов SIGINT и SIGTERM к процессу PID 1. Если процесс явно не зарегистрировал обработчик сигнала, сигнал игнорируется!
  3. Ошибка Shell Form:
    # ❌ ОШИБКА: Запускается /bin/sh -c "java -jar app.jar"
    ENTRYPOINT java -jar app.jar
    

    В этом случае PID 1 становится командный процессор /bin/sh, который не передаёт сигнал SIGTERM дочерней JVM. При остановке контейнера в Kubernetes сервис зависает на 30 секунд (grace period) и безжалостно убивается по SIGKILL без сохранения состояния транзакций.

  4. Решение: использовать исключительно Exec Form:
    # ✅ ПРАВИЛЬНО: JVM становится PID 1 и перехватывает shutdown hooks
    ENTRYPOINT ["java", "-jar", "app.jar"]
    # Или использовать легковесный менеджер процессов: docker run --init (tini)
    

Архитектура безопасности: Защита от Container Escape

Поскольку контейнеры разделяют ядро хоста, уязвимость в ядре (например, CVE-2016-5195 Dirty COW, CVE-2022-0847 Dirty Pipe) может позволить злоумышленнику вырваться на хост-сервер (Container Breakout).

Меры защиты в Production:

  1. Rootless Containers и USER nonroot: запрет работы от UID 0.
  2. Read-Only Root Filesystem: флаг --read-only блокирует запись исполняемых файлов в системные директории.
  3. Drop Capabilities: отказ от опасных привилегий ядра:
    securityContext:
      capabilities:
        drop: ["ALL"]
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
    
  4. Seccomp (Secure Computing Mode) & AppArmor/SELinux: фильтрация разрешённых системных вызовов (блокировка ptrace, reboot, mount).

4 Tricky Questions

1. Как JVM в Java 17/21 определяет доступный объём памяти и количество ядер процессора внутри контейнера? Что такое cgroups v2?

Ответ: JVM при запуске проверяет флаг -XX:+UseContainerSupport (активен по умолчанию). Она инспектирует файловую систему cgroups:

  • Для cgroups v1: считывает /sys/fs/cgroup/memory/memory.limit_in_bytes и /sys/fs/cgroup/cpu/cpu.cfs_quota_us / cpu.cfs_period_us.
  • Для cgroups v2 (единая иерархия): считывает лимиты из файлов /sys/fs/cgroup/memory.max и /sys/fs/cgroup/cpu.max. Количество доступных ядер вычисляется как отношение quota / period. Если результат меньше 1.0, JVM округляет значение до 1 ядра. На основе вычисленной памяти JVM по умолчанию резервирует до 25% (или значение флага -XX:MaxRAMPercentage=75.0) под максимальный размер кучи Heap (-Xmx).

2. Почему команда docker stop ждёт 10 секунд перед принудительным завершением контейнера, если внутри используется Shell Form в ENTRYPOINT?

Ответ: Команда docker stop посылает процессу с PID 1 сигнал SIGTERM, ожидает 10 секунд (тайм-аут по умолчанию), и если процесс не завершился, посылает SIGKILL. При использовании Shell Form (ENTRYPOINT java -jar app.jar) Docker стартует процесс /bin/sh -c "java ..." в качестве PID 1, а процесс JVM становится дочерним процессом PID 2. Стандартный командный интерпретатор /bin/sh не пробрасывает системные сигналы своим дочерним процессам. В результате JVM даже не знает, что пришёл SIGTERM, Spring Boot graceful shutdown не запускается, и через 10 секунд демон Docker принудительно убивает весь контейнер по SIGKILL.


3. В чём фундаментальная разница между побегом из контейнера (Container Escape) и побегом из виртуальной машины (Hypervisor Escape)?

Ответ:

  • Container Escape: контейнеры делят одно физическое ядро операционной системы хоста. Граница изоляции — это логические проверки в системных вызовах ядра (namespaces, capabilities). Эксплуатация любой уязвимости ядра Linux (например, повреждение памяти ядра через баг в драйвере или eBPF) даёт злоумышленнику прямой root-доступ к хостовой ОС за один шаг.
  • Hypervisor Escape: виртуальная машина изолирована на аппаратном уровне (Intel VT-x, AMD-V, кольца защиты CPU Ring -1 / Ring 0). У ВМ своё собственное независимое ядро. Чтобы вырваться на хост, атакующему требуется взломать гипервизор (KVM, ESXi) через эмуляцию устройств виртуального железа (QEMU/virtio), что на порядки сложнее и требует цепочки редчайших уязвимостей нулевого дня.

4. Почему запись больших временных файлов (например, генерация отчётов или кэш PDF) прямо в файловую систему контейнера без использования Volumes приводит к катастрофической деградации дискового I/O?

Ответ: Файловая система контейнера работает через драйвер Overlay2 (UnionFS):

  1. Каждая операция записи в существующий файл требует накладных расходов Copy-on-Write (CoW): блок данных должен быть прочитан из нижнего слоя образа, скопирован на верхний слой контейнера (UpperDir) и только затем перезаписан.
  2. Overlay2 не предназначен для высокоскоростного прямого ввода-вывода. Блокировки файловых дескрипторов и фрагментация слоёв создают высокую задержку (I/O latency).
  3. Слой контейнера сохраняется в директории /var/lib/docker/overlay2 хоста. Запись гигабайтов временных данных может быстро исчерпать дисковое пространство или пул inodes хостового диска. Решение: монтировать быстрые директории в оперативной памяти (tmpfs /tmp) или использовать внешние тома (Volumes), которые работают напрямую с блочным устройством хоста в обход OverlayFS.

🎯 Шпаргалка для интервью

Главное о контейнеризации

  • Определение: процессная виртуализация на базе одного ядра Linux (не виртуальная машина!).
  • Основа изоляции: Namespaces (PID, NET, MNT, USER, IPC, UTS, CGROUP, TIME).
  • Ограничение ресурсов: cgroups (квоты CPU, лимиты RAM, disk I/O).
  • Файловая система: слоистая OverlayFS (Overlay2) с механизмом Copy-on-Write.
  • JVM Support: -XX:+UseContainerSupport (с Java 8u191+ и в Java 17/21 по умолчанию).
  • Безопасность: non-root пользователь (USER 1000), read-only rootfs, drop capabilities, exec-форма в ENTRYPOINT.

Контейнеры vs Виртуальные машины

| Характеристика | Контейнеры (Docker/Podman) | Виртуальные машины (KVM/VMware) | | :— | :— | :— | | Ядро ОС | Одно общее ядро хоста Linux | Каждая машина имеет свою гостевую ОС | | Изоляция | Уровень системных вызовов ядра | Аппаратный уровень гипервизора | | Время старта | Миллисекунды – секунды | Десятки секунд – минуты | | Оверхед на CPU/RAM | $< 1\%$ CPU, мегабайты памяти | 5–15% CPU, гигабайты памяти на каждую ОС | | Безопасность границы| Ниже (общее ядро хоста) | Выше (аппаратная изоляция) |

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Контейнер — это сверхлегкая виртуальная машина, внутри которой загружается урезанная Ubuntu» — в контейнере нет гостевой ОС и нет ядра, это обычный изолированный процесс Linux.
  • ❌ «В Java 17 нужно передавать флаг -XX:+UseContainerSupport» — он включён по умолчанию начиная с Java 10.
  • ❌ «В Dockerfile лучше запускать приложение через ENTRYPOINT java -jar …» — shell-форма ломает обработку сигналов завершения (SIGTERM), нужна exec-форма с квадратными скобками ["java", ...].
  • ❌ «Контейнеры обеспечивают абсолютную изоляцию, равную виртуальным машинам» — уязвимость в ядре хоста компрометирует все контейнеры на сервере.

Связанные вопросы