Питання на співбесіді з Docker
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
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: поверхня атаки демона
У звичайній конфігурації демон Docker працює від root, і root усередині контейнера - це root ядра (UID 0). Вихід з контейнера через вразливість чи помилку конфігурації дає права root на хості. Два механізми зменшують цей ризик.
1. User namespace remapping (userns-remap) - демон лишається від root, але користувачі контейнера відображаються на непривілейований діапазон UID хоста:
// /etc/docker/daemon.json
{ "userns-remap": "default" }
Root (UID 0) у контейнері насправді є, наприклад, UID 100000 на хості. Якщо процес вирветься з контейнера, на хості він - звичайний користувач без прав.
2. Rootless mode - і демон, і контейнери працюють від звичайного користувача:
- демон не має прав root узагалі - вразливість у самому демоні не дає root на хості;
- доступ до сокета Docker дає лише права цього користувача (а не «фактично root», як у звичайному режимі);
- кожен користувач може мати власний Docker.
Обмеження й компроміси:
- порти нижче 1024 без додаткового налаштування недоступні (
net.ipv4.ip_unprivileged_port_start); - мережа реалізована в просторі користувача - продуктивність нижча, а справжня адреса клієнта може бути недоступна без додаткового налаштування;
- обмеження ресурсів через cgroups потребують cgroup v2 і делегування;
- деякі драйвери сховища й можливості недоступні;
- права на bind mount: файли, створені в контейнері, на хості належать відображеним UID - треба розуміти відображення, щоб не отримати «чужі» файли;
- з
userns-remapдеякі опції (--privileged, спільні простори імен з хостом) не працюють чи потребують вимкнення відображення для конкретного контейнера.
Альтернатива - Podman: працює без демона і за замовчуванням без root; CLI сумісний з Docker.
Коли варто:
- багатокористувацькі сервери й CI, де контейнери запускають люди чи процеси з різним рівнем довіри;
- посилена безпека продакшен-хостів, де можна прийняти обмеження;
- для локальної розробки rootless зазвичай зайвий, а Docker Desktop і так ізолює контейнери у віртуальній машині.
Незалежно від режиму USER в образі (непривілейований користувач усередині контейнера) - базова практика.
--privileged знімає майже всі обмеження контейнера:
- надає всі можливості (capabilities), включно з
SYS_ADMIN; - вимикає профілі seccomp і AppArmor/SELinux;
- дає доступ до всіх пристроїв хоста (
/dev) - дисків, модулів ядра.
Процес у привілейованому контейнері може змонтувати диск хоста й прочитати чи змінити будь-які файли, завантажити модуль ядра - тобто це фактично root на хості. Ізоляція контейнера тоді майже лише формальна.
Коли його застосовують - Docker-in-Docker, деякі системні агенти (керування мережею, сховищем), експерименти. Майже завжди є вужча альтернатива: окремі можливості (--cap-add), конкретні пристрої (--device), зовнішній демон через SSH чи безпечні інструменти збирання.
Seccomp - фільтр системних викликів ядра. Docker за замовчуванням застосовує профіль, що забороняє кілька десятків небезпечних викликів (наприклад, mount, reboot, kexec_load, add_key, створення нових просторів імен у більшості випадків). Більшості застосунків вони не потрібні, а для зловмисника це інструменти виходу з контейнера.
docker run --security-opt seccomp=./custom-profile.json myapp
docker run --security-opt seccomp=unconfined myapp # вимкнути - лише для налагодження
Власний, вужчий профіль (лише ті виклики, що реально використовує застосунок) - ще сильніший захист, але вимагає ретельного тестування.
AppArmor (Ubuntu/Debian) / SELinux (RHEL/Fedora) - мандатний контроль доступу: обмежує, до яких файлів, мереж і можливостей процес має доступ, незалежно від прав користувача. Docker застосовує профіль docker-default для AppArmor; власні профілі задають через --security-opt apparmor=....
Багатошаровий захист - кожен механізм закриває свою частину:
| Механізм | Що обмежує |
|---|---|
| простори імен | що процес бачить |
| cgroups | скільки ресурсів використовує |
| capabilities | які привілейовані дії може робити root |
| seccomp | які системні виклики ядра доступні |
| AppArmor/SELinux | до яких ресурсів є доступ |
| непривілейований користувач | права всередині контейнера |
Правило для продакшену: жодного --privileged і seccomp=unconfined без задокументованої причини; перевіряти це в рев'ю конфігурацій (Compose, маніфести) і сканерами конфігурації.
За замовчуванням Compose підключає всі сервіси проєкту до однієї мережі default. Кожен контейнер бачить кожен: зворотний проксі може під'єднатися до бази, а сервіс обробки зображень - до Redis із сесіями.
Якщо будь-який сервіс зламано (вразливість у бібліотеці обробки файлів, сторонній образ), зловмисник отримує мережевий доступ до всіх інших.
Сегментація - кілька мереж за принципом «хто з ким має говорити»:
services:
proxy:
image: caddy
ports: ["443:443"]
networks: [edge]
app:
image: myapp
networks: [edge, data]
worker:
image: myapp
command: php artisan queue:work
networks: [data, egress]
postgres:
image: postgres:18
networks: [data]
redis:
image: redis:8
networks: [data]
networks:
edge:
data:
internal: true # без маршруту в інтернет
egress: # окрема мережа з виходом назовні
Що це дає:
- проксі бачить лише застосунок - не базу й не Redis;
- база й Redis у мережі
internal: true- не можуть самі звертатися в інтернет (зламана база не завантажить шкідливий код і не відправить дані назовні); - воркер має вихід в інтернет для сторонніх API, але не стоїть «на краю» - до нього не звертаються ззовні.
Додаткові прийоми:
- без
portsдля всього, крім точки входу; - окремі облікові записи бази для застосунку й воркерів з мінімальними правами - мережа обмежує, хто може під'єднатися, права - що можна зробити після під'єднання;
- аліаси мереж - сервіс може мати різні імена в різних мережах.
Обмеження: мережі Docker - це сегментація на рівні з'єднань (L3/L4). Вони не замінюють автентифікацію між сервісами: Redis і база мають паролі навіть у внутрішній мережі. Для політик на рівні окремих потоків і шифрування між сервісами - оркестратори з мережевими політиками (Kubernetes NetworkPolicy) чи service mesh.
Перевірка: docker compose exec proxy nc -zv postgres 5432 має не з'єднатися - сегментація працює лише тоді, коли її перевірили.
Контейнери Linux потребують ядра Linux. На macOS (і Windows) Docker Desktop запускає легку віртуальну машину, і контейнери працюють усередині неї. Файли проєкту лежать на диску macOS, а процес у контейнері читає їх через межу віртуальної машини - механізм спільного доступу до файлів.
Чому це повільно для PHP і Node-проєктів: проблема не в розмірі файлів, а в їх кількості. Автозавантажувач Composer, node_modules, кеші фреймворку - кожен запит до Laravel відкриває й перевіряє сотні файлів; збирання фронтенду - тисячі. Кожна операція з файлом через межу ВМ коштує набагато дорожче, ніж на локальному диску.
Механізми й еволюція:
- gRPC FUSE, osxfs - старі механізми, дуже повільні на великих проєктах;
- VirtioFS - сучасний механізм за замовчуванням на macOS, значно швидший, але на великих репозиторіях усе ще відчутно повільніший за нативну файлову систему;
- Synchronized file shares - Docker Desktop тримає синхронізовану копію каталогу всередині ВМ (з файловим кешем), і контейнери читають її з нативною швидкістю. Розрахований на великі репозиторії (сотні тисяч файлів). Доступний у платних підписках Docker (Pro, Team, Business).
Практичні прийоми, що працюють незалежно від механізму:
- не монтувати залежності:
vendorіnode_modules- в іменованих томах (лишаються всередині ВМ) або встановлювати в образі; - монтувати лише потрібне: не весь проєкт з
.git, логами й кешем, а каталоги з кодом; - кеші й скомпільовані файли (
storage/framework,bootstrap/cache) - у томі чи tmpfs, а не на bind mount; - OPcache з
validate_timestampsі розумною частотою перевірки - менше звернень до файлової системи; docker compose watchзsync- копіювати змінені файли в контейнер замість спільного доступу;- ресурси ВМ: достатньо пам'яті й процесорів у налаштуваннях Docker Desktop.
Альтернативи Docker Desktop: OrbStack (швидший доступ до файлів і менше споживання ресурсів на macOS), Colima. Або відмовитися від Docker для самого PHP у розробці - Laravel Herd запускає PHP нативно, а в Docker лишаються лише бази й допоміжні сервіси.
На Linux проблеми немає: контейнери працюють на тому самому ядрі, і bind mount - це звичайне монтування без накладних витрат.
Докладніше в документації: Синхронізовані файлові спільні каталоги
Коли compose.yaml росте до десятків сервісів, з'являються дублювання й конфлікти. Compose має кілька механізмів структурування.
1. include (Compose 2.20+) - підключити інший файл Compose як окремий підпроєкт зі своїми відносними шляхами:
# compose.yaml
include:
- infra/compose.yaml # бази, Redis, пошук
- path: tools/compose.yaml # інструменти розробки
env_file: tools/.env
services:
app:
build: .
depends_on: [postgres, redis] # сервіси з підключених файлів доступні
На відміну від злиття через -f, кожен підключений файл розв'язує шляхи відносно свого розташування, тож команда, що відповідає за інфраструктуру, може тримати свій файл окремо. Конфлікт імен сервісів - помилка, а не тихе злиття.
2. extends - успадкувати конфігурацію сервісу з того самого чи іншого файлу:
services:
php-base:
build: .
environment:
APP_ENV: local
volumes:
- .:/var/www/html
app:
extends: php-base
ports: ["127.0.0.1:8000:8000"]
worker:
extends: php-base
command: php artisan queue:work
scheduler:
extends: php-base
command: php artisan schedule:work
3. Якорі YAML і поля розширення x-:
x-php: &php
build: .
env_file: .env
volumes: [".:/var/www/html"]
services:
app:
<<: *php
ports: ["127.0.0.1:8000:8000"]
worker:
<<: *php
command: php artisan queue:work
Поля верхнього рівня з префіксом x- Compose ігнорує, тож туди зручно класти спільні фрагменти. Злиття <<: - поверхневе: вкладені словники заміняються повністю, а не зливаються.
Як обрати:
- кілька незалежних частин (інфраструктура, застосунок, інструменти), різні власники -
include; - кілька ролей одного образу (web, worker, scheduler) -
extendsчи якорі YAML; - різні оточення для тих самих сервісів - кілька файлів через
-fчи профілі.
Перевірка результату - завжди docker compose config: усі три механізми розгортаються в підсумкову конфігурацію, і саме її варто перевіряти, а не покладатися на уявлення про злиття.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії