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

Питання на співбесіді з Docker

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

100 питань

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: поверхня атаки демона

У звичайній конфігурації демон 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 в образі (непривілейований користувач усередині контейнера) - базова практика.

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

--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, маніфести) і сканерами конфігурації.

Докладніше в документації: Профілі seccomp

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

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

Контейнери 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).

Практичні прийоми, що працюють незалежно від механізму:

  1. не монтувати залежності: vendor і node_modules - в іменованих томах (лишаються всередині ВМ) або встановлювати в образі;
  2. монтувати лише потрібне: не весь проєкт з .git, логами й кешем, а каталоги з кодом;
  3. кеші й скомпільовані файли (storage/framework, bootstrap/cache) - у томі чи tmpfs, а не на bind mount;
  4. OPcache з validate_timestamps і розумною частотою перевірки - менше звернень до файлової системи;
  5. docker compose watch з sync - копіювати змінені файли в контейнер замість спільного доступу;
  6. ресурси ВМ: достатньо пам'яті й процесорів у налаштуваннях 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: усі три механізми розгортаються в підсумкову конфігурацію, і саме її варто перевіряти, а не покладатися на уявлення про злиття.

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

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

Рівні
Junior 35 Middle 35 Senior 30

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