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

Docker: питання на співбесіді рівня Junior

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

35 питань

  • Образ (image) - незмінний шаблон: файлова система з усім потрібним (ОС-шар, PHP, розширення, код, залежності) і метадані - яку команду запускати, які порти слухати. Збирається з Dockerfile, зберігається в реєстрі (Docker Hub, GHCR).
  • Контейнер - запущений екземпляр образу: ізольований процес з власною файловою системою, мережею й обмеженнями ресурсів.

Аналогія: образ - як клас, контейнер - як об'єкт. З одного образу можна запустити скільки завгодно контейнерів.

docker build -t myapp:1.4 .         # зібрати образ
docker run -d --name web myapp:1.4  # запустити контейнер
docker ps                           # запущені контейнери
docker images                       # локальні образи

Шари. Образ складається з шарів тільки для читання - по одному на інструкцію Dockerfile, що змінює файли. Контейнер додає зверху тонкий записуваний шар. Тому:

  • десять контейнерів з одного образу ділять його шари й не займають десятикратно більше місця;
  • зміни, зроблені всередині контейнера, зникають разом із ним. Дані, що мають жити довше (база, завантажені файли), зберігають у томах (volumes).

Практичне правило: контейнер має бути «одноразовим» - його можна знищити й створити заново з того ж образу без втрати даних. Змінити застосунок - це зібрати новий образ, а не зайти в контейнер і поправити файл.

Докладніше в документації: Що таке образ

Кожна інструкція Dockerfile (RUN, COPY, ADD...) створює шар. Під час повторного збирання Docker перевіряє, чи змінилася інструкція та файли, що в неї входять. Якщо ні - бере шар з кешу. Щойно один шар змінився, усі наступні збираються заново.

Погано:

COPY . /var/www
RUN composer install --no-dev

Будь-яка зміна в коді (навіть README) інвалідує COPY ., і composer install виконується щоразу - хвилини на кожне збирання.

Добре: спершу те, що змінюється рідко, потім - часто:

COPY composer.json composer.lock /var/www/
RUN composer install --no-dev --no-scripts --no-autoloader

COPY . /var/www
RUN composer dump-autoload --optimize

Тепер залежності перевстановлюються лише при зміні composer.json/composer.lock, а зміна коду перебудовує тільки останні шари.

Ще правила:

  • .dockerignore - щоб у контекст збирання не потрапляли vendor/, node_modules/, .git/, .env, логи. Інакше COPY . інвалідується через файли, що до застосунку не мають стосунку, а секрети можуть потрапити в образ.
  • Встановлення пакетів ОС - на початку, одним RUN, разом з очищенням кешу менеджера пакетів.
  • Кеш-монтування BuildKit зберігає кеш Composer чи npm між збираннями навіть після інвалідації шару:
RUN --mount=type=cache,target=/root/.composer/cache composer install
  • У CI кеш зникає з кожною новою машиною - його експортують у реєстр (--cache-to/--cache-from).

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

Обидві інструкції додають файли в образ, але ADD робить більше, і саме це робить його менш передбачуваним.

COPY - просто копіює файли й каталоги з контексту збирання (або з іншої стадії --from=):

COPY composer.json composer.lock ./
COPY --chown=www-data:www-data . /var/www/html

ADD уміє ще й:

  • завантажувати файли за URL: ADD https://example.com/app.tar.gz /tmp/;
  • автоматично розпаковувати локальні архіви (.tar, .tar.gz, .tar.xz) у каталог призначення;
  • клонувати Git-репозиторії: ADD https://github.com/org/repo.git#v1.2 /src.

Чому за замовчуванням - COPY:

  • передбачуваність: COPY archive.tar.gz /app/ копіює архів як файл. ADD з тим самим рядком розпакує його - поведінка залежить від типу файлу;
  • кеш: для URL Docker мусить завантажити файл, щоб перевірити, чи він змінився;
  • безпека: завантаження з інтернету без перевірки вмісту - ризик ланцюжка постачання.

