PHP без прекини: Непробојни распоредувања, безбедни миграции и инстант враќања
Распоредувањето не е без прекин само затоа што менаџерот на процеси останал активен. Вистинскиот тест е построг: барањата што пристигнуваат за време на изданието мора да добијат една доследна верзија на апликацијата, базата на податоци мора да остане употреблива и за стариот и за новиот код, а неуспешната проверка на здравјето мора веднаш да го врати претходното издание.
Ова упатство го гради тој систем за PHP 8.3, PHP-FPM, Nginx и PostgreSQL. Изданијата се непроменливи директориуми. Атомска промена на симболичка врска активира нов код, миграциите со проширување и стеснување зачувуваат компатибилност, а одделните проверки за активност и подготвеност откриваат различни класи на неуспех.
Предуслови и оперативен модел
Примерот користи актуелна дистрибуција на Linux со PHP 8.3, PDO-екстензијата за PostgreSQL, PHP-FPM, Nginx, PostgreSQL, GNU coreutils, curl, tar и flock. Административната инсталација и промените во Nginx бараат root-привилегии. Рутинските распоредувања треба да се извршуваат како наменска, непривилегирана сметка deploy.
Апликацијата се наоѓа во /srv/acme:
/srv/acme/
├── current -> /srv/acme/releases/20260811T143000Z-a1b2c3d
├── releases/
│ └── 20260811T143000Z-a1b2c3d/
│ ├── public/index.php
│ ├── migrations/002_add_display_name.sql
│ └── vendor/
└── deploy.lock
/etc/acme/
├── app.env
└── deploy.env
/usr/local/bin/
└── deploy-acme
CI мора да го изгради артефактот, да инсталира продукциски зависности со заклучена датотека за зависности, да изврши тестови и да ја спакува апликацијата. Продукцијата никогаш не менува издание по распакувањето. Споделените податоци за запишување припаѓаат надвор од директориумите на изданијата или, по можност, во складиште за објекти.
Овој дизајн претпоставува еден хост, но протоколот за издание работи и зад балансер на оптоварување. На повеќе хостови, постепено испразнете ги и ажурирајте ги инстанците, притоа задржувајќи ја секоја промена во базата на податоци компатибилна со целата флота со мешани верзии.
Изградете мала продукциска апликација
Создадете public/index.php во работниот простор за изградба. Тој изложува крајна точка за активност независна од базата на податоци, крајна точка за подготвеност зависна од базата на податоци и пример-запитување што ја толерира новата nullable колона воведена подоцна.
<?php
declare(strict_types=1);
function respond(int $status, array $body): never
{
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
function database(): PDO
{
$config = parse_ini_file('/etc/acme/app.env', false, INI_SCANNER_RAW);
if ($config === false) {
throw new RuntimeException('Application configuration is unavailable');
}
$pdo = new PDO(
$config['DATABASE_DSN'],
$config['DATABASE_USER'],
$config['DATABASE_PASSWORD'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$pdo->exec("SET statement_timeout = '2000ms'");
$pdo->exec("SET lock_timeout = '500ms'");
$pdo->exec("SET idle_in_transaction_session_timeout = '3000ms'");
return $pdo;
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if ($path === '/health/live') {
respond(200, ['status' => 'alive']);
}
if ($path === '/health/ready') {
try {
database()->query('SELECT 1')->fetchColumn();
respond(200, ['status' => 'ready']);
} catch (Throwable) {
respond(503, ['status' => 'not_ready']);
}
}
if ($path === '/users') {
try {
$rows = database()->query(
"SELECT id, email, COALESCE(display_name, email) AS display_name
FROM users
ORDER BY id
LIMIT 100"
)->fetchAll();
respond(200, ['users' => $rows]);
} catch (Throwable $exception) {
error_log(sprintf(
'request_failed type=%s path=%s',
$exception::class,
$path
));
respond(500, ['error' => 'internal_error']);
}
}
respond(404, ['error' => 'not_found']);
DSN-врската за PostgreSQL треба да содржи ограничен тајмаут за поврзување, на пример pgsql:host=127.0.0.1;port=5432;dbname=acme;connect_timeout=2;application_name=acme_web. Тој тајмаут го опфаќа воспоставувањето на врската, не запитувањата. statement_timeout и lock_timeout на ниво на сесија независно го ограничуваат извршувањето на запитувањата и чекањето заклучување.
Чувајте ги акредитивите во /etc/acme/app.env, во сопственост на root:www-data со режим 0640. Не ставајте тајни во артефакти, исфрлања на околината, одговори за здравје или дневници на распоредување.
Нека Nginx разрешува едно непроменливо издание по барање
Конфигурирајте го виртуелниот хост со стабилната врска current како негов корен. Клучно, проследете го $realpath_root до PHP-FPM. Nginx ја разрешува симболичката врска до активното издание, па PHP добива патека на скрипта специфична за изданието, наместо двосмислена патека преку current.
server {
listen 80;
server_name app.example.com;
root /srv/acme/current/public;
index index.php;
location / {
try_files $uri /index.php?$query_string;
}
location = /index.php {
internal;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_connect_timeout 2s;
fastcgi_send_timeout 10s;
fastcgi_read_timeout 10s;
}
location ~ ^/health/(live|ready)$ {
allow 127.0.0.1;
allow ::1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_connect_timeout 2s;
fastcgi_send_timeout 5s;
fastcgi_read_timeout 5s;
}
location ~ \.php$ {
return 404;
}
}
Потврдете со nginx -t пред повторно вчитување на Nginx. Ограничувањето на крајните точки за здравје на loopback спречува тие да станат јавна дијагностика. Ако на надворешен балансер на оптоварување му е потребен пристап, дозволете само неговата експлицитна мрежа и применете го истото ограничување во заштитниот ѕид на хостот. Јавниот сообраќај треба да завршува TLS во Nginx или во доверлив upstream proxy.
PHP opcache може да остане овозможен. Различните реални патеки му даваат на секое издание различни клучеви на кешот, избегнувајќи застарен бајткод поврзан со повторно употребена патека. Постојните PHP-барања продолжуваат да извршуваат код веќе вчитан од старото издание, додека новите барања влегуваат во новото.
Користете миграции на база на податоци со проширување и стеснување
Да претпоставиме дека новото издание додава users.display_name. Додавање задолжителна колона, препишување на секој ред и распоредување код што веднаш зависи од неа би создало и ризик од заклучување и небезбедна граница за враќање назад.
Миграцијата за проширување додава само nullable колона и применува кратки тајмаути за базата на податоци:
BEGIN;
SET LOCAL lock_timeout = '500ms';
SET LOCAL statement_timeout = '5s';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS display_name text;
COMMIT;
Ако табелата не може да се заклучи во рок од 500 милисекунди, миграцијата не успева наместо да чека зад продукцискиот сообраќај. Распоредувањето запира пред активирањето. Трансакцискиот DDL на PostgreSQL, исто така, спречува делумно применета промена во овој пример.
Новата апликација чита COALESCE(display_name, email), додека стариот код ја игнорира колоната. Новите запишувачи треба да ги пополнуваат и старата претстава и display_name. Пополнувајте ги постојните редови одделно во мали трансакции:
WITH batch AS (
SELECT id
FROM users
WHERE display_name IS NULL
ORDER BY id
LIMIT 500
FOR UPDATE SKIP LOCKED
)
UPDATE users AS u
SET display_name = split_part(u.email, '@', 1)
FROM batch
WHERE u.id = batch.id
RETURNING u.id;
Повторувајте додека не се вратат редови, правејќи пауза меѓу сериите кога доцнењето на репликацијата, чекањето заклучување или латентноста на базата на податоци растат. Секоја серија се потврдува независно; не го обвиткувајте целото пополнување во една трансакција.
Фазата на стеснување доаѓа во подоцнежно издание, откако сите верзии на апликацијата ја запишуваат колоната и проверката покажува дека нема null-вредности. Дури тогаш кодот може да го отстрани резервното однесување или миграцијата да додаде ограничување. Враќањето на базата на податоци ретко треба да значи веднаш обратна промена на шемата: побезбедно е да се врати кодот на апликацијата додека проширената шема останува назадно компатибилна.
Имплементирајте атомско распоредување
Поставете ја следната скрипта, во сопственост на root и без можност за запишување, во /usr/local/bin/deploy-acme. Дајте ѝ на сметката за распоредување пристап само до потребниот директориум со изданија и датотеката со акредитиви за распоредување. Скриптата прифаќа артефакт и идентификатор на издание што содржи само букви, цифри, точки, долни црти и цртички.
#!/usr/bin/env bash
set -Eeuo pipefail
umask 027
artifact=${1:?usage: deploy-acme ARTIFACT RELEASE_ID}
release_id=${2:?usage: deploy-acme ARTIFACT RELEASE_ID}
if [[ ! $release_id =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "invalid release identifier" >&2
exit 64
fi
base=/srv/acme
release="$base/releases/$release_id"
current="$base/current"
candidate_link="$base/.current-$release_id"
exec 9>"$base/deploy.lock"
flock -n 9 || {
echo "another deployment is running" >&2
exit 75
}
[[ -f $artifact ]] || {
echo "artifact not found" >&2
exit 66
}
[[ ! -e $release ]] || {
echo "release already exists" >&2
exit 73
}
mkdir -m 0750 "$release"
tar --extract --gzip \
--file "$artifact" \
--directory "$release" \
--no-same-owner \
--no-same-permissions
[[ -f "$release/public/index.php" ]] || {
echo "invalid artifact" >&2
exit 65
}
php -d display_errors=0 -l "$release/public/index.php"
set -a
. /etc/acme/deploy.env
set +a
for migration in "$release"/migrations/*.sql; do
[[ -e $migration ]] || continue
psql "$DATABASE_URL" \
--set ON_ERROR_STOP=1 \
--file "$migration"
done
php -S 127.0.0.1:9081 -t "$release/public" \
>"/tmp/acme-preflight-$release_id.log" 2>&1 &
preflight_pid=$!
cleanup() {
kill "$preflight_pid" 2>/dev/null || true
wait "$preflight_pid" 2>/dev/null || true
}
trap cleanup EXIT
for attempt in 1 2 3 4 5; do
if curl --fail --silent --show-error \
--connect-timeout 1 --max-time 3 \
http://127.0.0.1:9081/health/ready >/dev/null; then
break
fi
[[ $attempt -lt 5 ]] || exit 1
sleep 1
done
previous=
if [[ -L $current ]]; then
previous=$(readlink -f "$current")
fi
ln -s "$release" "$candidate_link"
mv -Tf "$candidate_link" "$current"
if ! curl --fail --silent --show-error \
--connect-timeout 1 --max-time 5 \
--header 'Host: app.example.com' \
http://127.0.0.1/health/ready >/dev/null; then
if [[ -n $previous && -d $previous ]]; then
ln -s "$previous" "$candidate_link"
mv -Tf "$candidate_link" "$current"
fi
echo "activation failed; previous release restored" >&2
exit 1
fi
echo "activated $release_id"
Привремената врска и current се наоѓаат на истиот датотечен систем, што му овозможува на GNU mv -T атомски да ја замени врската. Никогаш не насочувајте го current кон директориум што се пополнува. Заклучувањето спречува две распоредувања да влезат во трка.
Привремениот PHP-сервер ги проверува bootstrap-от и пристапот до базата на податоци на кандидатот пред активирањето. Барањето по префрлањето потоа ја проверува вистинската патека на Nginx и PHP-FPM. Неуспешна проверка по префрлањето ја враќа претходната врска. Задржете неколку познато добри изданија за оператор да може рачно да ја изврши истата атомска замена на врската ако самата автоматизација не успее.
Тестирајте ги патеките на неуспех, не само успешната патека
Вежбајте го целосниот протокол во staging со поставки за PHP-FPM и база на податоци слични на продукциските:
- Испраќајте непрекинати барања за време на активирањето и потврдете дека секој одговор е успешен и целосно припаѓа или на едното или на другото издание.
- Користете неважечка лозинка за базата на податоци за кандидатот и потврдете дека претпроверката ја спречува промената на врската.
- Задржете конфликтно заклучување на табела и потврдете дека миграцијата излегува по истекот на нејзиниот тајмаут за заклучување.
- Направете проверката на подготвеност по активирањето да не успее и потврдете дека
currentсе враќа на претходната цел. - Извршете две распоредувања истовремено и потврдете дека едното излегува без да ги измени изданијата.
- Прекинете старо барање со долго траење по активирањето и потврдете дека изданието останува присутно додека тоа барање не заврши.
Не го бришете претходното издание веднаш. PHP-FPM работниците можеби сè уште опслужуваат барања чија влезна скрипта потекнува од него. Чистењето според задржување треба да отстранува само постари, неактивни изданија по конзервативен временски прозорец за траење на барањата.
Набљудувајте го изданието како продукциски настан
Забележете го идентификаторот на изданието во дневниците на распоредување и изложете го во структурирани дневници на апликацијата или во безопасно заглавие на одговор. Следете неуспеси на подготвеност, стапка на HTTP-грешки, латентност, длабочина на редицата на PHP-FPM, заситеност на работници, чекања на заклучување во базата на податоци, тајмаути на изјави, исцрпување на врски и доцнење на репликација.
Активноста треба да одговори дали PHP-процесот може да изврши барање. Подготвеноста треба да одговори дали инстанцата може да опслужува корисен сообраќај. Не правете активноста да зависи од PostgreSQL: прекин во базата на податоци треба да го отстрани сообраќајот преку подготвеноста, а не да предизвика бура од рестартирања на PHP-FPM.
Подесувањето на перформансите сè уште е важно. Одредете ја големината на пулот со работници на PHP-FPM според меморијата и капацитетот на врски со базата на податоци, а не само според бројот на CPU-јадра. Одржувајте го opcache загреан преку контролиран сет canary-барања ако латентноста при ладно стартување е значајна. Избегнувајте широки запитувања за подготвеност; SELECT 1 докажува поврзаност без да ги претвора проверките за здравје во оптоварување.
Конечна контролна листа за потврда
- Артефактот е непроменлив, тестиран и идентификуван со единствен ID на издание.
- Nginx проследува реална патека специфична за изданието до PHP-FPM.
- Активирањето е атомска замена на симболичка врска на ист датотечен систем.
- Чекањата за врска, запитување, заклучување, FastCGI, curl и распоредување се независно ограничени.
- Миграциите се адитивни и компатибилни и со стариот и со новиот код.
- Пополнувањата се извршуваат во мали, набљудливи трансакции надвор од активирањето.
- И проверките на подготвеност на кандидатот и по префрлањето поминуваат успешно.
- Неуспешно активирање автоматски го враќа претходното издание на апликацијата.
- Крајните точки за здравје и тајните се контролирани со пристап.
- Старите изданија остануваат достапни додека барањата во тек не можат да се повикуваат на нив.
Сигурното распоредување помалку зависи од паметна команда, а повеќе од зачувување компатибилност на секоја граница. Непроменливиот код го прави активирањето атомско. Миграциите со проширување и стеснување го прават враќањето назад возможно. Ограничените проверки за здравје ја претвораат неизвесноста во јасна одлука. Кога тие делови меѓусебно се зајакнуваат, распоредувањето престанува да биде рестартирање со надеж и станува рутинска, обратлива продукциска операција.