Vodiči

Zero-Downtime PHP: Bulletproof Deployments, Safe Migrations, and Instant Rollbacks

PHP bez zastoja: neprobojna postavljanja, sigurne migracije i trenutačna vraćanja na prethodno stanje

Implementacija nije bez prekida rada samo zato što je upravitelj procesa ostao aktivan. Pravi je test stroži: zahtjevi koji stižu tijekom izdanja moraju dobiti jednu dosljednu verziju aplikacije, baza podataka mora ostati upotrebljiva i za stari i za novi kod, a neuspješna provjera stanja mora odmah vratiti prethodno izdanje.

Ovaj vodič gradi takav sustav za PHP 8.3, PHP-FPM, Nginx i PostgreSQL. Izdanja su nepromjenjivi direktoriji. Atomska promjena simboličke veze aktivira novi kod, migracije proširi-pa-sažmi čuvaju kompatibilnost, a odvojene provjere živosti i spremnosti otkrivaju različite vrste kvarova.

Preduvjeti i operativni model

Primjer koristi aktualnu distribuciju Linuxa s PHP-om 8.3, proširenjem PDO PostgreSQL, PHP-FPM-om, Nginxom, PostgreSQL-om, GNU coreutilsom, curl, tar i flock. Administrativna instalacija i promjene Nginxa zahtijevaju ovlasti roota. Rutinske implementacije trebaju se izvoditi pod namjenskim, neprivilegiranim računom deploy.

Aplikacija se nalazi pod /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 mora izgraditi artefakt, instalirati produkcijske ovisnosti pomoću zaključane datoteke ovisnosti, pokrenuti testove i zapakirati aplikaciju. Produkcija nikada ne mijenja izdanje nakon raspakiravanja. Dijeljeni podaci s mogućnošću pisanja pripadaju izvan direktorija izdanja ili, po mogućnosti, u pohranu objekata.

Ovaj dizajn pretpostavlja jedan poslužitelj, ali protokol izdanja radi i iza uravnoteživača opterećenja. Na više poslužitelja postupno isključujte promet i ažurirajte instance, uz istodobno održavanje svake promjene baze podataka kompatibilnom s cijelom flotom mješovitih verzija.

Izgradite malu produkcijsku aplikaciju

Stvorite public/index.php u radnom prostoru izgradnje. Izlaže krajnju točku živosti neovisnu o bazi podataka, krajnju točku spremnosti ovisnu o bazi podataka i primjer upita koji podnosi novi nullable stupac uveden kasnije.

<?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']);

PostgreSQL DSN treba uključivati ograničeno vrijeme čekanja povezivanja, na primjer pgsql:host=127.0.0.1;port=5432;dbname=acme;connect_timeout=2;application_name=acme_web. To vrijeme čekanja pokriva uspostavljanje veze, a ne upite. statement_timeout i lock_timeout na razini sesije neovisno ograničavaju izvršavanje upita i čekanja na zaključavanje.

Pohranite vjerodajnice u /etc/acme/app.env, u vlasništvu root:www-data s dozvolama 0640. Ne stavljajte tajne u artefakte, ispise okruženja, odgovore provjera stanja ni dnevnike implementacije.

Neka Nginx razriješi jedno nepromjenjivo izdanje po zahtjevu

Konfigurirajte virtualni poslužitelj sa stabilnom vezom current kao korijenom. Ključno je proslijediti $realpath_root PHP-FPM-u. Nginx razrješava simboličku vezu na aktivno izdanje, pa PHP prima putanju skripte specifičnu za izdanje umjesto dvosmislene putanje kroz 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;
    }
}

Provjerite s nginx -t prije ponovnog učitavanja Nginxa. Ograničavanje krajnjih točaka stanja na loopback sprječava da postanu javna dijagnostika. Ako vanjskom uravnoteživaču opterećenja treba pristup, dopustite samo njegovu izričitu mrežu i provedite isto ograničenje u vatrozidu poslužitelja. Javni promet treba završavati TLS na Nginxu ili pouzdanom uzvodnom proxyju.

