Deployer dla Laravela: jeden build, smoke testy i rollback bez człowieka

12 min. czytania

Uruchom dep deploy ze standardowym recipe/laravel.php, a na produkcji wydarzą się trzy rzeczy, których nie chcę. Composer działa na serwerze, więc kod, który obsługuje ruch, nigdy nie był tym kodem, który przetestowało CI. artisan:migrate odpala się przy każdym deployu, niezależnie od tego, czy ktoś zatwierdził migrację w tym wydaniu. A jeśli nowy release po przełączeniu current zaczyna zwracać 500, Deployer melduje sukces i serwuje go dalej, dopóki człowiek nie wpisze dep rollback.

W moim starterze Laravela zostawiłem Deployerowi to, co robi dobrze (katalogi release'ów, pliki współdzielone, blokadę deployu, atomowe przełączenie, sprzątanie), a resztę wymieniłem. Ten post przechodzi przez ten deploy.php. Bloki PHP od sekcji „Host i ustawienia” czytane po kolei tworzą kompletną receptę. Cel: Deployer 8 (v8.0.5, stan na 2026-10-27), jedna VM z Nginx i PHP-FPM, a GitHub Actions jako jedyne miejsce, w którym jest klucz SSH.

Co naprawdę robi standardowa recepta

Zanim cokolwiek nadpiszesz, przeczytaj receptę. W Deployerze 8 recipe/laravel.php definiuje deploy tak:

php
task('deploy', [
    'deploy:prepare',
    'deploy:vendors',
    'artisan:storage:link',
    'artisan:optimize',
    'artisan:migrate',
    'deploy:publish',
    'artisan:reload',
]);

deploy:prepare to grupa: deploy:info, deploy:setup, deploy:lock, deploy:release, deploy:update_code, deploy:env, deploy:shared, deploy:writable. deploy:publish to deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success.

Ta struktura jest najcenniejsza. Każdy krok to nazwany task, a ponowna definicja taska o tej samej nazwie podmienia jego ciało. Dlatego zostawiam grupę, podmieniam deploy:update_code i deploy:vendors i piszę własną listę deploy.

Na serwerze Deployer układa katalogi tak:

text
/var/www/starter/
├── .dep/                 # blokada, log release'ów, moje pliki stanu
├── current -> releases/42
├── releases/
│   ├── 38/ … 42/
└── shared/
    ├── .env
    └── storage/

Host i ustawienia przychodzą ze środowiska

W repozytorium nie ma nic o serwerze docelowym. Workflow przekazuje host, użytkownika i ścieżkę w zmiennych środowiskowych, a recepta bez nich nie wystartuje.

php
<?php

declare(strict_types=1);

namespace Deployer;

require 'recipe/laravel.php';

/** @return non-empty-string */
function deploymentEnvironment(string $name): string
{
    $value = getenv($name);

    if ($value === false || $value === '') {
        throw new \RuntimeException(sprintf('Brak wymaganej zmiennej środowiskowej %s.', $name));
    }

    return $value;
}

host(deploymentEnvironment('DEPLOY_ENVIRONMENT'))
    ->setHostname(deploymentEnvironment('DEPLOY_HOST'))
    ->setRemoteUser(deploymentEnvironment('DEPLOY_USER'))
    ->set('deploy_path', deploymentEnvironment('DEPLOY_PATH'));

set('application', 'starter');
set('keep_releases', 5);
set('shared_files', ['.env']);
set('shared_dirs', ['storage']);
set('writable_dirs', ['bootstrap/cache', 'storage']);
set('writable_mode', 'acl');
set('writable_use_sudo', false);
set('artifact_path', deploymentEnvironment('DEPLOY_ARTIFACT'));
set('artifact_sha256', deploymentEnvironment('DEPLOY_ARTIFACT_SHA256'));

.env leży w shared/, a deploy:shared linkuje go do każdego release'u. keep_releases mówi, ile katalogów zostawi deploy:cleanup, czyli też, jak daleko wstecz da się cofnąć.

Build w CI, na serwer leci tarball

Job budujący w GitHub Actions odpala testy, buduje assety Vite i bundle SSR, potem robi composer install --no-dev --classmap-authoritative i pakuje wynik: git archive plus vendor/, public/build i bootstrap/ssr. Obok tarballa zapisuje jego SHA-256 i mały manifest (commit, wersje PHP i Node, hashe lockfile'ów).

Na serwerze nie ma Gita, Composera ani Node'a. deploy:update_code zamienia się w upload:

php
task('deploy:update_code', function (): void {
    $artifactPath = get('artifact_path');

    if (! is_file($artifactPath)) {
        throw new \RuntimeException(sprintf('Brak artefaktu release: %s.', $artifactPath));
    }

    upload($artifactPath, '{{release_path}}/release.tar.gz');
    run('test "$(sha256sum {{release_path}}/release.tar.gz | cut -d " " -f 1)" = "{{artifact_sha256}}"');
    run('tar -xzf {{release_path}}/release.tar.gz -C {{release_path}} --no-same-owner');
    run('rm {{release_path}}/release.tar.gz');
    run('printf "%s\n" "{{release_name}}" > {{release_path}}/RELEASE');
});

task('deploy:vendors', function (): void {
    run('test -f {{release_path}}/vendor/autoload.php');
});

Suma kontrolna jest porównywana na serwerze, po uploadzie, więc ucięty transfer wywraca deploy, zanim cokolwiek zostanie rozpakowane. deploy:vendors niczego już nie instaluje, tylko sprawdza, czy artefakt przyniósł własny vendor/. Plik RELEASE czyta config/ops.php, dzięki czemu każda linia logu ma numer release'u.

Migracje tylko na wyraźne życzenie

Standardowy artisan:migrate odpala się zawsze. Ja chcę, żeby człowiek przy każdym deployu zdecydował, że to wydanie może ruszyć schemat:

php
task('deploy:migrate', function (): void {
    if (getenv('DEPLOY_ALLOW_MIGRATIONS') !== 'true') {
        writeln('<comment>Migracje pominięte: DEPLOY_ALLOW_MIGRATIONS=true jest wymagane.</comment>');

        return;
    }

    run('cd {{release_path}} && php artisan migrate --force --no-interaction');
});

Flaga to input typu boolean w workflow, domyślnie false. Migracje lecą z nowego release'u przed przełączeniem, kiedy stary release dalej obsługuje ruch. To działa tylko wtedy, gdy migracja jest addytywna, czyli zgodnie z zasadą expand/contract z migracji bazy danych bez przestoju. Ta recepta nigdy nie cofa schematu. Zła migracja dostaje forward fix.

Sprawdź kandydata, zanim dostanie ruch

W tym momencie nowy release ma zbudowane cache (artisan:config:cache, route:cache, view:cache, event:cache), a current dalej wskazuje stary. To najtańszy moment na porażkę:

php
task('deploy:smoke', function (): void {
    run('test -f {{release_path}}/public/build/manifest.json');
    run('test -f {{release_path}}/bootstrap/ssr/app.js');
    run('cd {{release_path}} && php artisan about --only=environment --no-interaction');
    run('cd {{release_path}} && php artisan ops:readiness --no-interaction');
});

php artisan about bootuje framework z cache'owanym configiem release'u, więc zepsuty service provider albo plik konfiguracyjny wołający nieistniejącą klasę wyłoży się tutaj. ops:readiness to komenda startera: z CLI, na nowym kodzie, robi te same kontrole co endpoint /health/ready (baza, Redis, zapisywalny storage). Jeśli któraś linia zwróci kod różny od zera, Deployer się zatrzymuje, odpala deploy:failed, a użytkownicy tego release'u nie zobaczyli.

Zapamiętaj, który release był zdrowy

Wbudowany dep rollback bierze poprzedni katalog z logu release'ów. Przy automatycznym rollbacku chcę czegoś ściślejszego: release'u, który minutę temu obsługiwał ruch.

php
task('deploy:remember_healthy', function (): void {
    if (test('[ -L {{current_path}} ]')) {
        $healthy = run('readlink -f {{current_path}}');
        set('healthy_release', $healthy);
        run('mkdir -p {{deploy_path}}/.dep && printf "%s\n" '.escapeshellarg($healthy).' > {{deploy_path}}/.dep/healthy_release');
    } else {
        set('healthy_release', '');
    }
});

Trafia to też do .dep/healthy_release, więc po przerwanym deployu operator widzi cel bez czytania logów CI. Przy pierwszym deployu nie ma current, więc nie ma do czego wracać.

Przełącz atomowo i niech Nginx za tym nadąży

deploy:symlink w Deployerze 8 robi mv -T {{deploy_path}}/release {{current_path}}, jeśli mv obsługuje --no-target-directory. Zmiana nazwy jest atomowa, więc żaden request nie trafi na brakujące current.

Haczykiem jest PHP-FPM. Jeśli Nginx przekazuje SCRIPT_FILENAME ze ścieżką zawierającą current, OPcache po przełączeniu dalej serwuje stare pliki. Dokumentacja Deployera zaleca rozwiązanie symlinka w Nginx zamiast przeładowywania FPM i tak robi vhost startera:

nginx
root /var/www/starter/current/public;

location = /index.php {
    include fastcgi_params;
    fastcgi_pass unix:/run/php/starter.sock;
    fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
}

Każdy request jest przypinany do releases/42/public w chwili, gdy Nginx rozwiązuje ścieżkę, a requesty już trwające kończą się na releases/41.

Zrestartuj to, co trzyma kod w pamięci

Horizon, scheduler i serwer SSR Inertii ładują kod raz i go trzymają. Każdy działa jako unit systemd z WorkingDirectory=/var/www/starter/current i Restart=always, więc wystarczy je zatrzymać. systemd uruchomi je ponownie z nowego current:

php
task('deploy:restart_workers', function (): void {
    run('cd {{current_path}} && php artisan horizon:terminate --no-interaction');
    run('cd {{current_path}} && php artisan schedule:interrupt --no-interaction');
    run('cd {{current_path}} && (php artisan inertia:stop-ssr --no-interaction || true)');
});

horizon:terminate pozwala workerom dokończyć bieżący job. Zatrzymanie SSR może się nie udać, bo na świeżym hoście procesu SSR jeszcze nie ma. Z tego samego powodu joby w kolejce muszą być zgodne między kolejnymi release'ami: job wrzucony przez release 41 może podjąć worker na release 42. Więcej o tym w kolejkach Laravela w głąb.

Jeden automatyczny rollback, nigdy dwa

Po przełączeniu recepta pyta prawdziwą stronę, czy jest gotowa. Request idzie do lokalnego Nginx, ale z publiczną nazwą hosta przypiętą przez curl --resolve, więc TLS i TrustHosts zachowują się tak jak dla użytkowników:

php
set('smoke_command', function (): string {
    $host = getenv('DEPLOY_SMOKE_HOST');

    if ($host === false || preg_match('/\A[a-z0-9.-]+\z/i', $host) !== 1) {
        throw new \RuntimeException('DEPLOY_SMOKE_HOST musi być nazwą hosta.');
    }

    return sprintf(
        'curl --fail --silent --show-error --max-time 5 --resolve %1$s:443:127.0.0.1 https://%1$s/health/ready > /dev/null',
        $host,
    );
});

function readinessPasses(): bool
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        if (test(get('smoke_command'))) {
            return true;
        }

        writeln(sprintf('<comment>Readiness: próba %d/3 nieudana.</comment>', $attempt));

        if ($attempt < 3) {
            run('sleep 5');
        }
    }

    return false;
}

task('deploy:smoke:live', function (): void {
    if (readinessPasses()) {
        return;
    }

    $failedRelease = run('readlink -f {{current_path}}');
    $healthy = (string) get('healthy_release');

    if ($healthy === '' || $healthy === $failedRelease || ! test('[ -d '.escapeshellarg($healthy).' ]')) {
        throw new \RuntimeException('Readiness po przełączeniu nie przeszła, a nie ma zdrowego release’u, do którego można wrócić.');
    }

    run('cd {{deploy_path}} && ln -sfn '.escapeshellarg($healthy).' current.rollback && mv -T current.rollback current');
    run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) readiness failed" > '.escapeshellarg($failedRelease.'/BAD_RELEASE'));
    invoke('deploy:restart_workers');

    if (! readinessPasses()) {
        run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) rollback target unhealthy" > {{deploy_path}}/.dep/deploy_blocked');

        throw new \RuntimeException('Wydanie cofnięte, ale poprzednie również nie przechodzi readiness. Deploye zablokowane.');
    }

    throw new \RuntimeException('Wydanie cofnięte do '.basename($healthy).'.');
});

task('deploy:check_blocked', function (): void {
    if (test('[ -f {{deploy_path}}/.dep/deploy_blocked ]')) {
        throw new \RuntimeException('Deploye zablokowane po nieudanym rollbacku (.dep/deploy_blocked).');
    }
});

Liczą się tu trzy szczegóły. Rollback tworzy tymczasowy symlink i przenosi go na current, czyli robi ten sam atomowy ruch co przełączenie w przód (wbudowany task rollback używa bezpośrednio ln -nfs). Zapisuje BAD_RELEASE w katalogu nieudanego release'u, a to jest dokładnie ten znacznik, który wbudowany dep rollback sprawdza, zanim wybierze kandydata. I dzieje się raz. Jeśli stary release też jest chory, problem leży raczej w bazie, Redisie albo dysku, a przeskakiwanie między dwoma release'ami tylko by to ukryło. Recepta zapisuje wtedy .dep/deploy_blocked i każdy kolejny deploy zatrzymuje się na pierwszym tasku, dopóki człowiek nie usunie tego pliku. W każdej gałęzi deploy kończy się błędem, więc run workflow jest czerwony i nikt nie weźmie rollbacku za sukces.

Pełny plik w starterze wysyła też linie logger -p user.crit, które łapie alerting. Tutaj je pominąłem, żeby przepływ był czytelny.

Lista tasków

Standardowa lista odpala artisan:optimize i artisan:migrate bezwarunkowo, więc definiuję deploy jawnie:

php
task('deploy', [
    'deploy:check_blocked',
    'deploy:prepare',
    'deploy:vendors',
    'artisan:storage:link',
    'artisan:config:cache',
    'artisan:route:cache',
    'artisan:view:cache',
    'artisan:event:cache',
    'deploy:migrate',
    'deploy:smoke',
    'deploy:remember_healthy',
    'deploy:symlink',
    'deploy:restart_workers',
    'deploy:smoke:live',
    'deploy:unlock',
    'deploy:cleanup',
    'deploy:success',
]);

after('deploy:failed', 'deploy:unlock');

Wszystko powyżej deploy:symlink może się wywalić bez wiedzy użytkowników. Wszystko poniżej jest objęte rollbackiem. after('deploy:failed', 'deploy:unlock') zwalnia blokadę po porażce, więc kolejny run nie wymaga najpierw dep deploy:unlock.

Uruchamianie z GitHub Actions

Job deployujący pobiera artefakt zbudowany przez poprzedni job, sprawdza SHA-256 i to, czy commit z manifestu równa się GITHUB_SHA, instaluje Deployera z composer.json (deployer/deployer: ^8.0 w require-dev), zapisuje klucz SSH i przypięty known_hosts, a potem odpala:

bash
export DEPLOY_ARTIFACT_SHA256="$(cut -d ' ' -f 1 .release/starter.tar.gz.sha256)"
vendor/bin/dep deploy "$DEPLOY_ENVIRONMENT" --file=deploy.php --revision="$GITHUB_SHA" --ansi

Workflow startuje tylko przez workflow_dispatch, z concurrency: deploy-${{ inputs.environment }} i cancel-in-progress: false, więc dwa deploye nigdy się nie nakładają, a trwająca migracja nigdy nie zostanie przerwana. Staging i produkcja to environments w GitHubie. Klucz SSH jest sekretem environmentu, więc istnieje tylko w tym jobie, a required reviewers na production to miejsce, w którym człowiek zatwierdza wydanie (sprawdź, czy twój plan GitHuba daje je dla prywatnych repozytoriów). Stronę GitHub Actions dla deployu na symlinkach bez Deployera opisałem we wdrażaniu bez przestojów z GitHub Actions.

Kiedy tego nie używać

  • Masz kontenery. Na Kubernetesie albo Docker Swarm artefaktem jest obraz, a przełączenie i rollback robi orkiestrator. Deployer dublowałby jedno i drugie.
  • Masz kilka serwerów webowych za load balancerem. Deployer obsłuży wiele hostów, ale przełączenie przestaje być atomowe między nimi. Health checki per host należą do load balancera.
  • Robi to już platforma. Laravel Cloud, Forge z Envoyerem i podobne usługi dają katalogi release'ów i health checki bez recepty do utrzymania.
  • Twoje migracje nie są addytywne. Wtedy żadne narzędzie nie da ci deployu bez przestoju. Zaplanuj okno serwisowe i powiedz to wprost.

Dla jednej VM z Nginx i PHP-FPM pełna recepta to około 230 linii PHP, a każdy krok jest nazwanym taskiem, który zobaczysz w dep tree deploy.

Jeśli chcesz mieć to postawione na swoich serwerach albo potrzebujesz drugiej pary oczu do pipeline'u deployu, który twój zespół już ma, zobacz moją ofertę.

Źródła

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.