Питання на співбесіді з Docker
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
BuildKit - рушій збирання образів, що замінив старий (legacy) збирач. У Docker Engine він використовується за замовчуванням з версії 23.0 (у Docker Desktop - ще раніше), тож у сучасному Docker docker build - це вже BuildKit.
Що він змінив:
1. Паралельне збирання. BuildKit будує граф залежностей між етапами й виконує незалежні частини одночасно. У multi-stage збиранні етапи для Composer і для npm виконуються паралельно, а не по черзі.
2. Пропуск непотрібних етапів. Етапи, від яких не залежить цільовий етап, не збираються взагалі. Старий збирач виконував усі етапи послідовно.
3. Розумніший кеш:
- cache mounts (
RUN --mount=type=cache) - кеш пакетних менеджерів між збираннями, не потрапляючи в шари образу; - експорт і імпорт кешу в реєстр, локальний каталог чи кеш GitHub Actions - корисно для CI, де кожне збирання на «чистій» машині.
4. Секрети й SSH під час збирання - --mount=type=secret, --mount=type=ssh: доступ до приватних репозиторіїв і токенів без потрапляння в шари.
5. Мультиплатформні образи через docker buildx - одна команда збирає образ для amd64 і arm64.
6. Сучасний синтаксис Dockerfile: heredoc-и в RUN і COPY, COPY --link, COPY --chmod, перевірки (docker build --check).
7. Атестації - SBOM і provenance прикріплюються до образу під час збирання.
docker build і docker buildx build: у сучасному Docker docker build - псевдонім для docker buildx build з налаштованим за замовчуванням збирачем. buildx дає змогу створювати окремі збирачі (наприклад, драйвер docker-container для мультиплатформних збирань і експорту кешу).
Корисний рядок на початку Dockerfile:
# syntax=docker/dockerfile:1
Він каже BuildKit завантажити актуальну стабільну версію фронтенду Dockerfile - нові можливості синтаксису доступні без оновлення самого Docker.
Вивід збирання у BuildKit компактний; для повного логу кожного кроку - --progress=plain.
Контекст збирання - набір файлів, які Docker передає збирачу. У команді docker build . крапка - це контекст: поточний каталог з усім вмістом. Інструкції COPY і ADD бачать лише файли з контексту.
Проблема без .dockerignore: у контекст потрапляє все - vendor, node_modules, .git, логи, локальна база SQLite, .env:
- повільне збирання - сотні мегабайтів передаються збирачу щоразу;
- ламається кеш:
COPY . .вважає зміненими будь-які файли, включно з логами й.git, - шар і всі наступні збираються заново; - витік секретів:
.envз паролями чи ключі можуть опинитися в образі й потрапити в реєстр; - неправильні залежності: локальний
vendor(зібраний під macOS, з dev-пакетами) перезаписує той, що встановлено в образі.
.dockerignore для Laravel-проєкту:
.git
.github
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
tests
*.log
docker-compose*.yml
Синтаксис схожий на .gitignore: шаблони, ** для будь-якої глибини, ! - виняток з виключення.
Чому краще виключати, ніж копіювати вибірково: навіть з точними COPY (COPY app/ app/) контекст усе одно передається збирачу повністю - .dockerignore зменшує саму передачу.
Як перевірити розмір контексту: BuildKit показує його у виводі збирання (transferring context: 2.3MB). Сотні мегабайтів - сигнал, що щось не виключено.
Нюанси:
.dockerignoreшукається в корені контексту (або поруч з Dockerfile у виглядіDockerfile.dockerignoreдля кількох Dockerfile в одному репозиторії);vendorіnode_modulesвстановлюються всередині збирання (composer install,npm ci) - тоді вони збираються під Linux образу й лише з потрібними залежностями;- виключені файли недоступні навіть для
COPY- якщо файл потрібен у збиранні, його не можна виключати.
ARG - змінна часу збирання. Доступна лише під час docker build, у запущеному контейнері її немає:
ARG PHP_VERSION=8.5
FROM php:${PHP_VERSION}-fpm-alpine
ARG APP_VERSION=dev
RUN echo "Building version $APP_VERSION"
docker build --build-arg APP_VERSION=1.4.2 .
ENV - змінна середовища образу. Діє і під час збирання (у наступних інструкціях), і в кожному запущеному контейнері:
ENV APP_ENV=production \
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
Значення за замовчуванням можна перевизначити при запуску: docker run -e APP_ENV=staging ....
Типові поєднання:
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} # перенести значення збирання в середовище контейнера
Особливості ARG:
- область видимості:
ARG, оголошений доFROM, доступний лише в рядкуFROM. Щоб використати його всередині етапу, треба повторитиARG PHP_VERSIONпісляFROM; - кожен етап multi-stage збирання має свої
ARG; - вбудовані змінні BuildKit:
TARGETPLATFORM,TARGETARCH,BUILDPLATFORM- для мультиплатформних збирань.
Головна пастка - секрети:
ARG GITHUB_TOKEN
RUN composer config github-oauth.github.com $GITHUB_TOKEN && composer install
Значення ARG зберігається в історії образу (docker history показує команди з підставленими значеннями), а ENV - ще й у метаданих образу й кожному контейнері. Будь-хто з доступом до образу прочитає секрет. docker build --check попереджає про це правилом SecretsUsedInArgOrEnv. Для секретів - RUN --mount=type=secret.
Що куди класти:
ARG- параметри збирання: версії базових образів і інструментів, прапорці («встановити розширення для розробки»), версія релізу;ENV- налаштування середовища, однакові для всіх запусків образу (шляхи, параметри PHP);- конфігурація конкретного середовища (база, ключі, URL) - не в образ, а при запуску: змінні оточення,
.env, секрети оркестратора. Один образ має працювати і на staging, і на продакшені.
Офіційні PHP-образи мають варіанти на Alpine Linux (php:8.5-fpm-alpine) і на Debian (php:8.5-fpm на повному Debian, а для інших технологій часто ще й -slim).
Alpine:
- маленький розмір - базова система кілька мегабайтів, образ PHP - десятки мегабайтів замість сотень;
- менше пакетів - менше потенційних вразливостей у сканері;
- але інша стандартна бібліотека C - musl замість glibc.
Наслідки musl, через які обирають Debian:
- бінарні пакети під glibc не працюють: готові збірки деяких розширень, інструментів (наприклад, частина бінарників для Node.js-модулів, браузери для генерації PDF) - доводиться збирати з вихідного коду, і збирання стає довшим;
- відмінності в поведінці: робота з DNS (наприклад, обробка
searchуresolv.conf), локалі, деякі функції форматування - інколи дають несподівані помилки, які важко відтворити; - продуктивність: у частині навантажень (виділення пам'яті, багатопотоковість) musl помітно повільніший за glibc;
- налагодження: менше звичних інструментів, інша пакетна система (
apkзамістьapt).
Debian (slim):
- стандартний glibc - сумісність з більшістю бінарних пакетів і розширень;
- більший розмір (але з multi-stage збиранням і чисткою - прийнятний);
- передбачувана поведінка, звична для більшості серверних систем.
Як обрати:
- Alpine - коли важливий розмір і застосунок не залежить від нативних бінарників під glibc; добре перевірена конфігурація;
- Debian - коли потрібні нестандартні розширення, бінарні інструменти (wkhtmltopdf, Chromium, драйвери баз даних), коли команда стикалася з «дивними» помилками на musl;
- distroless / мінімальні образи - для скомпільованих застосунків (Go); для PHP менш практичні.
Незалежно від вибору:
- фіксувати версію: не
php:8.5-fpm-alpine, а конкретну мінорну версію чи дайджест - інакше нове збирання тихо підтягне іншу версію PHP або системних бібліотек; - оновлювати свідомо й регулярно - заради виправлень безпеки;
- не порівнювати лише розмір: різниця у 100 МБ рідко важлива, а година налагодження проблеми musl - помітна.
Докладніше в документації: Найкращі практики: вибір базового образу
Чому розмір має значення: довше завантаження при деплої й масштабуванні, більше місця в реєстрі й на серверах, більше пакетів - більше потенційних вразливостей.
Як знайти, що займає місце:
docker image ls myapp # загальний розмір
docker history myapp:1.4.2 # розмір кожного шару й команда, що його створила
dive myapp:1.4.2 # інтерактивний перегляд шарів і файлів
dive показує вміст кожного шару, які файли додано, змінено чи «видалено» в ньому, і оцінює «втрачене» місце.
Типові причини великого образу:
1. Видалення в окремому шарі. Шар неможливо зменшити наступним шаром - видалений файл просто приховується, але лишається в образі:
# погано: 300 МБ кешу лишаться в першому шарі
RUN apt-get update && apt-get install -y build-essential
RUN apt-get purge -y build-essential && rm -rf /var/lib/apt/lists/*
# добре: встановлення й чистка в одному RUN
RUN apt-get update \
&& apt-get install -y --no-install-recommends libzip-dev \
&& rm -rf /var/lib/apt/lists/*
2. Інструменти збирання в фінальному образі - компілятори, -dev-пакети, Composer, Node.js. Ліки - multi-stage: збирати в одному етапі, копіювати лише результат у фінальний.
3. Залежності для розробки: composer install без --no-dev, node_modules після збирання фронтенду (у фінальний образ потрібен лише public/build).
4. Зайве в контексті збирання: .git, тести, локальні vendor і node_modules через COPY . . без .dockerignore.
5. Кеші пакетних менеджерів: кеш Composer, npm, apk, apt - чистити в тому самому RUN або використовувати cache mounts (RUN --mount=type=cache), що взагалі не потрапляють в образ.
6. Рекомендовані пакети: apt-get install без --no-install-recommends тягне багато непотрібного.
7. Великий базовий образ: повний Debian замість slim/Alpine, якщо застосунку не потрібна сумісність, яку дає повний образ.
Корисні звички для PHP-образу: composer install --no-dev --optimize-autoloader --no-scripts на етапі залежностей, розширення - через docker-php-ext-install з видаленням -dev-пакетів у тому самому шарі, фронтенд - окремим етапом на образі Node.
Межа оптимізації: зменшувати розмір має сенс, доки це не шкодить читабельності Dockerfile і швидкості налагодження.
Multi-stage build - кілька етапів FROM в одному Dockerfile. Проміжні етапи містять інструменти для збирання, а фінальний образ копіює з них лише результат.
# Етап 1: фронтенд
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Етап 2: залежності PHP
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist
# Етап 3: фінальний образ
FROM php:8.4-fpm-alpine
WORKDIR /var/www
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
Що це дає:
- Менший образ. У фінальному немає Node.js,
node_modules, Composer, компіляторів - лише те, що потрібно для роботи. Різниця буває в сотні мегабайтів. - Менша поверхня атаки. Менше програм - менше вразливостей і менше інструментів для нападника, який потрапив у контейнер.
- Паралельне збирання. BuildKit збирає незалежні етапи одночасно, а невикористані пропускає.
- Один Dockerfile для всього: окремий етап
devз Xdebug і інструментами,testдля CI,production- збирати потрібний через--target.
docker build --target production -t myapp .
Пастка: копіюючи з етапу збирання, легко забути щось потрібне під час роботи (розширення PHP, системну бібліотеку). Перевіряйте фінальний образ запуском тестів чи smoke-тестом саме в ньому.
Обидві інструкції задають, що запускається в контейнері, але по-різному поводяться з аргументами docker run:
CMD- команда за замовчуванням. Якщо при запуску передати команду, вона повністю замінитьCMD.ENTRYPOINT- основний виконуваний файл. Аргументиdocker run(абоCMD) дописуються до нього як параметри.
ENTRYPOINT ["php", "artisan"]
CMD ["serve", "--host=0.0.0.0"]
docker run myapp # php artisan serve --host=0.0.0.0
docker run myapp queue:work # php artisan queue:work
docker run myapp migrate --force # php artisan migrate --force
Типовий сценарій - скрипт-обгортка як ENTRYPOINT, що готує середовище й передає керування команді:
#!/bin/sh
set -e
php artisan config:cache
exec "$@" # запустити CMD - і зробити його PID 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm"]
Exec-форма проти shell-форми:
CMD ["php-fpm"](JSON-масив, exec-форма) - процес запускається напряму й отримує сигнали.CMD php-fpm(shell-форма) - запускається через/bin/sh -c, і PID 1 - це оболонка. СигналSIGTERMприdocker stopотримує shell, а не застосунок, тож контейнер не завершується коректно і через 10 секунд його вбиваютьSIGKILL.
Тому в обох інструкціях - exec-форма, а в скрипті-обгортці - exec "$@".
ENTRYPOINT можна перевизначити при запуску: docker run --entrypoint sh myapp.
Поширене непорозуміння: EXPOSE 80 «відкриває порт». Насправді EXPOSE нічого не відкриває - це документація в метаданих образу: «застосунок у контейнері слухає цей порт».
EXPOSE 8080
Що EXPOSE робить:
- записує порт у метадані образу (
docker image inspect→ExposedPorts); - слугує підказкою для людей і інструментів;
- використовується прапорцем
-P(--publish-all): Docker опублікує всі задекларовані порти на випадкові порти хоста.
Що EXPOSE не робить:
- не публікує порт на хості - без
-pконтейнер недоступний ззовні, зEXPOSEчи без; - не обмежує доступ: інші контейнери в тій самій мережі можуть звертатися до будь-якого порту, на якому слухає процес, - навіть незадекларованого;
- не змушує застосунок слухати цей порт - це має налаштувати сам застосунок.
Публікація порту - при запуску:
docker run -p 8080:80 nginx # хост:8080 → контейнер:80, на всіх інтерфейсах хоста
docker run -p 127.0.0.1:8080:80 nginx # лише локально
# compose
services:
web:
ports:
- "8080:80"
Пастка безпеки з -p 3306:3306: опублікований порт доступний на всіх інтерфейсах хоста (0.0.0.0), тобто з інтернету, якщо сервер має публічну адресу. Docker додає правила в iptables так, що вони можуть обійти налаштування фаєрвола хоста (ufw). Базу й Redis не публікують зовсім - сервіси звертаються до них через мережу Docker - або публікують лише на 127.0.0.1.
Протокол: за замовчуванням TCP; для UDP - EXPOSE 53/udp і -p 53:53/udp.
Чи писати EXPOSE взагалі: так - як документацію: той, хто запускає образ, одразу бачить, який порт слухає застосунок. Але покладатися на нього як на механізм безпеки чи мережі не можна.
Docker за замовчуванням знає лише, чи працює процес. Процес може бути живим, але застосунок - завислим: зайняті всі воркери, втрачено з'єднання з базою, вичерпано пам'ять. HEALTHCHECK дає змогу перевіряти справжню готовність.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost/up || exit 1
Параметри:
--interval- як часто перевіряти (за замовчуванням 30 с);--timeout- скільки чекати на відповідь;--start-period- час на запуск, протягом якого невдачі не рахуються (прогрів, міграції);--start-interval- частіші перевірки протягом стартового періоду, щоб швидше стати «здоровим»;--retries- скільки невдач поспіль до статусуunhealthy.
Команда повертає 0 - здоровий, 1 - нездоровий. Статус видно в docker ps (healthy, unhealthy, starting).
Хто використовує статус:
- Compose:
depends_onзcondition: service_healthy- сервіс стартує, коли залежність справді готова; - Swarm замінює нездорові контейнери і не перемикає трафік на новий контейнер під час оновлення, доки той не здоровий;
- платформи деплою (Dokploy, Coolify, Kamal) чекають на здоровий контейнер перед перемиканням трафіку.
Сам Docker Engine не перезапускає нездоровий контейнер - статус лише позначається. Перезапуск робить оркестратор чи власна логіка.
Яка перевірка добра:
- легка й швидка: запит на
/up(у Laravel 11+ такий маршрут є за замовчуванням), а не тяжка сторінка з запитами до бази; - перевіряє застосунок, а не лише порт: відповідь саме від PHP, а не лише від Nginx;
- глибина - свідомий вибір: перевірка бази в healthcheck означає, що короткочасна недоступність бази позначить нездоровими всі контейнери застосунку - і оркестратор почне їх перезапускати, погіршуючи ситуацію. Часто краще перевіряти лише сам застосунок;
- наявні утиліти: у мінімальних образах немає
curl- тодіwget -q --spider, вбудована команда застосунку чи невеликий бінарник.
Для воркерів без HTTP (черги, планувальник) - перевірка процесу чи файлу-маркера, який воркер оновлює («серцебиття»).
Перевизначити чи вимкнути - у Compose: healthcheck: у сервісі чи disable: true.
Шари образу незмінні. Кожна інструкція додає новий шар поверх попередніх, і видалення файлу в пізнішому шарі не прибирає його з попереднього - лише позначає як видалений (так званий whiteout-файл).
RUN curl -o /tmp/sdk.tar.gz https://example.com/sdk.tar.gz # шар +500 МБ
RUN tar -xzf /tmp/sdk.tar.gz -C /opt && rm /tmp/sdk.tar.gz # архів «видалено»
У контейнері /tmp/sdk.tar.gz не видно, але 500 МБ залишилися в першому шарі. Образ завантажується з усіма шарами - і з видаленими файлами теж.
Як працює файлова система контейнера (OverlayFS):
- шари образу - лише для читання, складені один на одного;
- контейнер отримує тонкий записуваний шар зверху;
- зміна файлу з нижнього шару - копіювання при записі (copy-on-write): файл копіюється у верхній шар і змінюється там;
- видалення - позначка у верхньому шарі, що ховає файл нижче.
Як правильно - створення і видалення в одному шарі:
RUN curl -o /tmp/sdk.tar.gz https://example.com/sdk.tar.gz \
&& tar -xzf /tmp/sdk.tar.gz -C /opt \
&& rm /tmp/sdk.tar.gz
Або, краще, multi-stage build: усе тимчасове - у стадії збирання, у фінальний образ копіюється лише результат:
FROM debian:bookworm-slim AS sdk
RUN ... завантажити й розпакувати
FROM php:8.5-fpm
COPY --from=sdk /opt/sdk /opt/sdk
Типові «невидимі» витрати:
apt-get updateв одномуRUN, а очищення/var/lib/apt/lists- в іншому;COPY . ., а потімRUN rm -rf tests node_modules- краще не копіювати їх через.dockerignore;RUN chown -R www-data /appпісляCOPY- повна копія всіх файлів у новому шарі (замість цього -COPY --chown);- секрети: файл з ключем, скопійований і потім видалений, лишається в шарі - його можна дістати з образу. Для секретів -
RUN --mount=type=secret.
Записуваний шар контейнера теж варто тримати малим: логи, кеш, завантажені файли - у томах чи зовнішніх сховищах. Запис у шар контейнера повільніший (copy-on-write) і зникає разом з контейнером.
depends_on: [db] у звичайній формі гарантує лише порядок запуску контейнерів: db стартує раніше за app. Але «контейнер запущено» - не «база приймає з'єднання». PostgreSQL ще кілька секунд ініціалізується, а застосунок уже пробує підключитися й падає.
Рішення - healthcheck + умова:
services:
db:
image: postgres:17
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
depends_on:
db:
condition: service_healthy
Тепер app стартує лише тоді, коли healthcheck бази почав проходити.
Інші умови:
service_started- поведінка за замовчуванням.service_completed_successfully- дочекатися, поки разовий контейнер завершиться з кодом 0. Зручно для міграцій: окремий сервісmigrateзапускається, виконуєphp artisan migrate --forceі завершується, аappстартує після нього.
Але на цьому не зупиняються: depends_on працює лише при старті через Compose. База може перезапуститися пізніше, а на проді застосунок часто запускають оркестратором без Compose. Тому застосунок сам має бути стійким до тимчасової недоступності залежностей: повторні спроби підключення, коректні помилки, а не падіння при першому ж збої.
Healthcheck для самого застосунку (наприклад, на маршрут /up у Laravel) потрібен і проксі, і оркестратору - щоб не відправляти трафік у контейнер, що ще не готовий.
За замовчуванням контейнер може використати всю пам'ять і CPU хоста. Один процес з витоком пам'яті здатен покласти весь сервер разом з базою й іншими сервісами.
docker run --memory=512m --cpus=1.5 myapp
services:
worker:
deploy:
resources:
limits:
memory: 512M
cpus: "1.5"
Що відбувається при перевищенні пам'яті: ядро Linux (OOM killer) вбиває процес у контейнері. Контейнер завершується з кодом 137 (128 + 9, сигнал SIGKILL), а в docker inspect видно "OOMKilled": true. З restart: unless-stopped він підніметься знову - і, якщо причина не усунена, впаде знову.
CPU-ліміт працює інакше: процес не вбивають, а сповільнюють (throttling) - він отримує не більше вказаної частки процесорного часу.
Нюанси для PHP:
memory_limitу PHP і ліміт контейнера - різні речі. Ліміт контейнера рахує всі процеси: майстер PHP-FPM і всі воркери разом.pm.max_children = 20з піком 100 МБ на воркер - це до 2 ГБ, і ліміт 512 МБ гарантовано закінчиться OOM.- Кількість воркерів FPM, Octane чи черг підбирають під ліміт пам'яті контейнера, а не під пам'ять хоста.
- Воркерам черг ставлять
--memoryі--max-jobs, щоб вони перезапускалися раніше, ніж їх уб'є ядро.
Моніторинг: docker stats показує поточне споживання. Код виходу 137 у логах і рестарти без явних помилок у застосунку - перша ознака, що контейнеру не вистачає пам'яті.
tmpfs - файлова система в оперативній пам'яті. Дані ніколи не потрапляють на диск хоста і зникають при зупинці контейнера.
docker run --tmpfs /tmp:rw,size=64m,mode=1777 myapp
services:
app:
tmpfs:
- /tmp:size=64m
read_only: true
Три види сховища в Docker:
| Том | Bind mount | tmpfs | |
|---|---|---|---|
| де дані | область Docker на диску | каталог хоста | пам'ять |
| переживає контейнер | так | так | ні |
| швидкість | диск | диск (на macOS - повільно) | пам'ять |
| для чого | дані, що мають зберігатися | код у розробці, конфіги | тимчасові й чутливі дані |
Коли tmpfs доречний:
- файлова система лише для читання:
read_only: true- сильний захист (зловмисник не запише бекдор у код), але застосунку потрібні тимчасові файли. tmpfs для/tmp,/runі каталогів кешу дає записуване місце без ослаблення захисту; - чутливі тимчасові дані, які не повинні потрапити на диск: розшифровані файли, тимчасові ключі;
- швидкий тимчасовий кеш: скомпільовані шаблони, тимчасові файли обробки - коли їх втрата при перезапуску не страшна;
- тести: база в tmpfs (
/var/lib/postgresql/dataу тестовому контейнері) значно прискорює тести з багатьма записами.
Що враховувати:
- пам'ять: tmpfs займає оперативну пам'ять і рахується в ліміт пам'яті контейнера. Без
sizeможна непомітно з'їсти половину пам'яті хоста; заповнений tmpfs при ліміті - OOM; - дані зникають при перезапуску - нічого, що має зберігатися;
- лише Linux-контейнери і не спільний між контейнерами (кожен має свій);
- права:
mode=1777для/tmp, інакше непривілейований користувач не зможе писати.
Для Laravel з файловою системою лише для читання: storage/framework/cache, storage/framework/views - у tmpfs чи томі, сесії й кеш - у Redis чи базі, логи - в stderr, завантажені файли - в S3/R2. Тоді коду застосунку взагалі не потрібен запис на диск.
Контейнер у циклі перезапусків (Restarting (1) 5 seconds ago) - головний процес падає одразу після старту, а політика --restart запускає його знову.
Крок 1 - логи, включно з попередніми спробами:
docker logs --tail 100 app
docker logs --since 10m app
Найчастіші причини: помилка в конфігурації чи змінних оточення, недоступна база при старті, неіснуючий файл у CMD, помилка синтаксису після деплою.
Крок 2 - стан і причина завершення:
docker inspect app --format '{{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}}'
OOMKilled=true - перевищено ліміт пам'яті; код 127 - команду не знайдено; 1 - помилка застосунку.
Крок 3 - запустити образ вручну з оболонкою, обійшовши CMD:
docker run --rm -it --entrypoint sh myapp:latest
docker compose run --rm app sh
Усередині - перевірити файли, права, змінні, запустити команду вручну й побачити помилку.
Споживання ресурсів:
docker stats # CPU, пам'ять (з лімітом), мережа, диск - у реальному часі
docker stats --no-stream # одноразовий знімок
docker top app # процеси всередині контейнера
Що шукати:
- пам'ять росте й не падає - витік у довгоживучому процесі (воркер черги без
--max-jobs, Octane без--max-requests); - пам'ять близька до ліміту - скоро буде OOM; врахуйте, що в пам'ять контейнера входить і сторінковий кеш файлів;
- CPU 100% постійно - нескінченний цикл, активне очікування, надто часте опитування;
- багато процесів у
docker top- PHP-FPM з завеликимmax_childrenчи процеси-«зомбі» (немає init у PID 1).
Події Docker:
docker events --filter container=app --since 1h
Показує die, oom, kill, restart, health_status з часом - видно хронологію.
Для продакшену ручних команд замало: збір метрик (cAdvisor + Prometheus, Beszel, Netdata) і логів у централізоване сховище, сповіщення про перезапуски й OOM.
Мінімальні образи без оболонки (distroless) - для налагодження docker debug, який підключає набір інструментів до запущеного контейнера.
Спокуса знайома: зайти в контейнер (docker exec -it app bash), встановити пакет, виправити конфіг - «і все запрацювало». А потім зберегти результат як образ:
docker commit app myapp:fixed
Чому це погана практика:
- зміни зникнуть: записуваний шар контейнера знищується разом з контейнером. Наступний деплой, перезапуск оркестратором, масштабування - і ручні виправлення втрачено;
- невідтворюваність: образ з
docker commit- «чорна скринька». Ніхто не знає, які саме команди виконано, в якому порядку, з якими версіями. Повторити збирання неможливо; - немає історії в Git: зміни не проходять рев'ю, не прив'язані до коміту, їх не відкотити;
- сміття в образі: кеш менеджерів пакетів, логи, тимчасові файли, історія команд - усе, що було в контейнері, потрапляє в образ;
- секрети: якщо в контейнері були токени чи ключі - вони тепер в образі.
Принцип незмінної інфраструктури: контейнери не «лагодять», а замінюють. Виправлення - у Dockerfile чи конфігурації, новий образ, новий деплой.
Як правильно розслідувати й виправляти:
- зайти в контейнер, щоб з'ясувати проблему (це нормально);
- перенести виправлення в Dockerfile, конфіг у репозиторії чи змінні оточення;
- зібрати новий образ і задеплоїти.
Що бачити, що змінено в контейнері:
docker diff app
# A /var/www/html/storage/logs/laravel.log (додано)
# C /usr/local/etc/php/conf.d (змінено)
# D /tmp/cache (видалено)
Корисно для розслідування: що саме записує застосунок у файлову систему контейнера (і чи не варто це винести в том чи tmpfs).
Коли docker commit доречний:
- зберегти стан для розслідування інциденту чи криміналістики - заморозити контейнер, щоб вивчити пізніше;
- разові експерименти локально, які не підуть у продакшен.
Захист від спокуси в продакшені: файлова система лише для читання (read_only: true) - ручні зміни просто неможливі, а помилки конфігурації виявляються одразу.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії