<? phpukraine СПІВБЕСІДИ
Пошук по платформі

Питання на співбесіду з DevOps для PHP

Питання про інфраструктуру PHP-застосунків: Docker і шари образу, кеш білду, CI-пайплайни, деплой без простою, логи й моніторинг, змінні середовища та секрети.

Тема
Рівень
7 питань
OPS
DevOps·Junior ·Composer ·semver ·залежності

composer.json описує допустимі діапазони версій, composer.lock фіксує точні версії, які реально встановлені; install ставить строго з lock і дає однакові залежності всюди, update перераховує діапазони й переписує lock.

Локально працює, а на сервері фатальна помилка про неіснуючий метод — чому?
Чи треба комітити composer.lock, якщо версії й так є в composer.json?
Що станеться, якщо запустити composer update на продакшені?
Чим `^1.2.3` відрізняється від `~1.2.3` і що з них дозволить версію 2.0.0?

У Composer два файли з різними ролями. composer.json — це декларація намірів: «мені підійде будь-який Laravel 12.x». composer.lock — протокол результату: точні версії всіх пакетів, включно з транзитивними, які ви ніколи не згадували, плюс хеш коміту кожного з них, посилання на дистрибутив і content-hash — відбиток тих секцій composer.json, що впливають на резолюцію. Саме тому lock зазвичай на кілька тисяч рядків: у ньому не десяток ваших залежностей, а все дерево цілком.

composer install за наявності lock не виконує резолву взагалі — просто розпаковує перелічені версії. Це швидко, детерміновано і дає однаковий vendor/ на ноутбуці, в CI і на сервері. composer update навпаки: ігнорує зафіксовані версії, звіряється з composer.json, витягує з packagist найновіше, що вкладається в діапазони, розв'язує конфлікти між залежностями залежностей і переписує lock. Якщо composer.json змінили, а lock ні, install виведе попередження про невідповідність content-hash і все одно поставить старе; у CI цю розбіжність ловлять командою composer validate --strict, яка на ній падає.

Діапазони записують семантичним версіонуванням: MAJOR.MINOR.PATCH, де мажор означає несумісні зміни, мінор — сумісні доповнення, патч — виправлення. Практично використовують два оператори. Каретка ^1.2.3 дозволяє все до наступного мажора: >=1.2.3 <2.0.0. Тильда ~1.2.3 піднімає лише останню вказану позицію: >=1.2.3 <1.3.0, а от ~1.2 — це вже >=1.2 <2.0. Окремо треба знати виняток для нульових мажорів: у версіях 0.x Composer вважає ламким лівий ненульовий сегмент, тому ^0.3.1 означає >=0.3.1 <0.4.0, а не «до 1.0». Composer не читає код пакета — він довіряє номеру, тож захист від автора, який зламав API в мінорному релізі, лише один: зафіксований lock і тести після кожного оновлення.

Звідси робочий процес. Lock комітять у репозиторій (для застосунку — обов'язково; у бібліотеці він ігнорується споживачами, але корисний для власного CI). Оновлюють локально й точково: composer update vendor/package -W, потім тести, потім коміт зміненого lock окремою гілкою — так у код-рев'ю видно, що саме змінилося у версіях. composer require робить те саме автоматично, тобто частковий update лише для нового пакета. На деплої запускають виключно composer install --no-dev --optimize-autoloader: --no-dev викидає PHPUnit і решту інструментів розробки, --optimize-autoloader будує статичну мапу класів замість пошуку файлів на диску.

Межа цього підходу в тому, що lock фіксує версії, але не гарантує їхньої безпеки: зафіксований на пів року пакет може мати опубліковану вразливість. Тому в пайплайні тримають composer audit (доступний з Composer 2.4), який звіряє lock з базою відомих CVE, а оновлення роблять регулярними невеликими порціями, а не одним стрибком через два мажори раз на рік. Другий нюанс — платформа: якщо на ноутбуці PHP 8.4, а на сервері 8.2, резолюція локально дозволить те, що на сервері не встановиться. Лікується це config.platform.php у composer.json, а не прапорцем --ignore-platform-reqs, який просто вимикає перевірку й переносить падіння на продакшен.

# composer.json — діапазони, тобто наші наміри
#   "laravel/framework": "^12.0"   → >=12.0.0  <13.0.0  (мажор фіксуємо)
#   "nesbot/carbon":     "~3.8.0"  → >=3.8.0   <3.9.0   (патчі, без мінорів)
#   "league/csv":        "^0.9.2"  → >=0.9.2   <0.10.0  (нульовий мажор: ламає мінор)

# 1. Розробник: ставимо строго те, що у lock. Резолву немає, тому швидко.
composer install

# 2. Оновлення — тільки локально, у гілці, точково.
composer outdated --direct          # що взагалі має новіші версії
composer update nesbot/carbon -W    # -W = разом із залежностями цього пакета
vendor/bin/pest                     # тести на новому наборі версій
git add composer.json composer.lock && git commit -m "Bump carbon"

# 3. Конфлікт у composer.lock після мержу: файл не редагують руками.
git checkout --ours composer.json
git checkout --ours composer.lock
composer update --lock              # перерахувати content-hash без зміни версій

# 4. Деплой: install і нічого більше. update тут = нетестований код у релізі.
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
composer audit                      # відомі CVE у зафіксованих версіях
Розділення ролей: composer.json — це наміри (діапазони), composer.lock — це факт (точні версії, посилання на коміт і content-hash).
Що `composer install` за наявності lock не робить резолву взагалі й ставить те саме на ноутбуці, в CI і в продакшені; `composer update` ігнорує зафіксовані версії й перезбирає дерево залежностей.
Правило семантичного версіонування MAJOR.MINOR.PATCH і що `^1.2.3` означає `>=1.2.3 <2.0.0`, `~1.2.3` — `>=1.2.3 <1.3.0`, а для нульових мажорів `^0.3.1` це `>=0.3.1 <0.4.0`.
Що lock комітять для застосунків, а на деплої запускають `composer install --no-dev --optimize-autoloader`, ніколи не `update`.
Що оновлюють локально й точково: `composer update vendor/package -W`, потім тести, потім коміт зміненого lock у гілці.
Додати composer.lock у .gitignore «щоб не було конфліктів у мержі» — після цього кожна машина отримує свій набір версій.
Запускати `composer update` на сервері під час деплою й підтягувати щойно випущений мінорний реліз без жодного тесту.
Розв'язувати конфлікт у composer.lock руками в редакторі замість `git checkout --theirs composer.lock && composer update --lock` чи повторного `composer require`.
Вважати, що `^1.2.3` не пустить 1.9.0, або що `~1.2` і `~1.2.0` — це те саме (перше дозволяє 1.9, друге лише 1.2.x).
Ставити `"*"` або `dev-master` у composer.json і потім дивуватися, що збірка місячної давності не відтворюється.
Забути `--no-dev` на продакшені й тягнути PHPUnit разом із його залежностями в реліз.
ПОРАДА

Скажіть одним реченням: «composer.json — це те, що ми дозволяємо, composer.lock — це те, що ми перевірили». Далі додайте, що update робиться локально в окремій гілці з тестами, а на сервері живе тільки install — це відповідь рівня людини, яка деплоїла, а не читала документацію.

Сторінка питання →
OPS
DevOps·Middle ·Docker ·шари образу ·multi-stage

Multi-stage: у білдерах Composer і збірка ассетів, у фінальному образі лише runtime, vendor без dev-залежностей і код; шари впорядковані так, щоб зміна коду не інвалідувала шар із залежностями.

Чому composer install має йти після копіювання лише composer.json і lock?
Навіщо multi-stage build для PHP-проєкту з фронтендом?
Що має бути в продакшн-образі, а чого не має?

Правильний образ починається з розуміння кешу шарів. Docker перевикористовує шар, поки його вхідні дані не змінились, і перезбирає все після першого зміненого. Код змінюється щодня, composer.lock рідко, тому спершу копіюють лише composer.json і composer.lock, ставлять залежності, і тільки потім копіюють решту. Так зміна одного файлу не перевстановлює vendor.

Multi-stage розділяє збірку й рантайм. Composer живе у своєму стейджі, Node зі збіркою Vite у своєму, а фінальний образ на php:8.4-fpm-alpine отримує лише результат через COPY --from: vendor без dev-залежностей із оптимізованим автолоадером і зібраний public/build. Фінальний образ менший у рази, не містить інструментів збірки й має меншу поверхню атаки.

Продакшн-налаштування PHP входять в образ: php.ini-production як база, opcache з validate_timestamps=0, бо в immutable-образі файли не змінюються, а деплой це заміна контейнера. Версія базового образу пінується до мінорної, процес запускається від www-data, секрети приходять через змінні середовища, а не через .env у шарах. Healthcheck, логи в stdout і graceful shutdown FPM завершують картину. Для нового проєкту альтернативою FPM з nginx є FrankenPHP: один бінарник із вбудованим сервером і worker mode.

# syntax=docker/dockerfile:1

# 1. Залежності PHP: шар кешується, доки не зміниться composer.lock
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative

# 2. Фронтенд: Node живе лише тут
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build

# 3. Рантайм: пінована версія, без Composer, Node і dev-залежностей
FROM php:8.4-fpm-alpine AS runtime
RUN docker-php-ext-install pdo_pgsql opcache \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/opcache.ini $PHP_INI_DIR/conf.d/   # validate_timestamps=0, jit, memory
WORKDIR /var/www
COPY --chown=www-data:www-data --from=vendor /app ./
COPY --chown=www-data:www-data --from=assets /app/public/build ./public/build
USER www-data
HEALTHCHECK CMD php-fpm-healthcheck || exit 1
CMD ["php-fpm"]
Що кеш шарів працює зверху вниз: усе після зміненого шару перезбирається, тому залежності ставляться до копіювання коду.
Що multi-stage дає малий фінальний образ без Node, Composer і dev-залежностей, а також єдину точку правди для збірки в CI і локально.
Що продакшн-налаштування PHP задаються в образі: opcache з validate_timestamps=0, preload за потреби, memory_limit, realpath cache, і php.ini-production як база.
Що образ пінується до конкретної версії PHP і базового образу, запускається від non-root, а секрети не потрапляють у шари.
Що для FPM потрібен окремий nginx або єдиний образ на FrankenPHP, і що healthcheck, логи в stdout і graceful shutdown є частиною правильного образу.
Копіювати весь проєкт першим шаром: кожна зміна коду перевстановлює всі залежності.
Тягнути dev-залежності, Node і node_modules у продакшн-образ.
Використовувати тег latest або php:8 без мінорної версії й отримувати несподіване оновлення.
Запускати composer install або npm ci на старті контейнера замість на етапі збірки.
Лишати opcache.validate_timestamps=1 у продакшені й платити stat-викликами на кожен запит.
Запускати процес від root і класти .env з секретами в образ.
ПОРАДА

Додайте, що opcache.validate_timestamps=0 у продакшн-образі дає відчутний приріст, і поясніть, чому це безпечно саме в immutable-образі. Згадайте FrankenPHP як варіант без окремого nginx.

Сторінка питання →
OPS
DevOps·Middle ·.env ·секрети ·конфігурація

Конфігурація приходить у процес через змінні оточення й відрізняється по середовищах, код лишається однаковим; .env — це локальна зручність розробника, яку не комітять, а на продакшені значення дає pool php-fpm, оркестратор або менеджер секретів. У Laravel env() дозволений лише у файлах config/, бо після config:cache .env не завантажується взагалі.

Виправили значення в .env на сервері, перезапустили — застосунок далі бачить старе. Що сталося?
Що з цього комітять у git: .env, .env.example, .env.local, config/secrets?
Розробник випадково запушив ключ від платіжного шлюзу й одразу зробив revert. Цього достатньо?
Де тримати пароль до бази в контейнері, якщо змінні оточення видно в `docker inspect` і в `/proc/1/environ`?

Базовий принцип формулюється в одному реченні: конфігурація — це те, що відрізняється між середовищами, і вона має приходити ззовні, а не з коду. Це третій пункт 12-factor, і практично він означає, що один і той самий артефакт (образ, теґ, архів релізу) виїжджає на staging і на prod без перезбирання, а різниця між ними — виключно у значеннях змінних оточення процесу. Перевірка на зрілість тут проста: якщо у коді є if (app()->environment('production')) навколо адреси сервісу чи ліміту, конфігурація живе в коді, а не зовні.

Технічно PHP отримує ці значення трьома різними каналами, і їх плутають частіше, ніж здається. getenv() читає справжнє оточення процесу — те, що передав php-fpm, systemd чи контейнер. $_ENV наповнюється лише якщо в variables_order є буква E, а і php.ini-development, і php.ini-production постачаються зі значенням "GPCS", тобто без неї — тому на чистому продакшн-конфізі $_ENV порожній, хоча getenv() працює. Третій канал — бібліотека dotenv, яка на старті читає .env і сама наповнює $_ENV та $_SERVER. У Symfony компонент Dotenv за замовчуванням не викликає putenv() (це поведінка з 5.0), тож там значення беруть із $_ENV/$_SERVER, а не через getenv(). Висновок для коду: не звертайтеся до оточення напряму, ходіть через config() або параметри контейнера — тоді джерело можна змінити, не чіпаючи класи.

Конвенції двох головних фреймворків протилежні, і на співбесіді це люблять питати. У Laravel .env завжди в .gitignore, у репозиторії лежить .env.example зі списком ключів і безпечними значеннями; env() дозволений тільки у файлах config/, бо на деплої виконується php artisan config:cache, після чого .env не читається взагалі — Laravel бачить закешований bootstrap/cache/config.php і пропускає завантаження оточення. Звідси й класичний симптом «змінив .env, а нічого не змінилося»: треба config:clear або повторний config:cache. У Symfony ж .env комітять — це файл дефолтів, а локальні відхилення йдуть у .env.local, який у .gitignore; для продакшену composer dump-env prod (з symfony/flex) згортає все у скомпільований .env.local.php, щоб не парсити файли на кожному запиті.

Для справжніх секретів обидві екосистеми мають шифроване сховище, а індустріальний варіант — зовнішній менеджер. Symfony від 4.4 має vault на libsodium: bin/console secrets:set DATABASE_PASSWORD кладе sealed box у config/secrets/prod/, публічний ключ шифрування комітять, приватний prod.decrypt.private.php — ні (він доставляється окремо або передається через SYMFONY_DECRYPTION_SECRET). Laravel від 9.32 має php artisan env:encrypt --env=production, що робить .env.production.encrypted під AES-256-CBC, а розшифрування на деплої бере ключ зі змінної LARAVEL_ENV_ENCRYPTION_KEY. У хмарі ці файли часто не потрібні зовсім: AWS Secrets Manager, GCP Secret Manager чи HashiCorp Vault віддають значення інстансу, який автентифікувався роллю, — і тоді кореневого пароля на диску немає взагалі, а є короткоживучий токен. Окремо: секрет ніколи не потрапляє в ENV/ARG Dockerfile, бо лишається в шарі образу й читається через docker history; для збірки є RUN --mount=type=secret, для рантайму — файл у /run/secrets і патерн *_FILE.

Межі підходу варто назвати самому. Змінні оточення — не броня: їх успадковують дочірні процеси, вони читаються з /proc/<pid>/environ, потрапляють у краш-дампи й у сторінку помилки, якщо на проді лишили APP_DEBUG=true. Тому найчутливіше (приватні ключі, сертифікати) віддають файлом з правами 0400, а не змінною. Друга межа — час життя: значення, що не змінювалося рік, поводиться як пароль, який знають усі, хто колись мав доступ; тому ротація має бути плановою, і кожен секрет має пару «поточний + попередній», щоб її пережити без даунтайму — у Laravel це APP_PREVIOUS_KEYS для APP_KEY, у баз — окремий користувач на застосунок замість спільного. І третє, найважливіше правило інциденту: щойно секрет потрапив у пуш, він скомпрометований, і першою дією є відкликання ключа в провайдера, а не git filter-repo — переписана історія не забирає значення з форків, кешу CI, локальних клонів і логів вебхуків.

<?php
// config/services.php — єдине місце, де дозволено env().
return [
    'stripe' => [
        // Значення без дефолту: на проді має бути задане ззовні.
        'secret' => env('STRIPE_SECRET'),
        // Несекретна конфігурація: дефолт прямо тут, у .env лише відхилення.
        'webhook_tolerance' => (int) env('STRIPE_WEBHOOK_TOLERANCE', 300),
        // Патерн *_FILE: секрет змонтований файлом (Docker/K8s), не змінною.
        'signing_key' => ($p = env('STRIPE_SIGNING_KEY_FILE'))
            ? trim(file_get_contents($p))
            : env('STRIPE_SIGNING_KEY'),
    ],
];

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(StripeGateway::class, fn ($app) => new StripeGateway(
        // config() читає закешований масив і працює після config:cache.
        $app['config']->get('services.stripe.secret')
            ?? throw new RuntimeException('STRIPE_SECRET не заданий'),
    ));
}

// app/Billing/StripeGateway.php
final class StripeGateway
{
    public function __construct(private readonly string $secret) {}

    // ПОМИЛКА, на якій валяться:
    // public function __construct()
    // {
    //     // Після `php artisan config:cache` файли config/ не виконуються,
    //     // .env не завантажується — env() поверне null мовчки, без винятку,
    //     // і впаде вже HTTP-запит до Stripe із 401.
    //     $this->secret = env('STRIPE_SECRET');
    // }
}
Розділення конфігурації (адреси, ліміти, фіче-флаги) і секретів (паролі, ключі API): перше може лежати у репозиторії з дефолтами, друге — ніколи.
Що один артефакт (образ, теґ) деплоїться в усі середовища без перезбирання, а різниця між dev/staging/prod — тільки в значеннях змінних оточення.
Що `.env` — це файл для локальної розробки, який читає бібліотека dotenv на старті; у продакшені змінні краще віддавати процесу зовні: `env[...]` у пулі php-fpm, systemd, secret у Kubernetes, AWS Secrets Manager чи HashiCorp Vault.
Laravel: `.env` у .gitignore, `.env.example` у git, `env()` тільки в `config/`, на деплої `php artisan config:cache`. Symfony навпаки: `.env` комітять із безпечними дефолтами, секрети йдуть у `.env.local` або в sodium-сховище `secrets:set`.
Що витік секрету лікується ротацією, а не переписуванням історії git: щойно значення потрапило в пуш, воно скомпрометоване назавжди.
Викликати `env('STRIPE_SECRET')` у контролері чи сервісі: після `php artisan config:cache` .env не читається взагалі й виклик тихо поверне `null` — без винятку, з падінням уже на боці API.
Закомітити `.env` «тимчасово, щоб колега підняв стейджинг» і залишити його в історії репозиторію назавжди.
Прибрати секрет через `git revert` або `commit --amend` і вважати інцидент закритим, не відкликавши ключ.
Класти секрет у `ENV`/`ARG` в Dockerfile: значення лишається в шарі образу й видно в `docker history` та `docker inspect` кожному, хто має доступ до реєстру.
Використовувати одні й ті самі креденшели для dev і prod або тягнути дамп продакшену на ноутбук «щоб відтворити баг».
Змінити `.env` на сервері з увімкненим кешем конфігурації й не запустити `config:clear`/`config:cache` — застосунок працює зі старим масивом.
ПОРАДА

