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