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

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

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

30 питань

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 для каталогів запису).

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

«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.

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

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

Octane запускає Laravel у довгоживучих процесах (FrankenPHP, Swoole, RoadRunner): застосунок завантажується один раз, а потім обробляє тисячі запитів. Бутстрап фреймворку зникає з кожного запиту - час відповіді падає в рази.

FROM dunglas/frankenphp:1-php8.5
# ...
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]

Що змінюється для контейнера:

1. Пам'ять:

  • кожен воркер тримає завантажений застосунок - сотні МБ на кілька воркерів;
  • --workers (за замовчуванням - за кількістю ядер) треба узгоджувати з лімітом пам'яті контейнера, інакше OOM-killer з кодом 137;
  • кількість ядер, яку бачить PHP, - це ядра хоста, а не ліміт --cpus контейнера. Явно задавайте --workers.

2. Витоки пам'яті й стан:

  • пам'ять, що «протікає» за запит, накопичується;
  • --max-requests - воркер перезапускається після N запитів і звільняє пам'ять;
  • стан між запитами: статичні властивості, синглтони, що зберігають запит чи користувача, масиви-кеші в сервісах - переживають запит. Дані одного користувача можуть потрапити до іншого;
  • в Octane застосунок клонує контейнер на кожен запит, але синглтони, створені при старті, спільні - не інжектуйте Request, Auth чи конфігурацію в конструктор синглтона.

3. Деплой:

  • новий код - новий образ і новий контейнер, тож octane:reload для продакшену в Docker зазвичай не потрібен;
  • --watch - лише для розробки (потребує Node і chokidar), у продакшен-образ не потрапляє;
  • коректна зупинка: SIGTERM до Octane, stop_grace_period більший за найдовший запит.

4. Конфігурація:

  • config:cache при старті до запуску Octane - конфігурація завантажується один раз і далі не перечитується;
  • зміна змінних оточення вимагає перезапуску контейнера.

5. З'єднання:

  • з'єднання з базою й Redis живуть між запитами - після перезапуску бази перший запит може отримати «server has gone away»; Laravel перепідключається, але варто перевірити поведінку;
  • кількість з'єднань з базою = кількість воркерів × реплік - узгоджуйте з max_connections чи пулером.

Що перевірити перед переходом: тести під Octane, пакети на сумісність, навантажувальний тест з контролем пам'яті в docker stats протягом тривалого часу.

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

У контейнері з PHP кілька незалежних годинників і налаштувань часового поясу:

Рівень Де задається На що впливає
ОС контейнера TZ, /etc/localtime, пакет tzdata date у shell, cron, логи системних утиліт
PHP date.timezone у php.ini date(), new DateTime() без явного поясу
Laravel config/app.php → timezone now(), Carbon, Eloquent-дати, планувальник
база даних налаштування сервера, сесії NOW(), CURRENT_TIMESTAMP, timestamptz

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

  • офіційні PHP-образи за замовчуванням - UTC, і date.timezone не задано;
  • Alpine без tzdata взагалі не знає назв поясів - TZ=Europe/Kyiv мовчки ігнорується;
  • TZ не впливає на PHP напряму: PHP бере пояс з date.timezone, а якщо його немає - з власного значення за замовчуванням (UTC). Деякі сервери (зокрема вбудований у FrankenPHP) не підхоплюють TZ для PHP-дат;
  • Laravel перевизначає PHP: при старті він викликає date_default_timezone_set(config('app.timezone')), тож у застосунку діє саме конфігурація Laravel, а cron-задачі ОС чи сторонні скрипти - живуть за іншими правилами.

Узгоджене налаштування:

RUN apk add --no-cache tzdata        # для Alpine
ENV TZ=Europe/Kyiv
RUN echo "date.timezone=\${TZ}" > "$PHP_INI_DIR/conf.d/timezone.ini"
// config/app.php
'timezone' => env('APP_TIMEZONE', 'UTC'),

Яку політику обрати:

  • зберігати в базі UTC (або timestamptz у PostgreSQL) - класична рекомендація: без двозначностей при переході на літній час, без проблем з користувачами з різних поясів;
  • показувати в локальному поясі користувача чи сайту на рівні відображення;
  • якщо застосунок свідомо працює в одному місцевому поясі (сайт для однієї країни), - той самий пояс на всіх рівнях: ОС, PHP, Laravel, планувальник. Розбіжність між рівнями - головне джерело зсуву на 2-3 години.

Планувальник: ->dailyAt('09:00') виконується за поясом app.timezone (чи schedule_timezone), а не ОС. Переходи на літній час можуть пропустити чи подвоїти задачі, заплановані на 3:00 ночі.

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

docker exec app date
docker exec app php -r 'echo date_default_timezone_get(), PHP_EOL;'
docker exec app php artisan tinker --execute 'echo now();'

Докладніше в документації: PHP: налаштування date.timezone

Деплой без простою в контейнерах - це rolling update: нові контейнери стартують, проходять healthcheck, отримують трафік, а старі зупиняються. Певний час стара й нова версії працюють одночасно з однією базою.

Звідси головне правило: схема бази має бути сумісною з обома версіями коду.

Небезпечні зміни, якщо робити їх за один крок:

  • перейменування колонки: нова версія пише в full_name, стара ще читає name - помилки до завершення деплою;
  • видалення колонки: стара версія, що ще працює, звертається до неї;
  • NOT NULL для нової колонки без значення за замовчуванням: стара версія не заповнює її при вставці;
  • зміна типу колонки з довгою перебудовою таблиці - блокування й таймаути.

Розширення й звуження (expand/contract, parallel change):

Перейменування name → full_name в три деплої:

  1. розширення: міграція додає full_name (nullable); код пише в обидві колонки й читає зі старої;
  2. перенесення: задача в черзі заповнює full_name для старих рядків порціями; код читає з нової;
  3. звуження: коли жодна версія не використовує name - міграція видаляє її.

Порядок кроків деплою:

  1. зібрати образ, прогнати тести;
  2. виконати міграції разовим контейнером (migrate --force --isolated) - схема стає «ширшою», стара версія продовжує працювати;
  3. оновити контейнери поступово, з healthcheck;
  4. воркери черги - теж нова версія; задачі, поставлені старою версією, мають розбиратися новою (не змінювати сигнатуру конструктора job без сумісності);
  5. наступним деплоєм - очищувальна міграція.

Деталі, про які забувають:

  • серіалізовані job у черзі: Laravel серіалізує об'єкт задачі; перейменований клас чи властивість - і задачі зі старого деплою падають у новому воркері;
  • кеш: нова версія може читати з кешу дані у форматі старої - версіонуйте ключі чи очищуйте кеш;
  • сесії й CSRF мають переживати деплой - Redis/база, однаковий APP_KEY;
  • assets: користувач зі сторінкою старої версії підвантажує старі JS/CSS - вони мають бути доступні (CDN, хешовані імена);
  • довгі міграції на великих таблицях - окремо від деплою, з lock_timeout, CREATE INDEX CONCURRENTLY у PostgreSQL;
  • відкат: з розширювальними міграціями відкотити код можна без відкату схеми - це й робить їх безпечними.

Докладніше в документації: Martin Fowler: Parallel Change

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

Планувальник:

1. Окремий сервіс з однією реплікою - найпростіше:

services:
  scheduler:
    image: myapp:1.4.0
    command: php artisan schedule:work
    deploy:
      replicas: 1

Але «рівно одна репліка» не гарантована: під час rolling update старий і новий контейнери можуть жити одночасно, а на кількох вузлах - збій мережі може дати два активні планувальники.

2. onOneServer() - Laravel бере атомарне блокування в спільному кеші перед запуском задачі:

