Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Middle: питання на співбесіді з теми «Образи й Dockerfile»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

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-тестом саме в ньому.

Докладніше в документації: Multi-stage builds

Обидві інструкції задають, що запускається в контейнері, але по-різному поводяться з аргументами 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.

Докладніше в документації: Довідник Dockerfile

Поширене непорозуміння: 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 взагалі: так - як документацію: той, хто запускає образ, одразу бачить, який порт слухає застосунок. Але покладатися на нього як на механізм безпеки чи мережі не можна.

Докладніше в документації: Dockerfile: 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.

Докладніше в документації: Dockerfile: HEALTHCHECK

Шари образу незмінні. Кожна інструкція додає новий шар поверх попередніх, і видалення файлу в пізнішому шарі не прибирає його з попереднього - лише позначає як видалений (так званий 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) і зникає разом з контейнером.

Докладніше в документації: Docker: драйвери зберігання