<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
DEVOPS · SENIOR ЧАСТО ПИТАЮТЬ

Як викотити PHP-застосунок без простою: релізи через симлінк, контейнери, кеші?

Новий реліз збирається і прогрівається в окремій теці, трафік перемикається однією атомарною заміною симлінка (або поступовою заміною контейнерів за readiness-пробою), після чого скидаються realpath cache й OPcache і перезапускаються воркери; відкат - та сама операція у зворотний бік.

Симлінк перемкнули, а сайт ще п'ять хвилин віддає старий код. Де проблема?
Чому в Deployer симлінк не просто `ln -sfn`, а створення збоку плюс `mv`?
Rolling update у Kubernetes, під час деплою частина запитів падає з 502. Чому?
Як відкотити реліз за тридцять секунд і що саме при цьому не відкотиться?
деплой zero-downtime OPcache Deployer Kubernetes

Zero downtime означає рівно дві гарантії: жоден запит не потрапив у напівскопійовану теку і жоден не отримав відмову в з'єднанні. Першу схема з симлінками дає безкоштовно: новий реліз збирається у releases/20261001120000, поки трафік іде в current, що вказує на попередню теку. Друга тримається на тому, що заміна current виконується однією операцією rename(2): ядро підміняє запис у теці, і будь-який open() бачить або старий шлях, або новий, без проміжного стану. Саме тому Deployer не робить ln -sf поверх наявного симлінка, а створює лінк збоку і переносить його mv: ln -f всередині реалізований як unlink плюс symlink, тобто існує проміжок у кілька мікросекунд, коли current немає взагалі. Те, що не має версії, живе в shared/: .env, storage, завантажені файли. Теку bootstrap/cache у shared не кладуть, бо тоді прогрів наступного релізу перепише конфіг, з якого в цю мить працює попередній.

Перемкнути шлях ще не означає перемкнути код. Воркер php-fpm тримає resolved шлях у realpath cache (realpath_cache_ttl типово 120 секунд), а OPcache у продакшені зазвичай налаштований з opcache.validate_timestamps=0 і взагалі не цікавиться часом зміни файлів. Якщо nginx передає SCRIPT_FILENAME з буквальним /var/www/app/current/public/index.php, ключі OPcache у старого й нового релізу однакові, і сайт продовжує віддавати старий байткод. Робоча конфігурація: root /var/www/app/current/public плюс fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name і DOCUMENT_ROOT $realpath_root, щоб FPM отримував розкритий шлях конкретного релізу. Після цього kill -USR2 на мастер FPM дає graceful reload, старі воркери доживають поточні запити і вмирають разом із кешем, нові стартують з чистим. Там, де reload недоступний, той самий ефект дає cachetool opcache:reset через FastCGI-сокет. Прогрів кешів застосунку (config:cache, route:cache, view:cache, --optimize-autoloader, збірка Vite) робиться до перемикання, у теці релізу: перший запит не має компілювати Blade-шаблони.

У контейнерах симлінка немає, але проблема лишається: роль current переходить до Service і набору endpoints. Образ позначають незмінним тегом, краще digest, бо :latest ламає і відтворюваність, і відкат. Дефолтна стратегія RollingUpdate з maxUnavailable: 25% на малій кількості реплік означає, що pod гаситься раніше, ніж новий встиг прийняти трафік, тому ставлять maxUnavailable: 0 і maxSurge: 1. Новий pod отримує запити тільки після readiness-проби; у Laravel 11+ для цього вже є маршрут /up, який включається через withRouting(health: '/up'). Пробу тримають дешевою і без залежностей: якщо readiness ходить у базу, одна її затримка одночасно виводить з балансувальника всі поди і перетворює уповільнення на повну відмову. Для довгого старту є окрема startupProbe, щоб liveness не вбивала контейнер, який ще прогрівається. На виході з ротації потрібні preStop з короткою паузою (видалення з endpoints і SIGTERM летять паралельно, тому кілька запитів ще встигають прийти) і STOPSIGNAL SIGQUIT у Dockerfile: php-fpm на SIGTERM завершується негайно і рве активні запити, graceful для нього саме SIGQUIT.

Довгоживучі процеси деплой не чіпає жодним із цих механізмів. Воркер черги, запущений три години тому, тримає в пам'яті автозавантажувач попереднього релізу і буде обробляти джоби старим кодом, поки не перезапуститься. php artisan queue:restart кладе в кеш мітку часу, і кожен воркер виходить після поточної джоби, а підняти його назад має supervisor або systemd. Для Octane те саме робить octane:reload. У момент деплою в системі одночасно працюють дві версії коду: старі воркери, старі поди, браузери з уже завантаженим HTML. Тому сусідні релізи мають бути сумісні в обидва боки. Джоба, поставлена новим кодом, повинна десеріалізуватися старим, а відповідь API старого формату не повинна ламати свіжий фронтенд.