Schedule::command('reports:generate')
    ->dailyAt('02:00')
    ->onOneServer();

Schedule::command('feeds:sync')
    ->everyFiveMinutes()
    ->withoutOverlapping(10)
    ->onOneServer();
  • драйвер кешу має бути спільним і з атомарними блокуваннями: Redis, Memcached, database, DynamoDB. З file чи array у кожного контейнера своє «блокування» - захисту немає;
  • onOneServer захищає від дублювання в межах однієї хвилини, withoutOverlapping - від паралельного запуску, поки попередній ще працює (з терміном життя блокування на випадок падіння).

Разові artisan-команди:

  • не через docker exec у довільну репліку - результат залежить від того, куди потрапили, і команда помре разом з контейнером при деплої;
  • разовий контейнер з тим самим образом і оточенням: docker compose run --rm app php artisan users:reindex, у Kubernetes - Job;
  • довгі операції - розбити на job у черзі: команда лише ставить задачі, а воркери обробляють їх порціями з повторами й видно прогрес;
  • ідемпотентність: команду, яку можуть запустити двічі, робити безпечною для повтору.

Що ще потребує координації:

  • міграції - migrate --isolated;
  • queue:restart, horizon:terminate - сигнал через спільний кеш досягає всіх воркерів, незалежно від того, з якого контейнера його надіслано;
  • кеш-очищення (cache:clear) очищує спільне сховище - один раз для всіх, а не в кожній репліці.

Діагностика дублювання: логувати ім'я хоста (gethostname() дає ID контейнера) у задачах планувальника - видно, скільки разів і де вони виконались.

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

/var/run/docker.sock - сокет, через який клієнти керують демоном Docker. Демон працює від root і виконує будь-які команди API: створює контейнери з будь-якими параметрами.

Хто має доступ до сокета - той має root на хості. Наприклад:

docker run -v /:/host --rm -it alpine chroot /host sh

Новий контейнер монтує кореневу файлову систему хоста, і chroot дає повноцінну оболонку root на хості: читання /etc/shadow, SSH-ключів, зміна системних файлів, встановлення бекдора.

Що це означає на практиці:

1. Монтування сокета в контейнер (-v /var/run/docker.sock:/var/run/docker.sock) - популярне для Traefik, Portainer, агентів моніторингу, CI-раннерів, Watchtower. Якщо такий контейнер зламано (вразливість у вебінтерфейсі, ланцюжку постачання), зловмисник отримує весь хост.

2. Група docker на хості - користувач у ній фактично root без sudo. Додавати в неї варто лише тих, кому довіряєте як адміністраторам.

3. TCP-доступ до демона без TLS (-H tcp://0.0.0.0:2375) - відкритий root для всього інтернету. Такі демони масово знаходять і використовують для криптомайнінгу.

Як зменшити ризик:

  • не монтувати сокет без крайньої потреби; якщо інструмент може працювати інакше (файловий провайдер Traefik замість Docker-провайдера) - обрати інший спосіб;
  • проксі сокета з білим списком дозволених викликів API (наприклад, tecnativa/docker-socket-proxy): Traefik отримує лише читання списку контейнерів, а не створення нових;
  • монтування :ro не допомагає - це лише права на файл сокета, а не на дії через API;
  • rootless Docker - демон працює від звичайного користувача, і доступ до сокета дає права цього користувача, а не root;
  • CI-збирання - замість Docker-in-Docker через сокет хоста використовувати ізольовані раннери чи збирання без демона (BuildKit rootless, Kaniko, Buildah);
  • віддалений доступ до демона - лише через SSH (DOCKER_HOST=ssh://...) чи TLS з клієнтськими сертифікатами.

На співбесіді це питання перевіряє розуміння, що Docker API - привілейований інтерфейс, а не «просто інструмент розробника».

Докладніше в документації: Безпека Docker Engine: поверхня атаки демона

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

Інші рівні
Junior 35 Middle 35

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