Senior: питання на співбесіді з теми «Образи й Dockerfile»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
1. Не запускати від root. За замовчуванням процес у контейнері - root. Вразливість у застосунку тоді дає нападнику root усередині контейнера, а з помилками конфігурації (змонтований Docker-сокет, привілейований режим) - і шлях на хост.
RUN addgroup -S app && adduser -S app -G app
USER app
Для PHP-FPM зазвичай достатньо, щоб воркери працювали від www-data, а записувані каталоги (storage/, bootstrap/cache/) належали цьому користувачу.
2. Мінімальна база. alpine, -slim чи distroless-образи містять менше пакетів - менше CVE і менше інструментів для нападника. Multi-stage build прибирає з фінального образу компілятори й менеджери пакетів.
3. Жодних секретів у шарах. COPY .env чи ARG API_KEY залишають секрет в історії образу, і docker history чи розпакування шарів його покаже - навіть якщо пізніше файл видалено. Секрети - лише під час запуску (змінні оточення, Docker secrets) або під час збирання через --mount=type=secret.
4. Фіксовані версії. FROM php:8.4.13-fpm-alpine замість php:latest; для максимальної відтворюваності - за дайджестом @sha256:.... Плаваючий тег може принести несподівані зміни.
5. Сканування. docker scout cves, Trivy чи Grype у CI знаходять відомі вразливості в пакетах образу. Регулярне перезбирання підтягує оновлення безпеки базового образу.
6. Обмеження під час запуску: файлова система лише для читання (--read-only з окремими томами для запису), --cap-drop=ALL, без --privileged, ніколи не монтувати /var/run/docker.sock у застосунок.
7. .dockerignore - щоб .git, .env, дампи й ключі не потрапили в контекст збирання взагалі.
Інколи секрет потрібен під час збирання: токен для приватного Composer- чи npm-репозиторію, ключ для завантаження приватного пакета.
Чому не ARG чи ENV:
ARG COMPOSER_AUTH
RUN composer install
Значення ARG зберігається в метаданих образу й видно через docker history. ENV - тим більше, він лишається у фінальному образі. А COPY auth.json і подальше RM не допоможе: файл лишився в попередньому шарі.
Правильно - секрет-монтування BuildKit: секрет доступний лише під час виконання однієї інструкції RUN, як тимчасовий файл, і не потрапляє в жоден шар чи кеш.
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=composer_auth,target=/root/.composer/auth.json \
composer install --no-dev --prefer-dist
docker build --secret id=composer_auth,src=$HOME/.composer/auth.json .
# або зі змінної оточення:
docker build --secret id=composer_auth,env=COMPOSER_AUTH_JSON .
У Docker Compose і GitHub Actions (docker/build-push-action) для цього є власні параметри secrets.
Для SSH-ключів (клонування приватних Git-репозиторіїв під час збирання) - окремий механізм --mount=type=ssh з пересиланням SSH-агента, без копіювання ключа.
Перевірка: docker history --no-trunc і розпакування шарів не мають показувати ні значення, ні файлу секрету. Сканери секретів у CI (gitleaks, trufflehog) можна натравити й на сам образ.
Linux визначає права за числовими ідентифікаторами користувача й групи (UID/GID), а не за іменами. Контейнер і хост ділять ядро, тож файл, створений у контейнері процесом з UID 33 (www-data у Debian), на хості належатиме користувачу з UID 33 - яким би не було його ім'я.
Типові симптоми:
- у розробці з bind mount: застосунок у контейнері (UID 33 чи root) створює файли в
storage/іvendor/- на хості розробник (UID 1000) не може їх змінити чи видалити безsudo. Або навпаки: контейнер не може писати в каталог, створений на хості; - у продакшені: «Permission denied» при записі в
storage/logsчиbootstrap/cache, бо файли скопійовано з власникомroot, а процес працює якwww-data.
Рішення в образі:
FROM php:8.5-fpm
COPY --chown=www-data:www-data . /var/www/html
USER www-data
COPY --chown- власник одразу при копіюванні, без окремогоRUN chown -R(той дублює всі файли в новому шарі);USER- процес працює не від root.
Рішення для розробки - узгодити UID:
ARG UID=1000
ARG GID=1000
RUN groupmod -o -g ${GID} www-data && usermod -o -u ${UID} -g ${GID} www-data
docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
Тепер www-data у контейнері має той самий UID, що й розробник на хості, - файли з обох сторін належать одній людині. Так робить Laravel Sail (WWWUSER, WWWGROUP).
Альтернативи:
docker run --user $(id -u):$(id -g)- запуск від імені користувача хоста (але в образі може не бути такого користувача й домашнього каталогу);- іменовані томи замість bind mount для
vendor,node_modules- вони живуть у Docker, без конфлікту з хостом; - rootless Docker чи
userns-remap- root у контейнері відображається на непривілейованого користувача хоста.
На macOS і Windows з Docker Desktop проблема менш помітна: файлова система хоста монтується у віртуальну машину з перетворенням власників. Тому налаштування, що «працює на Mac», ламається на Linux-сервері чи в CI.
Пастка chmod -R 777 - «швидке рішення» проблем з правами, яке дає будь-якому процесу право змінювати код застосунку. Правильно - потрібний власник і мінімальні права (775 для каталогів запису).
«Docker» - це набір компонентів, і розуміння їхньої ролі пояснює, чому образи, зібрані Docker, працюють у Kubernetes без Docker.
Стандарти OCI (Open Container Initiative):
- image spec - формат образу: шари, маніфест, конфігурація;
- runtime spec - як запустити контейнер з розпакованого образу;
- distribution spec - протокол реєстрів (push/pull).
Образ, зібраний Docker, - звичайний OCI-образ. Його запускають containerd, CRI-O, Podman, Kubernetes.
Шари виконання:
docker CLI → dockerd (Docker Engine) → containerd → containerd-shim → runc → процес
- docker CLI - клієнт; надсилає команди демону через API (сокет
/var/run/docker.sock); - dockerd - Docker Engine: збирання (BuildKit), мережі, томи, API, Compose-сумісність;
- containerd - керує життєвим циклом контейнерів і образами (pull, зберігання, знімки файлових систем);
- runc - низькорівневе середовище виконання OCI: створює namespaces і cgroups і запускає процес;
- shim - тримає контейнер, коли containerd перезапускається.
Чому це важливо на практиці:
- Kubernetes з версії 1.24 не використовує Docker Engine напряму - він працює з containerd чи CRI-O через CRI. Образи при цьому ті самі;
- перезапуск dockerd не обов'язково зупиняє контейнери (опція
live-restore); - альтернативні runtime: gVisor (
runsc) додає ізоляцію ядра в просторі користувача, Kata Containers - легкі віртуальні машини для кожного контейнера. Підключаються до Docker як--runtime; - Podman - сумісний з CLI Docker, без центрального демона й з rootless за замовчуванням.
Docker Desktop на macOS і Windows: контейнерам потрібне ядро Linux, тож Docker Desktop запускає легку віртуальну машину з Linux, а docker CLI на хості говорить з демоном у ній. Звідси особливості: повільніший доступ до файлів хоста (bind mount через межу віртуальної машини), окрема пам'ять і CPU, обмежені налаштуваннями VM, host.docker.internal для доступу до хоста.
Архітектура процесора: образ зібрано під конкретну архітектуру (amd64, arm64). На Mac з Apple Silicon образ amd64 запуститься через емуляцію - повільно й інколи з помилками, тому для продакшен-серверів важливі мультиплатформні образи.
Докладніше в документації: Docker: альтернативні середовища виконання
Офіційний образ PHP постачається без php.ini - діють значення за замовчуванням, розраховані на розробку. Для продакшену конфігурацію треба задати явно.
Базова конфігурація:
FROM php:8.5-fpm
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/app.ini $PHP_INI_DIR/conf.d/zz-app.ini
; docker/php/app.ini
expose_php = Off
memory_limit = 256M
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 30
date.timezone = Europe/Kyiv
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = tracing
opcache.jit_buffer_size = 64M
Ключові рішення для контейнера:
opcache.validate_timestamps = 0- PHP не перевіряє зміну файлів на кожен запит. У контейнері код незмінний (новий деплой = новий контейнер), тож перевірка - марна робота. Але для розробки з bind mount потрібне1, інакше зміни коду не видно;opcache.max_accelerated_files- більше за кількість PHP-файлів проєкту зvendor(у Laravel-проєкті легко десятки тисяч);preload(opcache.preload) - завантажити класи фреймворку в пам'ять при старті FPM. Дає приріст, але вимагає перезапуску при зміні коду й акуратного вибору файлів;- JIT помітно допомагає обчислювальним задачам; для типового вебзастосунку, що чекає на базу, ефект невеликий.
PHP-FPM:
; www.conf
pm = static ; передбачуване використання пам'яті в контейнері з лімітом
pm.max_children = 20 ; ≈ ліміт пам'яті контейнера / пам'ять одного воркера
pm.max_requests = 500 ; перезапуск воркера - захист від витоків пам'яті
У контейнері з жорстким лімітом пам'яті pm = dynamic з великим max_children може перевищити ліміт під навантаженням - і OOM-killer вб'є процеси.
Логи: FPM і PHP мають писати в stderr/stdout (error_log = /proc/self/fd/2, catch_workers_output = yes), щоб їх збирав Docker.
Розділення середовищ: однаковий образ для всіх середовищ, а відмінності (рівень логування, Xdebug) - через змінні оточення чи окрему стадію збирання для розробки, а не різні Dockerfile.
Часовий пояс: date.timezone у PHP і змінна TZ разом з пакетом tzdata в образі - інакше логи, планувальник і дати застосунку можуть «з'їжджати» на UTC.