Що таке контейнеризація і навіщо вона потрібна
Запущений екземпляр образу називається контейнером (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):
- LowerDir: базові шари образу (ОС, JRE, JAR-файл). Вони розділяються між усіма запущеними контейнерами на одному сервері, заощаджуючи оперативну пам’ять та дисковий простір.
- UpperDir (Container Layer): тонкий шар читання/запису (Read-Write), унікальний для кожного конкретного екземпляра контейнера.
- 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) має спеціальні обов’язки:
- Він зобов’язаний перехоплювати та ховати зомбі-процеси (Zombie Reaping), чиї батьківські процеси завершилися.
- За замовчуванням ядро Linux НЕ застосовує стандартну дію для сигналів
SIGINTтаSIGTERMдо процесу PID 1. Якщо процес явно не зареєстрував обробник сигналу, сигнал ігнорується! - Помилка 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без збереження стану транзакцій. - Рішення: використовувати виключно 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:
- Rootless Containers та
USER nonroot: заборона роботи від UID 0. - Read-Only Root Filesystem: прапорець
--read-onlyблокує запис виконуваних файлів у системні директорії. - Drop Capabilities: відмова від небезпечних привілеїв ядра:
securityContext: capabilities: drop: ["ALL"] readOnlyRootFilesystem: true allowPrivilegeEscalation: false - 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):
- Кожна операція запису в наявний файл вимагає накладних витрат Copy-on-Write (CoW): блок даних має бути прочитаний з нижнього шару образу, скопійований на верхній шар контейнера (UpperDir) і лише потім перезаписаний.
- Overlay2 не призначений для високошвидкісного прямого введення-виведення. Блокування файлових дескрипторів та фрагментація шарів створюють високу затримку (I/O latency).
- Шар контейнера зберігається в директорії
/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", ...]. - ❌ «Контейнери забезпечують абсолютну ізоляцію, рівну віртуальним машинам» — уразливість у ядрі хоста компрометує всі контейнери на сервері.