Сформулюйте так: «код однаковий у всіх середовищах, різні лише значення змінних оточення, і жодне з них не живе в репозиторії». Далі додайте фразу, за якою чути досвід: «витік секрету закривається ротацією, а не переписуванням історії» — і поясніть, що на продакшені `.env` взагалі може не бути, бо змінні віддає pool php-fpm або оркестратор.

Сторінка питання →
OPS
DevOps·Middle ·CI ·тести ·статичний аналіз

Встановлення залежностей з кешем, перевірка стилю, статичний аналіз, тести на реальній базі й збірка артефакту; швидкі кроки першими, щоб пайплайн падав рано.

Як пришвидшити CI, що йде 20 хвилин?
Чи потрібен PHPStan у CI, якщо є тести?
У якому порядку запускати кроки пайплайну?

Мінімальний CI для PHP-проєкту складається з чотирьох кроків. Встановлення залежностей: composer validate, composer install строго за lock-файлом з кешем vendor по хешу composer.lock, composer audit для відомих вразливостей. Перевірка стилю: Pint або PHP-CS-Fixer у режимі перевірки без правок. Статичний аналіз: PHPStan або Psalm на зафіксованому рівні, для легасі з baseline. Тести: PHPUnit або Pest на тій самій базі, що в продакшені, піднятій як service-контейнер, бо SQLite відрізняється в типах, JSON і ALTER TABLE.

Порядок визначає принцип fail fast. Лінтер і аналіз відпрацьовують за секунди, тому вони йдуть першими або паралельно, а хвилинні тести після. Збірка Docker-образу відбувається лише після зелених перевірок і лише з гілки, з якої деплоять. Образ тегується SHA коміту, щоб будь-який деплой можна було відкотити до конкретної збірки.

Коли пайплайн росте до 20 хвилин, лікують не вимкненням кроків, а вимірюванням. Зазвичай час іде на залежності без кешу й на послідовні тести: кеш шарів, паралельні джоби, шарди тестів через --parallel, транзакції замість міграцій з нуля в кожному тесті, і винесення важких інтеграційних сьютів на етап merge. Статичний аналіз при цьому не прибирають: він ловить помилки типів у коді, до якого тести не дійшли, і робить це за секунди.

# .github/workflows/ci.yml — мінімальний, але повний пайплайн
name: CI
on: [push, pull_request]

jobs:
  static:                      # секунди: падаємо рано
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', coverage: none }
      - uses: actions/cache@v4
        with: { path: vendor, key: composer-${{ hashFiles('composer.lock') }} }
      - run: composer validate --strict && composer install --no-interaction --prefer-dist
      - run: composer audit
      - run: vendor/bin/pint --test
      - run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G

  tests:                       # хвилини: реальна база, як у продакшені
    runs-on: ubuntu-latest
    needs: static
    services:
      postgres:
        image: postgres:17
        env: { POSTGRES_DB: app_test, POSTGRES_USER: app, POSTGRES_PASSWORD: secret }
        options: --health-cmd pg_isready --health-interval 5s
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with: { php-version: '8.4', extensions: pdo_pgsql }
      - uses: actions/cache@v4
        with: { path: vendor, key: composer-${{ hashFiles('composer.lock') }} }
      - run: composer install --no-interaction --prefer-dist
      - run: vendor/bin/pest --parallel
        env: { DB_CONNECTION: pgsql, DB_HOST: localhost, DB_DATABASE: app_test, DB_USERNAME: app, DB_PASSWORD: secret }

  build:                       # лише після зелених перевірок
    runs-on: ubuntu-latest
    needs: tests
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t registry.example.com/app:${{ github.sha }} .
Конкретний набір: composer validate і install з кешем, Pint або PHP-CS-Fixer у режимі перевірки, PHPStan або Psalm на фіксованому рівні, PHPUnit або Pest, збірка Docker-образу лише після зелених перевірок.
Принцип fail fast: лінтер і статичний аналіз за секунди, тести за хвилини, тому спершу швидке, а незалежні кроки паралельно.
Що тести в CI ганяються на тій самій базі, що в продакшені, через service container, а не на SQLite, бо відмінності в SQL реальні.
Що composer.lock у репозиторії й install без update, composer audit для вразливостей, а версія PHP у CI збігається з образом продакшену.
Розуміння, який рівень PHPStan тримається і чому, і що baseline дозволяє впровадити аналіз у легасі без зупинки розробки.
Обмежуватись лише запуском тестів і вважати CI готовим.
Запускати тести на SQLite заради швидкості й ловити відмінності в SQL уже в продакшені.
Не кешувати vendor і залежності Docker-шарів, через що кожен запуск качає все з нуля.
Ставити збірку образу перед тестами або деплоїти з гілки без перевірок.
Використовувати composer update у CI замість install і отримувати різні залежності в кожному запуску.
ПОРАДА

Скажіть, який рівень PHPStan тримаєте і чому саме такий — це показує реальний досвід, а не список інструментів. Додайте, як розбили довгі тести на паралельні джоби.

Сторінка питання →
OPS
DevOps·Middle ·логи ·моніторинг ·observability

Логи пишемо структурованим JSON у stdout одним рядком на подію й віддаємо збирачу, помилки дублюємо в Sentry, де вони групуються за fingerprint і мають реліз та контекст, а сигналом «щось не так» служать не логи, а метрики RED (rate, errors, duration) з алертами на симптоми, які бачить користувач.

Користувач каже «сайт лежав п'ять хвилин учора ввечері» — як ви це перевірите заднім числом?
Куди має писати логи PHP-застосунок у Docker і чому не у storage/logs?
Навіщо Sentry, якщо всі винятки й так є в лог-файлі?
Як ви дізнаєтесь про аварію раніше, ніж про неї напише клієнт?