PHP opcache može ostati omogućen. Različite stvarne putanje daju svakom izdanju različite ključeve predmemorije, izbjegavajući zastarjeli bajtkod povezan s ponovno korištenom putanjom. Postojeći PHP zahtjevi nastavljaju izvršavati kod već učitan iz starog izdanja, dok novi zahtjevi ulaze u novo izdanje.

Koristite migracije baze podataka proširi-pa-sažmi

Pretpostavimo da novo izdanje dodaje users.display_name. Dodavanje obveznog stupca, ponovno pisanje svakog retka i implementacija koda koji odmah o njemu ovisi stvorili bi rizik od zaključavanja i nesigurnu granicu povratka.

Migracija proširenja dodaje samo nullable stupac i primjenjuje kratka vremena čekanja baze podataka:

BEGIN;

SET LOCAL lock_timeout = '500ms';
SET LOCAL statement_timeout = '5s';

ALTER TABLE users
    ADD COLUMN IF NOT EXISTS display_name text;

COMMIT;

Ako se tablica ne može zaključati unutar 500 milisekundi, migracija ne uspijeva umjesto da čeka iza produkcijskog prometa. Implementacija se zaustavlja prije aktivacije. Transakcijski DDL PostgreSQL-a također sprječava djelomično primijenjenu promjenu u ovom primjeru.

Nova aplikacija čita COALESCE(display_name, email), dok stari kod ignorira stupac. Novi zapisivači trebaju popuniti i staru reprezentaciju i display_name. Postojeće retke popunite zasebno u malim transakcijama:

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;

Ponavljajte dok se ne vrati nijedan redak, pauzirajući između serija kada rastu kašnjenje replikacije, čekanja na zaključavanje ili latencija baze podataka. Svaka se serija zasebno potvrđuje; nemojte cijelo popunjavanje obuhvatiti jednom transakcijom.

Faza sažimanja dolazi u kasnijem izdanju, nakon što sve verzije aplikacije pišu stupac i provjera pokaže da nema null vrijednosti. Tek tada kod može ukloniti rezervnu vrijednost ili migracija može dodati ograničenje. Povrat baze podataka rijetko bi trebao značiti trenutno poništavanje sheme: sigurnije je vratiti kod aplikacije dok proširena shema ostaje kompatibilna unatrag.

Implementirajte atomsku implementaciju

Postavite sljedeću skriptu u vlasništvu roota, bez mogućnosti pisanja, na /usr/local/bin/deploy-acme. Računu za implementaciju odobrite pristup samo potrebnom direktoriju izdanja i datoteci vjerodajnica za implementaciju. Skripta prihvaća artefakt i identifikator izdanja koji sadrži samo slova, znamenke, točke, podvlake i crtice.

#!/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"

Privremena veza i current nalaze se na istom datotečnom sustavu, što GNU mv -T omogućuje atomsku zamjenu veze. Nikada nemojte usmjeriti current na direktorij koji se popunjava. Zaključavanje sprječava utrku dviju implementacija.

Privremeni PHP poslužitelj provjerava pokretanje kandidata i pristup bazi podataka prije aktivacije. Zahtjev nakon promjene zatim provjerava stvarnu putanju Nginxa i PHP-FPM-a. Neuspješna provjera nakon promjene vraća prethodnu vezu. Zadržite nekoliko provjereno dobrih izdanja kako bi operater mogao ručno izvršiti istu atomsku zamjenu veze ako sama automatizacija ne uspije.

Testirajte putanje kvara, a ne samo sretan put

Isprobajte cjeloviti protokol u stagingu s postavkama PHP-FPM-a i baze podataka sličnima produkcijskima:

  • Šaljite neprekidne zahtjeve tijekom aktivacije i potvrdite da je svaki odgovor uspješan i u cijelosti pripada jednom od izdanja.
  • Upotrijebite neispravnu lozinku baze podataka za kandidata i provjerite da preflight sprječava promjenu veze.
  • Zadržite sukobljeno zaključavanje tablice i potvrdite da migracija završava nakon isteka vremena čekanja na zaključavanje.
  • Uzrokujte neuspjeh provjere spremnosti nakon aktivacije i provjerite da se current vraća na prethodni cilj.
  • Istodobno pokrenite dvije implementacije i potvrdite da jedna završava bez izmjene izdanja.
  • Nakon aktivacije završite dugotrajni stari zahtjev i potvrdite da izdanje ostaje prisutno dok taj zahtjev ne završi.

Nemojte odmah izbrisati prethodno izdanje. PHP-FPM radnici možda još poslužuju zahtjeve čija je ulazna skripta došla iz njega. Čišćenje zadržavanja treba ukloniti samo starija, neaktivna izdanja nakon konzervativnog vremenskog prozora trajanja zahtjeva.

Promatrajte izdanje kao produkcijski događaj

Zabilježite identifikator izdanja u dnevnicima implementacije i izložite ga u strukturiranim dnevnicima aplikacije ili bezopasnom zaglavlju odgovora. Pratite neuspjehe spremnosti, stopu HTTP pogrešaka, latenciju, dubinu reda PHP-FPM-a, zasićenost radnika, čekanja na zaključavanje baze podataka, prekoračenja vremena izvršavanja naredbi, iscrpljivanje veza i kašnjenje replikacije.

Živost treba odgovoriti može li PHP proces izvršiti zahtjev. Spremnost treba odgovoriti može li instanca poslužiti koristan promet. Nemojte učiniti živost ovisnom o PostgreSQL-u: prekid rada baze podataka treba ukloniti promet kroz spremnost, a ne pokrenuti oluju ponovnih pokretanja PHP-FPM-a.

Ugađanje performansi i dalje je važno. Veličinu skupa radnika PHP-FPM-a odredite prema memoriji i kapacitetu veza baze podataka, a ne samo prema broju CPU-a. Održavajte opcache zagrijanim kroz kontrolirani skup canary zahtjeva ako je latencija hladnog pokretanja značajna. Izbjegavajte široke upite spremnosti; SELECT 1 dokazuje povezivost bez pretvaranja provjera stanja u radno opterećenje.

Završni kontrolni popis provjere

  • Artefakt je nepromjenjiv, testiran i označen jedinstvenim ID-om izdanja.
  • Nginx prosljeđuje stvarnu putanju specifičnu za izdanje PHP-FPM-u.
  • Aktivacija je atomska zamjena simboličke veze na istom datotečnom sustavu.
  • Čekanja veze, upita, zaključavanja, FastCGI-ja, curla i implementacije neovisno su ograničena.
  • Migracije su aditivne i kompatibilne i sa starim i s novim kodom.
  • Pozadinska popunjavanja izvršavaju se u malim, nadziranima transakcijama izvan aktivacije.
  • Provjere spremnosti kandidata i nakon promjene obje prolaze.
  • Neuspješna aktivacija automatski vraća prethodno izdanje aplikacije.
  • Krajnje točke stanja i tajne kontroliraju se pristupom.
  • Stara izdanja ostaju dostupna dok ih zahtjevi u tijeku više ne mogu referencirati.

Pouzdana implementacija manje se svodi na pametnu naredbu, a više na očuvanje kompatibilnosti na svakoj granici. Nepromjenjiv kod čini aktivaciju atomskom. Migracije proširi-pa-sažmi omogućuju povratak. Ograničene provjere stanja pretvaraju neizvjesnost u jasnu odluku. Kada se ti dijelovi međusobno pojačavaju, implementacija prestaje biti restart pun nade i postaje rutinska, reverzibilna produkcijska operacija.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.