Коли ADD доречний:

  • розпакувати локальний архів одним кроком (наприклад, бінарник з архіву поруч із Dockerfile);
  • завантажити файл із перевіркою контрольної суми - сучасний ADD підтримує --checksum=sha256:..., тож вміст гарантовано той, що очікувався;
  • Git-репозиторій як джерело без окремого git clone в образі.

Корисні параметри обох інструкцій:

  • --chown=user:group - власник файлів одразу при копіюванні. Окремий RUN chown -R після COPY створює ще один шар з копією всіх файлів - образ подвоюється в розмірі;
  • --chmod=755 - права доступу;
  • --link - копіювання незалежним шаром, який не інвалідується при зміні попередніх шарів (корисно для фінальних стадій).

Що потрапляє в контекст визначає .dockerignore - без нього COPY . . скопіює .git, node_modules, .env і локальні логи.

Правило Docker Best Practices: COPY для локальних файлів; ADD - лише коли потрібні його додаткові можливості, і то свідомо.

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

Образ - це набір шарів, і кожна інструкція Dockerfile, що змінює файлову систему (RUN, COPY, ADD), створює новий шар. Розмір образу - сума шарів.

Історія шарів:

docker image history myapp:latest
IMAGE          CREATED BY                                      SIZE
a1b2c3d4e5f6   COPY . /var/www/html                            48MB
<missing>      RUN composer install --no-dev                   62MB
<missing>      RUN apt-get update && apt-get install ...       310MB
<missing>      FROM php:8.5-fpm                                 ...

Видно, яка інструкція скільки додала. --no-trunc показує повні команди.

Метадані образу:

docker image inspect myapp:latest

Змінні оточення, ENTRYPOINT/CMD, відкриті порти, мітки, архітектура (amd64/arm64), кількість шарів.

Що всередині файлової системи:

docker run --rm -it myapp:latest sh          # зайти й подивитися
docker run --rm myapp:latest du -sh /var/www/html/* | sort -h

Інструмент dive показує вміст кожного шару і файли, які були додані в одному шарі й видалені в наступному (марнування місця).

Типові причини великих образів:

  • кеш менеджерів пакетів: apt-get install без rm -rf /var/lib/apt/lists/* в тому самому RUN;
  • інструменти збирання у фінальному образі: компілятори, git, node_modules для збирання фронтенду - їх прибирає multi-stage build;
  • зайве в контексті: .git, тести, локальні файли - потрібен .dockerignore;
  • chown -R окремою інструкцією - копія всіх файлів у новому шарі;
  • важкий базовий образ - повний Debian замість slim.

Чому docker image ls і реальний розмір відрізняються: спільні шари базового образу зберігаються на диску один раз для всіх образів, що на ньому побудовані. docker system df -v показує, скільки займають шари насправді.

Розмір має значення: час завантаження при деплої й масштабуванні, місце в реєстрі, поверхня атаки (кожен зайвий пакет - потенційна вразливість).

Докладніше в документації: docker image history

Офіційний образ php має кілька варіантів - вони відрізняються тим, як запускається PHP:

Тег Що всередині Для чого
php:8.5-cli лише PHP CLI команди, воркери черг, скрипти
php:8.5-fpm PHP-FPM (FastCGI) за Nginx/Caddy, класичний веб-стек
php:8.5-apache Apache з mod_php простий «все в одному» веб-сервер
php:8.5-zts збірка з потокобезпекою розширення на кшталт parallel, вбудовування

Кожен варіант має основу Debian (за замовчуванням) або Alpine (php:8.5-fpm-alpine) - менший розмір, але інша бібліотека C (musl), що інколи дає несумісності.

Альтернатива для Laravel - образи FrankenPHP (dunglas/frankenphp): сучасний сервер на основі Caddy з вбудованим PHP, HTTPS і режимом воркера для Octane - один процес замість зв'язки Nginx + PHP-FPM.

Встановлення розширень - допоміжні скрипти офіційного образу:

FROM php:8.5-fpm

RUN apt-get update \
    && apt-get install -y --no-install-recommends libpq-dev libzip-dev libicu-dev \
    && docker-php-ext-install pdo_pgsql zip intl opcache \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && rm -rf /var/lib/apt/lists/*
  • docker-php-ext-install - розширення з вихідних кодів PHP (pdo_mysql, intl, zip, gd, opcache...);
  • docker-php-ext-configure - параметри перед збиранням (наприклад, gd --with-jpeg --with-freetype);
  • pecl install + docker-php-ext-enable - розширення з PECL (redis, xdebug, imagick);
  • системні бібліотеки (libpq-dev, libzip-dev) треба встановити самостійно - їх назви різняться в Debian і Alpine.

Простіше - install-php-extensions (проєкт mlocati): сам встановлює потрібні системні залежності для обох дистрибутивів:

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_pgsql intl zip redis opcache

Конфігурація PHP: готові шаблони php.ini-production / php.ini-development лежать у $PHP_INI_DIR; власні налаштування - окремим файлом у $PHP_INI_DIR/conf.d/.

Пастка: інструменти збирання (*-dev пакети, компілятори) лишаються в образі - їх прибирають у тому самому RUN чи через multi-stage build.

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

Файлова система контейнера - тимчасова: усе, що записано всередину, зникає разом з контейнером (docker rm). Для даних, що мають жити довше, є два основні способи:

Том (volume) - сховище, яким керує Docker (зазвичай у /var/lib/docker/volumes). Живе незалежно від контейнерів.

services:
  db:
    image: postgres:17
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Bind mount - каталог хоста, змонтований у контейнер напряму. Зміни видно з обох боків одразу.

services:
  app:
    volumes:
      - ./:/var/www       # код з машини розробника

Коли що:

  • Томи - для даних сервісів: бази, Redis, завантажені файли. Портабельні, не залежать від структури каталогів хоста, з ними простіше робити бекапи. На macOS і Windows значно швидші за bind mount.
  • Bind mount - у розробці: правите код в IDE, контейнер одразу бачить зміни. На проді - лише для конфігурацій, якщо взагалі.

Пастки:

  • docker compose down -v видаляє томи разом з даними бази. Без -v томи лишаються.
  • Права доступу в bind mount: файли, створені в контейнері від root, на хості теж належать root.
  • Анонімні томи (без імені) легко загубити й накопичити - docker volume prune прибирає невикористані.

На проді для коду ні том, ні bind mount не потрібні: код запікають в образ, а новий реліз - це новий образ.

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

Compose створює для проєкту окрему мережу, і всі сервіси в ній доступні один одному за назвою сервісу - вбудований DNS Docker перетворює її на IP контейнера.

services:
  app:
    build: .
    environment:
      DB_HOST: db          # не localhost і не IP
      REDIS_HOST: redis
  db:
    image: postgres:17
  redis:
    image: redis:7

Типова помилка новачка - localhost. У контейнері app адреса localhost - це сам контейнер app, а не база. Бази там немає, тож DB_HOST=localhost дає «connection refused». Потрібно DB_HOST=db.

Порти: внутрішні й опубліковані.

  • Усередині мережі сервіси звертаються один до одного за внутрішнім портом контейнера: db:5432.
  • ports: ["5433:5432"] публікує порт на хост - щоб підключитися з машини розробника чи з інтернету. Для зв'язку між контейнерами публікувати не потрібно.
db:
  ports:
    - "127.0.0.1:5433:5432"   # доступно лише з цього комп'ютера

Безпека: ports: ["5432:5432"] на сервері відкриває базу на всі інтерфейси - і Docker при цьому обходить правила ufw, бо сам керує iptables. Базу на проді або не публікують узагалі, або прив'язують до 127.0.0.1.

Кілька мереж дозволяють ізолювати сервіси: фронтенд-проксі бачить застосунок, але не базу.

Докладніше в документації: Мережа в Compose

docker run створює контейнер з образу й запускає його.

docker run -d --name redis --restart unless-stopped -p 127.0.0.1:6379:6379 -v redis-data:/data redis:8-alpine

Основні прапорці:

Прапорець Що робить
-d у фоні (detached); без нього - вивід у терміналі
--name redis ім'я замість випадкового (hungry_turing)
-p 8080:80 опублікувати порт контейнера 80 на порту хоста 8080
-v назва:/шлях іменований том; -v ./src:/app - каталог хоста (bind mount)
-e KEY=value змінна оточення; --env-file .env - з файлу
--rm видалити контейнер після завершення (для разових команд)
-it інтерактивний режим з терміналом (для bash, tinker)
--restart політика перезапуску: no, on-failure, always, unless-stopped
--network приєднати до мережі
-w /app робочий каталог
--memory 512m, --cpus 1 ліміти ресурсів
--user 1000:1000 від імені якого користувача запустити процес

Команда після образу замінює CMD образу:

docker run --rm -it php:8.5-cli php -v
docker run --rm -v "$PWD":/app -w /app composer:2 composer install

Другий приклад - класичний спосіб запустити інструмент без встановлення на хост: контейнер існує лише на час команди.

Що варто пам'ятати:

  • без --rm зупинені контейнери накопичуються (docker ps -a) разом зі своїми записуваними шарами;
  • порядок має значення: прапорці Docker - до назви образу, аргументи для процесу - після. docker run nginx -p 80:80 передасть -p 80:80 самому nginx;
  • -p 8080:80 публікує порт на всіх інтерфейсах хоста - для служб, які не мають бути доступні ззовні, - -p 127.0.0.1:8080:80;
  • -v з відносним шляхом без ./ Docker вважає назвою тому, а не каталогом;
  • --restart always перезапускає навіть контейнер, зупинений вручну, після перезапуску Docker; unless-stopped - поважає ручну зупинку.

Для кількох пов'язаних контейнерів довгі команди docker run швидко стають незручними - їх описують у compose.yaml.

Докладніше в документації: docker container run

Стани контейнера:

created → running → (paused) → exited → (removed)
               ↑______restart______|
  • created - створений (docker create), але не запущений;
  • running - головний процес працює;
  • paused - процеси заморожено (docker pause), пам'ять зберігається;
  • exited - головний процес завершився; файлова система контейнера й логи лишаються, доки контейнер не видалено;
  • removed - docker rm: контейнер і його записуваний шар знищено (томи - ні).

Головне правило: контейнер живе, поки працює його головний процес (PID 1). Процес завершився - контейнер зупинився. Тому контейнер з CMD ["php", "artisan", "migrate"] зупиняється одразу після міграцій - це нормально.

Команди:

docker stop app      # SIGTERM, а через 10 с - SIGKILL
docker kill app      # одразу SIGKILL (чи інший сигнал: --signal)
docker restart app
docker ps -a         # усі контейнери, включно з exited, і їхні коди завершення

Коди завершення - діагностика:

Код Значення
0 процес завершився нормально
1 помилка застосунку (виняток, невдала команда)
126 / 127 команду неможливо виконати / не знайдено (помилка в CMD/ENTRYPOINT)
137 128 + 9 (SIGKILL) - процес убито примусово
143 128 + 15 (SIGTERM) - процес завершився на запит зупинки

Код 137 - найчастіше:

  • OOM-killer: контейнер перевищив ліміт пам'яті. Перевірка: docker inspect app --format '{{.State.OOMKilled}}' - true;
  • docker stop не дочекався: процес не обробив SIGTERM за 10 секунд і отримав SIGKILL (проблема з PID 1 чи довге завершення).

Код 143 при docker stop - очікуваний: процес коректно відреагував на SIGTERM.

Політики перезапуску (--restart) визначають, що відбувається після завершення: no, on-failure[:N] (лише при ненульовому коді), always, unless-stopped. Контейнер, що падає одразу після старту, з always потрапляє в цикл перезапусків з наростаючою затримкою - видно в docker ps як Restarting (1) ....

Перша дія при падінні: docker logs app (логи зупиненого контейнера доступні, доки його не видалено) і docker inspect для коду й причини.

Докладніше в документації: Docker: автоматичний запуск контейнерів

Принцип «конфігурація в оточенні» (з методології Twelve-Factor App): той самий образ запускається в різних середовищах, а відмінності (адреси баз, ключі, режими) передаються змінними оточення.

Способи передачі:

docker run -e APP_ENV=production -e DB_HOST=db myapp       # окремі змінні
docker run -e DB_PASSWORD myapp                            # значення з оточення хоста
docker run --env-file ./production.env myapp               # з файлу KEY=value
# compose.yaml
services:
  app:
    image: myapp
    environment:
      APP_ENV: production
      DB_HOST: db
    env_file:
      - .env.production

Пріоритет (від нижчого до вищого):

  1. ENV в Dockerfile - значення за замовчуванням в образі;
  2. env_file / --env-file;
  3. environment / -e - перекривають попередні.

ENV в Dockerfile доречний для незмінних налаштувань образу (PHP_INI_DIR, шляхи, COMPOSER_ALLOW_SUPERUSER), але не для секретів чи значень, що відрізняються між середовищами: усе з ENV видно в docker image inspect і в кожному контейнері з цього образу.

Для Laravel:

  • Laravel читає змінні оточення процесу через env() - файл .env у контейнері не обов'язковий, якщо змінні передано Docker;
  • php artisan config:cache «заморожує» значення на момент виконання команди. Якщо кеш зроблено під час збирання образу, змінні оточення, передані при запуску, ігноруватимуться. Кешувати конфігурацію треба при старті контейнера, коли змінні вже доступні;
  • поза файлами конфігурації env() не працює після config:cache - у коді лише config().

Безпека:

  • змінні оточення видно в docker inspect, у /proc/<pid>/environ, вони потрапляють у звіти про помилки й дочірні процеси;
  • для секретів кращі файли секретів (Docker/Compose secrets монтуються в /run/secrets/...) або менеджер секретів;
  • .env з секретами - не в образ (.dockerignore) і не в Git.

Перевірка того, що бачить контейнер: docker exec app env чи docker compose config - підсумкова конфігурація Compose з підставленими значеннями.

Докладніше в документації: docker run: змінні оточення

Laravel-застосунок - це не лише вебсервер. У продакшені працює кілька процесів, і в Docker кожен зазвичай отримує свій контейнер:

services:
  app:        # вебзапити: PHP-FPM (+ Nginx) або FrankenPHP
    image: myapp:1.4.0
  queue:      # воркер черги
    image: myapp:1.4.0
    command: php artisan queue:work --tries=3 --max-jobs=1000
  scheduler:  # планувальник задач
    image: myapp:1.4.0
    command: php artisan schedule:work
  db:
    image: postgres:18
  redis:
    image: redis:8-alpine

Ролі контейнерів:

Контейнер Що робить
вебсервер + PHP обробляє HTTP-запити. Класична пара - Nginx і PHP-FPM (два контейнери чи один), сучасна - FrankenPHP чи Octane
воркер черги виконує задачі з черги: листи, обробку файлів, сповіщення
планувальник щохвилини запускає schedule:run (чи працює постійно через schedule:work)
база даних PostgreSQL чи MySQL з томом для даних
Redis кеш, сесії, черги, блокування
опційно Horizon замість простого воркера, Reverb для вебсокетів, Meilisearch для пошуку

Ключова ідея - один образ, різні команди. Веб, воркер і планувальник використовують той самий образ застосунку з тим самим кодом і залежностями - відрізняється лише команда запуску. Це гарантує, що воркер виконує задачі тим самим кодом, що й веб, який їх поставив.

Що зазвичай не в контейнерах у продакшені:

  • база даних часто винесена в керований сервіс (RDS, Cloud SQL) - бекапи, реплікація й оновлення стають чужою турботою;
  • файли користувачів - в об'єктному сховищі (S3, R2), а не на диску контейнера.

Локально все це збирає Laravel Sail чи власний compose.yaml, а в продакшені - Compose на одному сервері, Docker Swarm, Kubernetes чи платформи на кшталт Laravel Cloud, Dokploy, Coolify.

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

Laravel пише у два каталоги:

  • storage/ - логи, скомпільовані шаблони Blade, файловий кеш, сесії, завантаження на диск local;
  • bootstrap/cache/ - кеш конфігурації, маршрутів, подій, packages.php, services.php.

Типова помилка:

The stream or file "/var/www/html/storage/logs/laravel.log" could not be opened in append mode: Failed to open stream: Permission denied

Причина: процес PHP працює від одного користувача (www-data, UID 33 в офіційних образах Debian чи 82 в Alpine), а файли належать іншому - найчастіше root, бо COPY у Dockerfile за замовчуванням створює файли власника root.

Виправлення в Dockerfile:

COPY --chown=www-data:www-data . /var/www/html

# або для вже скопійованих файлів - лише каталоги для запису
RUN chown -R www-data:www-data storage bootstrap/cache \
 && chmod -R ug+rwX storage bootstrap/cache

USER www-data

--chown при COPY кращий за окремий RUN chown -R: окремий chown створює ще один шар з копією всіх файлів і збільшує образ.

Чого не робити:

  • chmod -R 777 storage - «працює», але дає запис усім і маскує справжню проблему;
  • запускати PHP від root, щоб «не було проблем з правами» - якщо зловмисник виконає код, він отримає root у контейнері.

Типові пастки:

  • bind mount у розробці (./:/var/www/html) - файли мають власника з хоста (UID 501 на macOS, 1000 на Linux). На Linux процес у контейнері з іншим UID не може писати. Рішення - запускати PHP з UID розробника (Sail робить це через WWWUSER);
  • том для storage створюється при першому запуску з правами root - потрібно задати власника в образі до оголошення тому чи при старті;
  • файли, створені php artisan від root через docker exec (наприклад, кеш конфігурації чи лог) потім недоступні для www-data. Виконувати команди від того самого користувача: docker exec -u www-data app php artisan ....

Докладніше в документації: Laravel: права на каталоги

Laravel читає значення через env() у файлах config/*.php. Джерела:

  1. змінні оточення процесу - те, що передав Docker (-e, environment, env_file у Compose, секрети оркестратора);
  2. файл .env - його завантажує бібліотека phpdotenv при старті.

Пріоритет: справжні змінні оточення важливіші за .env - phpdotenv не перезаписує вже встановлені змінні. Тому в контейнері можна взагалі не мати .env: усі значення приходять від Docker.

Чому .env не варто класти в образ:

  • образ стає прив'язаним до одного середовища - для staging і продакшену потрібні різні образи;
  • секрети (APP_KEY, паролі бази, ключі API) потрапляють у шари образу й у реєстр - їх видно будь-кому з доступом до образу;
  • .env має бути в .dockerignore.

Пастка config:cache:

php artisan config:cache зберігає всю конфігурацію з уже підставленими значеннями в bootstrap/cache/config.php. Після цього .env і змінні оточення більше не читаються.

  • якщо зробити config:cache під час збирання образу, туди потраплять значення з середовища збирання (чи порожні) - а змінні, передані при запуску, буде проігноровано;
  • тому кешувати конфігурацію треба при старті контейнера, коли оточення вже відоме:
#!/bin/sh
# entrypoint.sh
php artisan config:cache
php artisan route:cache
php artisan view:cache
exec "$@"

Ще один наслідок: після config:cache функція env() поза файлами config/ повертає null. У коді застосунку - лише config('services.stripe.key'), а не env('STRIPE_KEY').

Перевірка в контейнері:

docker exec app php artisan about       # середовище, драйвери, чи закешовано конфігурацію
docker exec app php artisan config:show database.default

Докладніше в документації: Laravel: кешування конфігурації

У розробці npm run dev запускає dev-сервер Vite (порт 5173) і створює файл public/hot з його адресою. Директива @vite бачить цей файл і підключає скрипти не зі зібраних файлів, а з dev-сервера, - звідси гаряче перезавантаження (HMR).

Чому в Docker це часто не працює з коробки:

  • Vite за замовчуванням слухає лише localhost усередині контейнера - з браузера на хості до нього не достукатися;
  • порт 5173 не опубліковано;
  • у public/hot записується адреса, яку бачить контейнер, а браузер має звертатися до хоста.

Налаштування для контейнера:

// vite.config.js
export default defineConfig({
    plugins: [laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true })],
    server: {
        host: '0.0.0.0',          // слухати всі інтерфейси контейнера
        port: 5173,
        strictPort: true,
        hmr: { host: 'localhost' }, // адреса, яку бачить браузер
    },
});
# compose.yaml
services:
  app:
    ports:
      - "127.0.0.1:5173:5173"

Laravel Sail робить саме це: публікує VITE_PORT і запускає sail npm run dev всередині контейнера.

Типові симптоми:

  • сторінка без стилів і помилки ERR_CONNECTION_REFUSED до :5173 - порт не опубліковано чи Vite слухає лише localhost;
  • стилі є, але зміни не підхоплюються - файлова система через bind mount не надсилає подій про зміни (буває на Windows і macOS), допомагає server.watch.usePolling: true;
  • у продакшені запити до :5173 - у образ потрапив залишений файл public/hot. Його треба додати в .dockerignore.

Продакшен - зібрані файли, без Node в образі:

FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM php:8.5-fpm-alpine
COPY --from=assets /app/public/build /var/www/html/public/build

npm run build створює public/build з маніфестом і хешованими іменами файлів, а @vite читає маніфест. Окрема стадія збирання означає, що Node, node_modules і вихідні JS-файли не потрапляють у фінальний образ.

Помилка «Unable to locate file in Vite manifest» у контейнері - файли фронтенду не зібрано або не скопійовано в образ (чи public/build перекрито томом).

Докладніше в документації: Laravel: запуск Vite

Воркер - окремий контейнер з тим самим образом, але іншою командою:

services:
  queue:
    image: myapp:1.4.0
    command: php artisan queue:work redis --queue=high,default --tries=3 --max-jobs=1000 --max-time=3600
    restart: unless-stopped
    stop_grace_period: 60s

Чому окремий контейнер:

  • масштабується незалежно: docker compose up -d --scale queue=4;
  • важка задача не забирає ресурси у вебзапитів;
  • падіння воркера не впливає на сайт, а Docker перезапускає його сам (restart), тож Supervisor усередині не потрібен.

Чому queue:work треба перезапускати після деплою:

queue:work - довгоживучий процес: він завантажує застосунок один раз і тримає код у пам'яті. Після оновлення файлів старий воркер продовжує виконувати старий код.

  • на звичайному сервері php artisan queue:restart ставить мітку в кеш, і воркери завершуються після поточної задачі (а Supervisor запускає їх заново);
  • у Docker код у контейнері не змінюється - деплой нової версії означає новий образ і нові контейнери. Старі контейнери зупиняються, нові стартують з новим кодом. queue:restart у такому разі не обов'язковий, але корисний, якщо воркери живуть довше за деплой.

Коректна зупинка:

  • docker stop надсилає SIGTERM - воркер Laravel завершує поточну задачу і виходить;
  • через stop_grace_period (за замовчуванням 10 секунд) Docker надсилає SIGKILL - задача обривається посередині. Цей час має бути більшим за найдовшу задачу;
  • щоб сигнал дійшов, PHP має бути PID 1 (exec у скрипті запуску чи exec-форма command).

Від витоків пам'яті: --max-jobs і --max-time змушують воркер періодично завершуватися, а Docker його перезапускає з чистою пам'яттю. Для --memory враховуйте ліміт пам'яті контейнера.

Horizon - замість кількох queue:work один контейнер з php artisan horizon, який сам керує процесами за конфігурацією й балансує їх між чергами. Після деплою - horizon:terminate чи заміна контейнера.

Докладніше в документації: Laravel: запуск воркера черги

Питання рівня Junior з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.

Інші рівні
Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 7 відкритих вакансій рівня Junior. Переглянути вакансії