Почніть з того, що застосунок у контейнері не володіє своїми логами. Дванадцятифакторний підхід трактує лог як потік подій: процес пише в stdout/stderr і більше ні за що не відповідає — ані за ротацію, ані за доставку, ані за retention. У Docker цей потік підбирає логовий драйвер, у Kubernetes — збирач на ноді (Fluent Bit, Vector, Promtail), який складає записи в Loki, OpenSearch чи хмарне сховище. Файл storage/logs/laravel.log у поді помирає разом із подом, а канал daily у трьох репліках дає три розірвані стрічки, які ніхто не звірить. Технічно в Laravel це канал з driver => monolog, StreamHandler на php://stdout і JsonFormatter; найпростіший варіант того ж — вбудований канал stderr із LOG_STDERR_FORMATTER=Monolog\Formatter\JsonFormatter. Окремо не забудьте про сам PHP-FPM: без catch_workers_output = yesdecorate_workers_output = no, доступного з PHP 7.3) фатальні помилки воркера просто зникнуть, не дійшовши до stdout контейнера.

Структурованість важливіша за сам факт логування. Текстовий рядок «User 42 paid 1500 UAH» читається людиною і не читається машиною: за ним не побудувати ані фільтр, ані графік. PSR-3 навмисно розділяє message і масив context, і саме другий аргумент Log::info('checkout.paid', ['order_id' => …]) перетворюється на індексовані поля JSON. Практичне правило: повідомлення — це стабільний ідентифікатор події (checkout.paid, payment.gateway_timeout), усе змінне — у контекст. Тоді запит «усі таймаути платіжного шлюзу за останню годину, згруповані за провайдером» — це один рядок у мові запитів сховища, а не регулярний вираз по мегабайтах тексту.

Другий обов'язковий елемент — наскрізний ідентифікатор. Один HTTP-запит породжує записи у nginx, у застосунку, у кількох чергових джобах і, можливо, у сусідньому сервісі; без спільного ключа склеїти їх неможливо. Nginx уміє генерувати $request_id, застосунок приймає його заголовком X-Request-Id, кладе у Context (з Laravel 11 дані Context автоматично потрапляють у кожен запис лога і серіалізуються разом із джобою в чергу — на відміну від Log::withContext(), який межу черги не переживає), ставить тегом у Sentry і повертає у відповіді. Далі за одним рядком з тікета користувача піднімається вся історія запиту.

Sentry вирішує іншу задачу, ніж сховище логів, і тому не замінюється ним. Він рахує fingerprint події за типом винятку і стеком, тож мільйон однакових падінь — це одна проблема з лічильниками подій і унікальних користувачів. До кожної події прикріплені release і environment (звідси відповідь на головне питання чергового: «це з'явилося в останньому деплої?»), breadcrumbs із запитами і SQL перед падінням, значення змінних у кадрах стеку, призначення відповідальному і статус «regressed», якщо закрита помилка повернулася. У Laravel 11/12 інтеграція sentry/sentry-laravel підключається у bootstrap/app.php через Integration::handles($exceptions) у withExceptions(), а traces_sample_rate для трасування ставлять часткою на кшталт 0.1, бо повне трасування продакшену коштує грошей. Із тих самих міркувань send_default_pii лишають вимкненим, а в before_send вирізають ключі password, token, authorization.

І головне розмежування, яке очікує почути інтерв'юер: логи й Sentry — інструменти розслідування, а не сповіщення. Дізнаватися про аварію треба з метрик. Мінімум — модель RED на HTTP-шарі: частота запитів, частка 5xx і гістограма латентності (дивіться p95/p99, середнє ховає хвіст), плюс специфіка PHP — зайняті воркери FPM зі сторінки pm.status_path, яку знімає php-fpm_exporter, глибина черги і вік найстарішої джоби, кількість failed_jobs. Збирає це Prometheus, алертить Alertmanager. Алерти вішають на симптоми, які відчуває користувач, і бажано через бюджет помилок SLO з двома порогами burn rate: швидкий (умовно 14x за 5 хвилин) будить людину, повільний (3x за 6 годин) створює тікет. Алерт «на кожен ERROR у логах» гарантовано вимкнуть через тиждень; алерт «CPU > 80%» будить без причини, бо висока утилізація сама по собі не є проблемою.

Межі цієї схеми — вартість і сліпі зони. Логи на рівні debug у продакшені коштують дорожче за сервери й тягнуть персональні дані у сховище з довгим retention, тому рівень тримають на info, а деталізацію вмикають точково і тимчасово. Метрики ламаються на високій кардинальності: мітка user_id у Prometheus створює мільйони серій і кладе сховище — ідентифікаторам місце в логах і трейсах, не в мітках. Трейси (open-telemetry/opentelemetry-php разом із розширенням opentelemetry) закривають третій кут спостережуваності — де саме всередині запиту витрачений час, — але їх завжди семплюють. Нарешті, найпідступніший випадок — тиша: застосунок, який перестав слати метрики, на графіку виглядає так само, як ідеально здоровий, тому в схемі обов'язково має бути dead man's switch на потік метрик і на cron.

// config/logging.php — усе в stdout структурованим JSON, помилки додатково в Sentry
'default' => env('LOG_CHANNEL', 'stack'),
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['stdout', 'sentry'],
        'ignore_exceptions' => false,
    ],

    // Один рядок = один JSON-обʼєкт. Файлів немає: збирач читає stdout контейнера.
    'stdout' => [
        'driver' => 'monolog',
        'level' => env('LOG_LEVEL', 'info'),          // на проді info, не debug
        'handler' => Monolog\Handler\StreamHandler::class,
        'handler_with' => ['stream' => 'php://stdout'],
        'formatter' => Monolog\Formatter\JsonFormatter::class,
        'processors' => [Monolog\Processor\PsrLogMessageProcessor::class],
    ],

    'sentry' => [
        'driver' => 'sentry',
        'level' => 'error',                            // у Sentry — лише те, що вимагає дії
        'bubble' => true,
    ],
],

// app/Http/Middleware/TrackRequestContext.php — наскрізний ідентифікатор
public function handle(Request $request, Closure $next): Response
{
    $requestId = $request->header('X-Request-Id') ?: (string) Str::uuid();

    // Context (Laravel 11+) сам додається в кожен запис лога і їде разом із джобою в чергу
    Context::add('request_id', $requestId);
    Context::add('user_id', $request->user()?->id);
    \Sentry\configureScope(fn ($scope) => $scope->setTag('request_id', $requestId));

    // Дані — окремим масивом-контекстом, а не всередині тексту повідомлення
    Log::info('checkout.paid', ['order_id' => $order->id, 'amount_uah' => $order->total]);

    return tap($next($request), fn ($response) => $response->headers->set('X-Request-Id', $requestId));
}
Що в контейнері застосунок не володіє файлами логів: пише в stdout/stderr одним рядком JSON, а ротацією, доставкою і зберіганням займається платформа (Docker driver, Fluent Bit, Vector, Promtail).
Різницю між текстовим і структурованим записом: у JSON-лога поля `level`, `message`, `context.request_id`, `user_id` індексуються і шукаються, у `sprintf`-рядку — ні.
Наскрізний `request_id`, який іде з заголовка `X-Request-Id` у кожен запис лога і в чергові джоби, — без нього логи розподіленої системи не склеюються.
Що Sentry — це не «ще один лог», а агрегатор: дедуплікація за fingerprint, кількість подій і користувачів, реліз, у якому проблема з'явилася, breadcrumbs і стек зі значеннями змінних.
Що алерти вішають на метрики й SLO (частка 5xx, p95 латентності, глибина черги, вік найстарішої джоби), а логи читають уже після спрацювання алерту, щоб зрозуміти причину.
Обізнаність про вартість і PII: рівні логування, семплінг трейсів, `send_default_pii=false` і скрабінг токенів перед відправкою.
Писати в `storage/logs/laravel.log` всередині контейнера: після рестарту подів логи зникають, а `daily` канал у кількох репліках дає розірвану картину.
Ставити `LOG_LEVEL=debug` на продакшені й отримати рахунок за зберігання більший, ніж за самі сервери, плюс SQL-запити з персональними даними в логах.
Форматувати дані в текст повідомлення (`Log::info("User {$id} paid {$sum}")`) замість другого аргументу-контексту — потім по такому логу неможливо агрегувати.
Вважати Sentry заміною моніторингу: він ловить винятки, але не побачить, що сервіс просто перестав відповідати, або що черга росте без жодної помилки.
Алертити на кожен `ERROR` у логах — за тиждень команда вимикає сповіщення й аварію знову помічає клієнт.
Заводити алерт на «CPU > 80%» замість симптому: висока утилізація сама по собі не є проблемою, а падіння p95 і зростання 5xx — є.
Забути про stderr самого PHP-FPM: без `catch_workers_output = yes` фатальні помилки воркера не потрапляють у stdout контейнера взагалі.
ПОРАДА

Скажіть так: «лог — це те, що читають, коли вже відомо, що зламалось; дізнаємось ми про поломку з метрик». Далі назвіть три рівні — структурований JSON у stdout для розслідування, Sentry для винятків із дедуплікацією і релізом, метрики RED з алертами на SLO для сповіщення. Це показує, що ви розрізняли ці інструменти на чергуванні, а не просто перелічили модні назви.

Сторінка питання →
OPS
DevOps·Senior ·масштабування ·stateless ·сесії

Треба винести назовні весь стан, який зараз лежить на локальному диску чи в памʼяті процесу: сесії — у Redis/БД, завантажені файли — в обʼєктне сховище, кеш і локи — у спільний Redis, черги й планувальник — в окремі процеси з дедуплікацією; липкі сесії це не рішення, а спосіб не помітити проблему.

Додали другий вузол за балансувальником — і користувачів почало кожен другий запит викидати з логіну. Чому?
Ми ввімкнули липкі сесії на балансувальнику, і все запрацювало. Що з цим рішенням не так?
Куди дівати файли, які завантажують користувачі, коли серверів більше ніж один?
Що саме у вашому застосунку заважає прямо зараз запустити три копії контейнера замість однієї?

Сам PHP-процес між запитами стану не тримає — після кожного запиту він усе забуває, і в цьому сенсі мова масштабується краще за більшість інших. Проблема в тому, що застосунок роками писався в припущенні «диск і памʼять у нас одні на всіх», і стан осідає в кількох конкретних місцях: у каталозі сесій, у завантажених файлах, у кеші й локах, у чергах і cron, у логах. Питання, яке треба поставити до кожного з них, одне: що станеться, якщо наступний запит того самого користувача прийде на інший вузол, а цей вузол просто зникне. Все, що на це питання відповідає «зламається», і є список робіт.

Найпершим стріляє сесія. Дефолтний session.save_handler=files кладе серіалізований масив у session.save_path, тобто в локальну ФС конкретного вузла; балансувальник розкидає запити, і користувач через раз бачить форму логіну. Спільним сховищем робить або сам PHP (session.save_handler=redis з session.save_path="tcp://host:6379?database=2" від phpredis, або memcached), або фреймворк: у Laravel SESSION_DRIVER=redis|database (з 11-ї версії дефолт скелета — database), у Symfony — framework.session.handler_id з RedisSessionHandler. Тут є дві деталі, на яких валяться. Перша: нативний files тримає flock на файлі до кінця запиту, тому паралельні AJAX-запити одного користувача виконуються послідовно; у phpredis це поведінка вимикається за замовчуванням і вмикається redis.session.locking_enabled=1, а власна сесія Laravel не блокує нічого взагалі — там останній запис перемагає. Друга: сховище спільне, а ключі мають бути однакові, бо cookie з ідентифікатором сесії зашифрована APP_KEY (у Symfony APP_SECRET підписує signed URI та remember-me); згенерований під час деплою ключ дає вузол, який не може прочитати чужу cookie.

Далі файли й кеш. Завантаження користувачів переїжджають у обʼєктне сховище — S3 чи сумісне (MinIO, DigitalOcean Spaces) через диск s3 і league/flysystem-aws-s3-v3; storage:link і public/storage при цьому втрачають сенс, приватні файли віддаються через Storage::temporaryUrl(). З кешем тонше: opcache і зібрані артефакти (config:cache, route:cache, view:cache) — це похідні від коду, вони мають бути на кожному вузлі й будуються під час збірки образу, це нормально. А от кеш даних локальним бути не може: APCu живе в межах процесу FPM, файловий — у межах вузла, і скидання ключа на вузлі A залишає вузол B зі старими даними. Разом із кешем переїжджає все, що на ньому побудоване: Cache::lock(), RateLimiter, мітка queue:restart, локи withoutOverlapping() і onOneServer(). Найнеприємніше, що вони не падають — вони просто перестають бути глобальними гарантіями й стають локальними.

