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 для каталогів запису).
«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 stop надсилає головному процесу контейнера (PID 1) сигнал SIGTERM і чекає (за замовчуванням 10 секунд). Якщо процес не завершився - надсилає SIGKILL, який не можна перехопити: усе обривається на півдорозі.
Чому SIGTERM не доходить до застосунку:
- Shell-форма команди.
CMD php artisan queue:workзапускається як/bin/sh -c "...". PID 1 - оболонка, аshне передає сигнали дочірнім процесам. Воркер не дізнається, що треба зупинитися. - Скрипт-обгортка без
exec.entrypoint.sh, що просто викликаєphp-fpmв кінці, лишається PID 1, а FPM - його дочірнім процесом. - 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.
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), бо логи - для розслідування, а не для сповіщень.
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 - один основний процес на контейнер, а різні відповідальності - окремі контейнери, що взаємодіють через мережу й томи:
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- часто достатньо без входу в контейнер.
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 протягом тривалого часу.
У контейнері з 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();'
Деплой без простою в контейнерах - це rolling update: нові контейнери стартують, проходять healthcheck, отримують трафік, а старі зупиняються. Певний час стара й нова версії працюють одночасно з однією базою.
Звідси головне правило: схема бази має бути сумісною з обома версіями коду.
Небезпечні зміни, якщо робити їх за один крок:
- перейменування колонки: нова версія пише в
full_name, стара ще читаєname- помилки до завершення деплою; - видалення колонки: стара версія, що ще працює, звертається до неї;
NOT NULLдля нової колонки без значення за замовчуванням: стара версія не заповнює її при вставці;- зміна типу колонки з довгою перебудовою таблиці - блокування й таймаути.
Розширення й звуження (expand/contract, parallel change):
Перейменування name → full_name в три деплої:
- розширення: міграція додає
full_name(nullable); код пише в обидві колонки й читає зі старої; - перенесення: задача в черзі заповнює
full_nameдля старих рядків порціями; код читає з нової; - звуження: коли жодна версія не використовує
name- міграція видаляє її.
Порядок кроків деплою:
- зібрати образ, прогнати тести;
- виконати міграції разовим контейнером (
migrate --force --isolated) - схема стає «ширшою», стара версія продовжує працювати; - оновити контейнери поступово, з healthcheck;
- воркери черги - теж нова версія; задачі, поставлені старою версією, мають розбиратися новою (не змінювати сигнатуру конструктора job без сумісності);
- наступним деплоєм - очищувальна міграція.
Деталі, про які забувають:
- серіалізовані job у черзі: Laravel серіалізує об'єкт задачі; перейменований клас чи властивість - і задачі зі старого деплою падають у новому воркері;
- кеш: нова версія може читати з кешу дані у форматі старої - версіонуйте ключі чи очищуйте кеш;
- сесії й CSRF мають переживати деплой - Redis/база, однаковий
APP_KEY; - assets: користувач зі сторінкою старої версії підвантажує старі JS/CSS - вони мають бути доступні (CDN, хешовані імена);
- довгі міграції на великих таблицях - окремо від деплою, з
lock_timeout,CREATE INDEX CONCURRENTLYу PostgreSQL; - відкат: з розширювальними міграціями відкотити код можна без відкату схеми - це й робить їх безпечними.
Коли застосунок масштабується горизонтально (кілька реплік на одному чи кількох серверах), з'являється проблема дублювання: те, що має відбутися один раз, відбувається в кожній репліці.
Планувальник:
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 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії