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

DevOps для Middle: питання на співбесіду

4 питання рівня Middle з теми DevOps з розгорнутими відповідями, порадами та перевіркою.

Тема
Рівень
4 питання
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 для сповіщення. Це показує, що ви розрізняли ці інструменти на чергуванні, а не просто перелічили модні назви.

Сторінка питання →
Прогрес карток і тестів зберігається у профілі. Створити профіль·Увійти