🐳 Розділ 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", ...].
  • ❌ «Контейнери забезпечують абсолютну ізоляцію, рівну віртуальним машинам» — уразливість у ядрі хоста компрометує всі контейнери на сервері.

Пов’язані питання