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

Питання на співбесіді: Compose, мережі й томи

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

15 питань

Файлова система контейнера - тимчасова: усе, що записано всередину, зникає разом з контейнером (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: змінні оточення

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) потрібен і проксі, і оркестратору - щоб не відправляти трафік у контейнер, що ще не готовий.

Докладніше в документації: Порядок запуску в Compose

За замовчуванням контейнер може використати всю пам'ять і 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. Тоді коду застосунку взагалі не потрібен запис на диск.

Докладніше в документації: Docker: tmpfs-монтування

Контейнер у циклі перезапусків (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 container stats

Спокуса знайома: зайти в контейнер (docker exec -it app bash), встановити пакет, виправити конфіг - «і все запрацювало». А потім зберегти результат як образ:

docker commit app myapp:fixed

Чому це погана практика:

  • зміни зникнуть: записуваний шар контейнера знищується разом з контейнером. Наступний деплой, перезапуск оркестратором, масштабування - і ручні виправлення втрачено;
  • невідтворюваність: образ з docker commit - «чорна скринька». Ніхто не знає, які саме команди виконано, в якому порядку, з якими версіями. Повторити збирання неможливо;
  • немає історії в Git: зміни не проходять рев'ю, не прив'язані до коміту, їх не відкотити;
  • сміття в образі: кеш менеджерів пакетів, логи, тимчасові файли, історія команд - усе, що було в контейнері, потрапляє в образ;
  • секрети: якщо в контейнері були токени чи ключі - вони тепер в образі.

Принцип незмінної інфраструктури: контейнери не «лагодять», а замінюють. Виправлення - у Dockerfile чи конфігурації, новий образ, новий деплой.

Як правильно розслідувати й виправляти:

  1. зайти в контейнер, щоб з'ясувати проблему (це нормально);
  2. перенести виправлення в Dockerfile, конфіг у репозиторії чи змінні оточення;
  3. зібрати новий образ і задеплоїти.

Що бачити, що змінено в контейнері:

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) - ручні зміни просто неможливі, а помилки конфігурації виявляються одразу.

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

docker stop надсилає головному процесу контейнера (PID 1) сигнал SIGTERM і чекає (за замовчуванням 10 секунд). Якщо процес не завершився - надсилає SIGKILL, який не можна перехопити: усе обривається на півдорозі.

Чому SIGTERM не доходить до застосунку:

  1. Shell-форма команди. CMD php artisan queue:work запускається як /bin/sh -c "...". PID 1 - оболонка, а sh не передає сигнали дочірнім процесам. Воркер не дізнається, що треба зупинитися.
  2. Скрипт-обгортка без exec. entrypoint.sh, що просто викликає php-fpm в кінці, лишається PID 1, а FPM - його дочірнім процесом.
  3. PID 1 особливий у ядрі: для нього сигнали без явно встановленого обробника ігноруються. Процес, що не обробляє SIGTERM, ставши PID 1, не завершиться від нього.

Рішення:

CMD ["php", "artisan", "queue:work"]   # exec-форма: процес - PID 1
#!/bin/sh
# entrypoint.sh
php artisan config:cache
exec "$@"            # замінити оболонку командою, а не запустити дочірній процес
  • --init (init: true у Compose) додає крихітний init-процес (tini) як PID 1: він пересилає сигнали й прибирає процеси-зомбі.
  • stop_grace_period - більше часу, якщо коректне завершення справді довге.

Навіщо це на практиці: воркер черги, що отримав SIGTERM, доробляє поточне завдання й завершується. Убитий SIGKILL - обриває його посередині: напівзапис у базу, повторне виконання після рестарту. Тому --timeout воркера, stop_grace_period і ліміти оркестратора налаштовують узгоджено. Те саме для PHP-FPM (SIGQUIT - м'яке завершення) і Octane.

Докладніше в документації: Shell- і exec-форма в Dockerfile

Docker збирає все, що процес пише в stdout і stderr, і передає драйверу логування. Звідти логи бачить docker logs, їх забирають збирачі (Loki, Fluent Bit, Vector, CloudWatch) і оркестратори.

Чому не файли всередині контейнера:

  • файл у записуваному шарі зникає разом з контейнером - саме тоді, коли логи потрібні, щоб зрозуміти, чому він упав;
  • файл росте без обмежень і з'їдає диск;
  • кожен сервіс пише в свій шлях, і збирати логи доводиться з десятка місць.

Laravel: канал stderr замість single/daily:

LOG_CHANNEL=stderr

Для PHP-FPM ще потрібно, щоб помилки воркерів потрапляли в stderr (catch_workers_output = yes, error_log = /proc/self/fd/2).

Структуровані логи. JSON-рядки замість тексту дозволяють збирачу індексувати поля: рівень, ID запиту, користувача, тривалість. Тоді пошук «усі помилки запиту X» - це запит, а не grep.

Ротація на хості. Драйвер за замовчуванням json-file не обмежує розмір, і логи балакучого контейнера можуть заповнити диск сервера. Налаштовують:

{
  "log-driver": "local",
  "log-opts": { "max-size": "20m", "max-file": "5" }
}

(у /etc/docker/daemon.json для всіх контейнерів або logging: у Compose для окремого сервісу).

Що ще варто: не логувати секрети й персональні дані; додавати ID запиту в усі записи, щоб пов'язати лог застосунку, проксі й воркера черги; мати окремий моніторинг помилок (Sentry), бо логи - для розслідування, а не для сповіщень.

Докладніше в документації: Логування в Docker

localhost усередині контейнера - це сам контейнер, а не хост. Тому з контейнера застосунку DB_HOST=localhost не знайде базу, що працює на хості.

Як звернутися до хоста:

Docker Desktop (macOS, Windows): спеціальне ім'я host.docker.internal вказує на хост:

DB_HOST=host.docker.internal

Linux: цього імені за замовчуванням немає, але його можна додати:

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"

host-gateway Docker підставляє як адресу хоста в мережі контейнерів. Laravel Sail додає це налаштування саме для Xdebug, якому треба підключатися до IDE на хості.

Сервіс на хості має слухати не лише 127.0.0.1, а інтерфейс, доступний з мережі Docker, - інакше з'єднання буде відхилено.

Мережа host (--network host) - контейнер використовує мережевий стек хоста напряму: localhost - це хост, порти не потрібно публікувати. На Linux це просто й без накладних витрат; у Docker Desktop мережа host працює інакше (вона відноситься до віртуальної машини, а підтримка для хоста macOS/Windows з'явилася в нових версіях і має обмеження).

Чим Docker Desktop відрізняється від Linux:

  • контейнери працюють у віртуальній машині з Linux - мережі Docker живуть у ній, а не на хості;
  • IP-адреси контейнерів недоступні з хоста напряму - лише через опубліковані порти;
  • опубліковані порти прокидаються з віртуальної машини на хост автоматично;
  • VPN і корпоративні проксі можуть впливати на мережу VM - Docker Desktop має окремі налаштування проксі.

На Linux:

  • IP-адреси контейнерів (172.17.x.x) доступні з хоста напряму;
  • опубліковані порти Docker відкриває правилами iptables, що можуть обходити ufw - порт бази, опублікований на 0.0.0.0, стає доступним з інтернету, навіть якщо ufw його «закриває».

Практичні правила:

  • сервіси між собою - через мережу Docker за назвою сервісу (DB_HOST=db), а не через хост;
  • до хоста - через host.docker.internal з host-gateway для Linux;
  • не покладатися на IP-адреси контейнерів - вони змінюються при перестворенні;
  • конфігурацію, що «працює на Mac», перевіряти на Linux (CI, сервер) - мережева поведінка відрізняється.

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

Рекомендація Docker - один основний процес на контейнер, а різні відповідальності - окремі контейнери, що взаємодіють через мережу й томи:

services:
  web:      # Nginx
  app:      # PHP-FPM
  queue:    # php artisan queue:work
  scheduler: # php artisan schedule:work

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

  • незалежне масштабування: воркерів черги може бути п'ять, а вебконтейнерів два;
  • окремі ресурси й ліміти: воркер, що обробляє відео, не з'їсть пам'ять вебзапитів;
  • ізольовані перезапуски й деплой: падіння воркера не вбиває веб;
  • логи й стан кожного процесу окремо, зрозумілий docker ps і healthcheck;
  • PID 1 - сам процес, тож сигнали зупинки доходять напряму.

Той самий образ - різні команди. Не потрібно чотири образи: один образ застосунку, а контейнери відрізняються command:

x-app: &app
  image: myapp:${TAG}
  env_file: .env

services:
  app:   { <<: *app }
  queue: { <<: *app, command: php artisan queue:work --max-jobs=1000 }
  scheduler: { <<: *app, command: php artisan schedule:work }

Коли кілька процесів в одному контейнері виправдані:

  • тісно пов'язані процеси, що мають жити й масштабуватися разом: Nginx + PHP-FPM у одному «вебконтейнері» - поширений і прийнятний компроміс;
  • середовища, що дають лише один контейнер (деякі PaaS);
  • сайдкар-процеси, без яких основний не працює.

Як робити це правильно:

  • менеджер процесів у PID 1 - supervisord, s6-overlay, tini + скрипт. Він запускає процеси, перезапускає впалі й передає сигнали зупинки всім;
  • вивід усіх процесів у stdout/stderr контейнера;
  • визначитися, що робити при падінні одного процесу: перезапустити лише його чи завершити весь контейнер (щоб оркестратор побачив проблему). Тихий перезапуск ховає помилки.

Сучасна альтернатива для Laravel: FrankenPHP поєднує веб-сервер і PHP в одному процесі - зв'язка Nginx + PHP-FPM стає непотрібною, і «вебконтейнер» знову має один процес.

Антипатерн: запускати в одному контейнері ще й базу чи Redis «для простоти» - дані, оновлення й масштабування бази стають залежними від деплою застосунку.

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

Мінімальні образи (distroless, scratch, «скелетні» Alpine) - добра практика безпеки: менше пакетів - менше вразливостей і можливостей для зловмисника. Але в них немає sh, curl, ps, netstat - docker exec -it app sh не працює.

docker debug (входить у Docker Desktop і передплати Docker) підключає до запущеного контейнера окрему оболонку з набором інструментів, не змінюючи образ:

docker debug app
  • бачить файлову систему й процеси контейнера;
  • інструменти (vim, curl, htop, nslookup...) беруться з окремого образу інструментів і не потрапляють у контейнер застосунку;
  • працює й для зупинених контейнерів і образів (docker debug myapp:latest).

Способи без docker debug:

1. Контейнер-сусід у тих самих просторах імен:

docker run --rm -it \
  --network container:app \
  --pid container:app \
  nicolaka/netshoot

Інструментальний контейнер бачить мережу й процеси контейнера app: ss -tlnp, curl localhost:8080, tcpdump, ps aux. Сам контейнер застосунку не змінюється.

2. Доступ до файлової системи:

docker cp app:/var/www/html/storage/logs/laravel.log ./
docker export app | tar -t | grep config   # вміст файлової системи контейнера

3. nsenter з хоста (Linux, потрібні права root) - увійти в простори імен процесу контейнера й використовувати інструменти хоста.

4. Окрема налагоджувальна стадія в Dockerfile:

FROM gcr.io/distroless/... AS production
FROM production AS debug
COPY --from=busybox:musl /bin/busybox /busybox/

Для локального розслідування збирається --target debug, у продакшен іде production.

У Kubernetes аналог - тимчасові контейнери (kubectl debug), що підключаються до поди.

Що важливо:

  • налагодження в продакшені - контрольована операція: доступ до хоста й Docker - це фактично root, тож дії мають логуватися й обмежуватися;
  • не встановлювати інструменти в контейнер застосунку «на хвилинку» - контейнер змінено, і результат розслідування може бути спотворено;
  • спершу зовнішні сигнали: логи (docker logs), метрики, docker inspect - часто достатньо без входу в контейнер.

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