Відкат у цій схемі - та сама атомарна операція у зворотний бік: rename на попередню теку, reload FPM, queue:restart. У Deployer це dep rollback, у Kubernetes kubectl rollout undo або явний деплой попереднього digest. Займає секунди, бо попередній реліз лежить на диску цілим, із власним vendor/ і власною збіркою assets; саме тому keep_releases тримають на десятці, а не на одиниці. Межа відкату проходить там, де починається стан. Міграції вже застосовані, і симлінк їх не повертає, тому зміни схеми роблять у фази expand and contract окремо від викочування коду. Хешовані asset-файли попередньої збірки мусять лишатися доступними, інакше браузер із закешованим HTML отримає 404 на chunk. Вебхуки, листи й платежі, надіслані зламаним релізом, відкатові не підлягають взагалі. Швидкий відкат вартий того, щоб тримати його робочим і регулярно перевіряти, але прикриває він лише помилки в коді, а не помилки в даних.

# Розкладка: релізи поруч, current - симлінк на активний
#   /var/www/app/releases/20261001120000/   новий код
#   /var/www/app/shared/{.env,storage}      переживає релізи
#   /var/www/app/current -> releases/...    те, на що дивиться nginx

RELEASE=/var/www/app/releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 -b "$1" git@github.com:acme/app.git "$RELEASE"
ln -s /var/www/app/shared/.env    "$RELEASE/.env"
ln -s /var/www/app/shared/storage "$RELEASE/storage"   # bootstrap/cache НЕ шарять

cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan migrate --force --isolated   # до перемикання, окремим кроком

# Атомарна заміна. ln -sfn робить unlink+symlink, тобто є мить, коли current
# не існує; rename(2) заміняє запис одним кроком. Без -n чи -T лінк взагалі
# ліг би ВСЕРЕДИНУ теки попереднього релізу.
ln -s "$RELEASE" /var/www/app/current.tmp
mv -T /var/www/app/current.tmp /var/www/app/current

# Нові запити побачать новий код лише після скидання кешів шляхів і OPcache.
# opcache_reset() з CLI воркерів FPM не чіпає: або reload, або cachetool.
kill -USR2 "$(cat /run/php/php8.4-fpm.pid)"   # старі воркери доживають запит
php artisan queue:restart                     # воркери старого коду вийдуть
curl -fsS --max-time 5 https://app.example.com/up || echo "health check впав"

# Відкат: той самий rename на попередній реліз плюс той самий reload
#   PREV=$(ls -1dt /var/www/app/releases/* | sed -n 2p)
Розкладку releases/shared/current і те, що перемикання - це один `rename(2)` поверх симлінка, а не послідовність unlink+symlink з вікном, коли `current` не існує.
Що після перемикання старий код ще живе в realpath cache FPM і в OPcache з `validate_timestamps=0`, тому перемикання завжди в парі з graceful reload або `cachetool`, а nginx має передавати `SCRIPT_FILENAME $realpath_root$fastcgi_script_name`.
Що `composer install`, `npm run build` і `config:cache`/`route:cache`/`view:cache` виконуються всередині нового релізу до перемикання, а `bootstrap/cache` не входить у shared-теки.
Для контейнерів: незмінний тег або digest образу, `maxUnavailable: 0`, readiness-проба на окремому endpoint (`/up` у Laravel 11+), `preStop` з паузою перед SIGTERM і `STOPSIGNAL SIGQUIT` для php-fpm.
Що відкат симлінка чи попереднього digest не відкочує міграції, формат payload у чергах і серіалізовані об'єкти в кеші й сесіях, тому сумісність релізів планується наперед.
Писати `ln -sf release current` без `-n`: симлінк ляже всередину теки попереднього релізу, а не замінить його.
Викликати `opcache_reset()` з CLI і чекати, що воркери FPM побачать новий код: CLI має власний процес і власний кеш.
Прогрівати `config:cache` після перемикання або тримати `bootstrap/cache` у shared: попередній реліз починає читати конфіг наступного.
Вішати readiness-пробу на endpoint, який ходить у базу: одна затримка БД виводить з балансувальника всі поди одночасно.
Забути `queue:restart` і `octane:reload`: воркери годинами виконують джоби кодом попереднього релізу.
ПОРАДА

Опишіть перемикання як одну системну операцію: `rename(2)` поверх симлінка плюс reload FPM. Хто називає другу половину, той реально деплоїв; хто згадує лише симлінк, той читав статтю.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`rename(2)` поверх симлінка атомарний, проблема не в ньому: старий код живе в realpath cache кожного воркера (типово 120 секунд) і в OPcache, який у продакшені не стежить за часом зміни файлів. Тому перемикання завжди в парі з `kill -USR2` або `cachetool opcache:reset`, а nginx має передавати `$realpath_root`.