Черги й планувальник із «ще одного процесу на вебсервері» стають окремою одиницею розгортання. Воркер не потребує nginx і масштабується власним темпом, зате потребує тієї самої черги (redis, SQS, database) і коректного завершення: на SIGTERM queue:work дороблює поточну задачу й виходить, тому terminationGracePeriodSeconds має бути більшим за найдовший job, інакше задача обірветься посередині. schedule:run навпаки має спрацьовувати рівно один раз: або окремий CronJob в оркестраторі, або ->onOneServer() на кожній задачі з обовʼязково спільним лок-стором. У контейнері до цього додається загальне правило: писати можна лише в змонтовані томи й у зовнішні сервіси, бо шар запису викидається разом із контейнером — тому логи йдуть у stderr (LOG_CHANNEL=stderr) і збираються збирачем, а не ротуються всередині. І окремо: за балансувальником треба оголосити довірені проксі (trustProxies у bootstrap/app.php, framework.trusted_proxies у Symfony), інакше X-Forwarded-For і X-Forwarded-Proto ігноруються, у логах усі запити приходять з однієї адреси, а згенеровані посилання стають http://.

Липкі сесії (ip_hash у nginx, cookie ... insert у HAProxy, стікі target group в ALB) варто вміти назвати запахом і пояснити чому. Вони не усувають локальний стан, а лише роблять його непомітним: навантаження розподіляється нерівномірно (один NAT — один вузол), автоскейлінг перестає допомагати, бо нові вузли не забирають наявні сесії, виведення вузла на деплой означає розлогінених користувачів, а падіння — втрачені кошики. Плюс тестування деградує: помилка «стан не спільний» проявиться не одразу, а через півроку під час першого ж інциденту. Легітимні винятки є — WebSocket і SSE справді привʼязані до процесу, і липкість тут не милиця, а вимога; так само вона нормальна як тимчасовий місток на час міграції сесій.

Межа підходу в тому, що абсолютно stateless застосунків не буває — стан не зникає, а переїжджає, і те, що переїхало, стає новою точкою відмови. Redis із сесіями, кешем і чергами покладе весь кластер швидше, ніж це зробив би один вебвузол, тож за ним іде реплікація чи Sentinel, і окреме питання — чи можна пережити його недоступність (сесії — ні, кеш — так, якщо код готовий до промаху). Другий компроміс — латентність: те, що було читанням з локального диска чи з APCu за мікросекунди, стає мережевим викликом, тому гарячі незмінні дані іноді свідомо лишають у локальному APCu з коротким TTL, приймаючи розузгодженість у кілька секунд. Третій — вартість переходу: якщо застосунок писався десять років з session_start() і move_uploaded_file() у public/, чесна відповідь на співбесіді включає етапність — спершу сесії й ключі, потім файли, потім кеш і локи, і лише в кінці вимкнення липкості як фінальна перевірка, що стану на вузлах більше немає.

# ── /etc/php/8.4/fpm/conf.d/90-session.ini ────────────────────────────
# Було (типовий одиничний сервер): сесія — файл у локальній ФС вузла.
#   session.save_handler = files
#   session.save_path    = "/var/lib/php/sessions"
# Стало: спільне сховище через розширення redis (phpredis 6.x).
session.save_handler = redis
session.save_path    = "tcp://redis.internal:6379?database=2"
redis.session.locking_enabled = 1   # відтворює блокування, яке давав files
redis.session.lock_expire     = 30  # щоб зависла вимога не тримала сесію вічно
session.gc_maxlifetime        = 7200 # для redis це TTL ключа, GC не потрібен

# ── .env (Laravel 12) — увесь локальний стан переїжджає у сервіси ─────
SESSION_DRIVER=redis        # було file → storage/framework/sessions
CACHE_STORE=redis           # було file; APCu теж живе в межах процесу
QUEUE_CONNECTION=redis      # було sync або database «бо один сервер»
FILESYSTEM_DISK=s3          # було local → storage/app/public
LOG_CHANNEL=stderr          # логи в stdout/stderr, не у файл контейнера
APP_KEY=base64:...          # ОДИН на всі вузли, інакше cookie не розшифрувати

# ── Перевірка, що стан справді спільний, а не «здається спільним» ────
# на вузлі A:
php artisan tinker --execute 'Cache::put("probe", gethostname(), 60);'
# на вузлі B — має вивести імʼя вузла A, а не null:
php artisan tinker --execute 'echo Cache::get("probe") ?? "null";'

# ── Планувальник: cron на всіх вузлах, дедуплікація в коді ───────────
# * * * * * php /app/artisan schedule:run >> /dev/null 2>&1
# Schedule::command('reports:send')->daily()->onOneServer();
#   ↑ потрібен redis/memcached/database/dynamodb: файлові локи локальні,
#     винятку не буде, а звіт піде стільки разів, скільки вузлів.

# ── Запах, а не рішення: липкі сесії на балансувальнику ──────────────
# nginx:   upstream app { ip_hash; server web1; server web2; }
# haproxy: cookie SRVID insert indirect nocache
# Якщо після їх вимкнення застосунок ламається — стан досі локальний.
Перелік місць, де ховається стан: `session.save_path`, завантажені файли, кеш і локи, черги й cron, логи — і що кожне з них має окреме рішення.
Що сесія за замовчуванням це `session.save_handler=files` у локальній ФС вузла, а спільною вона стає через `session.save_handler=redis` з `session.save_path="tcp://..."` або через драйвер застосунку (`SESSION_DRIVER=redis|database`).
Що спільним має бути не лише сховище, а й ключі: один `APP_KEY` (Laravel) чи `APP_SECRET` (Symfony) на всі вузли, інакше вузол B не розшифрує cookie, видану вузлом A.
Розуміння, що APCu і файловий кеш живуть у межах процесу чи вузла, тому `Cache::lock()`, rate limiter, `queue:restart` і `->onOneServer()` без спільного стора мовчки перестають гарантувати те, що обіцяють.
Що липкі сесії — тимчасовий милиць: вони ламають рівномірність навантаження, автоскейлінг і виведення вузла з ротації, а падіння вузла все одно втрачає сесії.
Що планувальник і воркери — це окремі процеси, а не щось, що запускається на кожному вебвузлі «за компанію».
Змонтувати NFS у `session.save_path` і вважати задачу закритою: додається мережева затримка на кожен запит і блокування файлу сесії через `flock` по мережі.
Вирішити проблему липкими сесіями (`ip_hash` у nginx, `cookie SRVID insert` у HAProxy) і залишити стан локальним — після перезапуску вузла всі його користувачі розлогінені.
Генерувати `php artisan key:generate` під час деплою або в `Dockerfile`: у кожного вузла (чи в кожній збірці) свій `APP_KEY`, і сесії з cookie перестають розшифровуватись.
Лишити `FILESYSTEM_DISK=local` і `storage:link`: аватарка, завантажена на вузол A, дає 404 при відкритті з вузла B.
Запускати `schedule:run` з cron на кожному вузлі без `->onOneServer()` — щоденний звіт розсилається стільки разів, скільки вузлів.
Забути про довірені проксі: без `trustProxies` (Laravel) чи `framework.trusted_proxies` (Symfony) `$request->ip()` повертає адресу балансувальника, а `url()` генерує `http://` за HTTPS-балансувальником.
ПОРАДА

Скажіть так: «Горизонтальне масштабування — це не про сервери, а про пошук стану. Я проходжу по списку: сесія, файли, кеш і локи, черги, cron, логи — і для кожного питаю, що станеться, якщо наступний запит прийде на інший вузол, а цей вузол зараз зникне». Далі окремо додайте, що липкі сесії — це індикатор незакритого пункту зі списку, а не рішення.

Сторінка питання →
OPS
DevOps·Senior ·міграції ·zero-downtime ·деплой

Схема змінюється у фази expand і contract: спершу зміни, сумісні зі старим кодом, потім деплой коду, який працює з обома варіантами, і лише після повного викочування прибирання старого.

Як перейменувати колонку без простою?
Чому не можна додавати NOT NULL без default на великій таблиці?
Що таке expand and contract?

Під час zero-downtime деплою стара й нова версії коду певний час працюють одночасно з однією базою. Тому кожна міграція має бути сумісна з попереднім кодом, а кожна версія коду з попередньою і наступною схемою. Цей принцип називають expand and contract: спершу розширення схеми, сумісне з усім, потім переключення коду, і лише після повного викочування прибирання старого.

Безпечні операції: додати nullable-колонку, нову таблицю, індекс через CONCURRENTLY. Небезпечні: видалення, перейменування, зміна типу, NOT NULL на існуючій колонці. Небезпечні операції розкладаються на кілька релізів. Перейменування колонки займає чотири: додати нову, писати в обидві й читати стару, читати нову й писати в обидві, писати лише в нову й видалити стару. Між другим і третім кроком іде backfill батчами по первинному ключу, щоб не тримати довге блокування й не роздувати WAL.

Окрема категорія — DDL, який блокує таблицю. У PostgreSQL додавання NOT NULL сканує таблицю під блокуванням; обхід через CHECK ... NOT VALID, VALIDATE CONSTRAINT без блокування запису і потім SET NOT NULL. Індекс без CONCURRENTLY зупиняє запис на час побудови. Будь-який DDL запускається з lock_timeout, щоб не чекати блокування вічно й не ставити в чергу всі запити за собою.

Міграції виконуються один раз, окремим кроком до перемикання трафіку, а не в entrypoint кожної репліки, і мають план відкату. Успіх міграції є умовою розкатки нового образу.

-- Задача: перейменувати users.name у users.full_name без простою

-- Реліз 1 (expand): нова nullable-колонка, миттєво
ALTER TABLE users ADD COLUMN full_name text;

-- Реліз 2: код пише в обидві колонки, читає стару. Backfill батчами у фоні
UPDATE users SET full_name = name
WHERE id BETWEEN 1 AND 10000 AND full_name IS NULL;
-- ... наступні діапазони з паузою

-- Реліз 3: код читає full_name. NOT NULL без довгого блокування (PostgreSQL)
ALTER TABLE users ADD CONSTRAINT users_full_name_nn CHECK (full_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_full_name_nn;   -- сканує без блокування запису
ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;      -- PG 12+ використає CHECK, без скану
ALTER TABLE users DROP CONSTRAINT users_full_name_nn;

-- Індекс без блокування запису
CREATE INDEX CONCURRENTLY users_full_name_idx ON users (full_name);

-- Реліз 4 (contract): код більше не торкається name
ALTER TABLE users DROP COLUMN name;

-- Запобіжник для будь-якого DDL: не чекати блокування вічно
SET lock_timeout = '3s';
SET statement_timeout = '30s';
Назву патерну expand and contract і розуміння, що під час деплою одночасно працюють стара й нова версія коду з однією базою.
Що кожна міграція має бути сумісна з попередньою версією коду: додавання nullable-колонки або нової таблиці безпечне, видалення й перейменування ні.
Що перейменування робиться в кілька релізів: нова колонка, подвійний запис, backfill батчами, перемикання читання, видалення старої.
Що деякі DDL блокують таблицю або переписують її: ALTER з NOT NULL без default, зміна типу, індекс без CONCURRENTLY у PostgreSQL, і як це обійти.
Що міграції запускаються один раз до перемикання трафіку, окремим кроком із lock_timeout, а не всередині кожного контейнера на старті.
Видаляти або перейменовувати колонку в тому ж релізі, що й код: під час перекриття версій одна з них падає.
Додавати NOT NULL колонку без default на таблиці з мільйонами рядків і ловити повне переписування або блокування.
Створювати індекс без CONCURRENTLY у PostgreSQL і блокувати запис на хвилини.
Робити backfill одним UPDATE на всю таблицю замість батчів і тримати блокування й WAL.
Запускати migrate у entrypoint кожного контейнера й отримувати гонку між репліками.
ПОРАДА

Ключове слово — expand and contract. Назвіть його, опишіть обидві фази і наведіть приклад з перейменуванням колонки на чотири релізи.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти
ПІДТЕМИ
Docker і шари образу Кеш білду CI-пайплайн Деплой без простою Логи й метрики Секрети
НА ЧОМУ ВАЛЯТЬСЯ

Від PHP-розробника не чекають рівня SRE — достатньо розуміти, що відбувається при білді й деплої. Питання переважно практичні: чому образ важить 1.2 ГБ і чому кеш злітає на кожному коміті.