Code review-ul are un plafon. Un reviewer poate citi un diff și poate raționa pe el, dar nu poate da click pe el. Pentru un produs cu UI, cel mai util comentariu de review e „am deschis, am încercat fluxul și pasul doi pierde starea formularului”, și primești comentariul ăla doar dacă reviewer-ul are un URL. Vercel a construit o companie pe observația asta. Dacă aplicația ta e un front-end static, folosește Vercel și nu mai citi.
A noastră nu e. Platforma pe care o rulăm pentru un client din SUA înseamnă trei aplicații Next.js plus un strat de API, Lambda-uri, tabele DynamoDB și o bază de date Aurora, toate definite în CDK și desfășurate pe AWS. Un preview doar de front-end îndreptat spre backend-ul comun de dev e o minciună: îi arată reviewer-ului noul UI vorbind cu vechiul API. Voiam tot stack-ul per pull request și voiam să coste cam un dolar. Iată ce am construit.
Ce e per pull request și ce nu
Trucul e să decizi ce trebuie duplicat. Să duplici tot e lent de creat și scump de ținut. Să nu duplici nimic e Vercel. Linia pe care am tras-o:
Tot ce are preț pe oră și timp de creare lent e partajat: VPC-ul, NAT-ul, clusterul Aurora. Tot ce e gratuit când stă și rapid de creat e per PR: App Runner la dimensiune minimă, Lambda-urile, tabelele DynamoDB. Baza de date e compromisul. Un cluster separat per PR ar dura 10 minute și ar costa $40 pe lună la pragul de 0,5 ACU; o bază de date separată pe clusterul comun înseamnă un CREATE DATABASE și nu costă nimic. Migrările rulează pe pr_123 la crearea stack-ului, exact cum rulează în producție, deci un PR care schimbă schema are preview cu propria schemă.
Partea de CDK
Mediul exista deja ca valoare de context CDK: dev, staging, prod. Un preview e o a patra variantă, pr, cu un număr.
// bin/app.ts
const env = app.node.tryGetContext('env') ?? 'dev';
const pr = app.node.tryGetContext('pr'); // "123" sau undefined
const suffix = pr ? `-pr-${pr}` : '';
new PlatformStack(app, `platform-${env}${suffix}`, {
env: accounts[env === 'pr' ? 'dev' : env],
config: {
...configs[env === 'pr' ? 'dev' : env],
removalPolicy: pr ? RemovalPolicy.DESTROY : configs[env].removalPolicy,
warmInstances: pr ? 1 : configs[env].warmInstances,
instanceSize: pr ? 'small' : configs[env].instanceSize,
databaseName: pr ? `pr_${pr}` : 'app',
previewToken: pr ? Secret.fromSecretNameV2(...).secretValue : undefined,
},
});
Stack-ul în sine nu știe că e un preview. Primește o configurație în care politica de ștergere zice DESTROY, instanța e mică, numele bazei de date are un număr în el. Resursele partajate sunt importate cu Vpc.fromLookup, DatabaseCluster.fromDatabaseClusterAttributes și Repository.fromRepositoryName, adică exact ce făcea deja stack-ul de dev pentru referințele între stack-uri. Diff-ul pentru suportul de preview a avut sub o sută de linii, și mare parte era plumbing-ul de configurație de mai sus.
Partea de GitHub Actions
Două workflow-uri. Primul rulează la pull_request cu tipurile opened, synchronize și reopened:
jobs:
preview:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read, pull-requests: write }
concurrency: preview-${{ github.event.number }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::<dev-account>:role/github-deploy-dev
aws-region: us-east-1
- run: ./scripts/build-images.sh --tag pr-${{ github.event.number }}-${{ github.sha }}
- run: npx cdk deploy platform-pr-${{ github.event.number }} --context env=pr --context pr=${{ github.event.number }} --require-approval never
- run: ./scripts/comment-preview-url.sh ${{ github.event.number }}
Al doilea rulează la pull_request cu tipul closed și are un singur pas: cdk destroy pe același nume de stack, urmat de DROP DATABASE pr_123 printr-o Lambda mică ce are acces de rețea la cluster. Merged sau abandonat, PR-ul își ia mediul cu el.
Scriptul de comentariu citește URL-ul App Runner din output-urile stack-ului și îl postează o dată, apoi editează același comentariu la fiecare push, ca PR-ul să nu se umple de zgomot de bot. URL-ul e hostname-ul *.awsapprunner.com. Nu creăm domenii custom pentru preview-uri: o validare de certificat per PR adaugă cinci minute și nu aduce nimic.
De la push la URL: cam șase minute
| Pas | Timp |
|---|---|
| Checkout, assume role | 20 s |
| Build la trei imagini în CodeBuild, în paralel | ~3 min |
cdk deploy pe un stack nou, inclusiv crearea serviciului App Runner |
~2,5 min |
Migrări pe pr_123 |
10 s |
| Comentariu pe PR | 2 s |
La push-urile următoare stack-ul există, deci se schimbă doar tag-ul imaginii și App Runner face un rolling update: cam patru minute. Nu cele patruzeci de secunde ale Vercel, dar suficient de rapid încât un reviewer care cere o schimbare o vede în aceeași ședință.
Cele trei lucruri care nu-l lasă să devină haos
Un măturător. Workflow-urile eșuează. Un eveniment closed se pierde când GitHub are un incident sau când cineva șterge branch-ul din CLI. O Lambda programată listează stack-urile CloudFormation numite platform-pr-*, verifică data creării și distruge tot ce e mai vechi de șapte zile. A rulat pe bune de patru ori. Fără ea, preview-urile moarte se acumulează la $5 pe lună fiecare, în liniște.
Un token de preview. Preview-urile sunt pe URL-uri publice, cu date care arată real. Un middleware Next.js verifică un cookie setat de /preview?token=…, cu token-ul citit dintr-un secret partajat la runtime, nu copt la build. Reviewerii dau click o dată pe link-ul din comentariul PR-ului, care poartă token-ul, și apoi navighează normal. Nu e o graniță de securitate împotriva unui atacator hotărât, dar ține crawlerele și partajarea accidentală afară, iar datele din spate sunt fixture-uri, niciodată producție.
Fixture-uri, nu date de prod. Baza de date per PR e populată dintr-un fișier de fixture din repo: câteva conturi, comenzi, stările care contează pentru review. Copierea datelor de producție în preview-uri e cel mai rapid mod de a le scurge și face și preview-urile lente de creat. Dacă un reviewer are nevoie de o stare anume, o adaugă în fixture și orice preview viitor o are.
Cât costă
| Element | Per PR, deschis 3 zile |
|---|---|
| App Runner, 0,25 vCPU / 0,5 GB, mai mult inactiv | ~$0,50 |
| DynamoDB on-demand, câteva mii de cereri | ~$0,01 |
| Lambda | ~$0,00 |
| Baza de date pe clusterul comun | $0 |
| CodeBuild, 3 imagini × ~4 build-uri | ~$1,20 |
| Total | ~$1,70 |
Cu douăzeci de pull request-uri pe lună e cam $35, din care CodeBuild e partea cea mai mare. Clusterul Aurora comun din dev exista deja și se pune singur pe pauză; preview-urile îl țin treaz ceva mai mult, ceea ce e real, dar greu de măsurat. Față de un loc Vercel Pro per developer, e mai ieftin, și spre deosebire de Vercel face preview și la API și la stratul de date.
Când să nu faci asta
Dacă backend-ul tău e stabil și PR-urile sunt aproape mereu de front-end, un preview doar de front-end pe dev-ul comun e în regulă și mult mai simplu. Dacă stack-ul tău ia douăzeci de minute de creat pentru că are un ALB, o instanță RDS și un cluster Redis, mediile per PR vor fi prea lente ca să fie utile și ar trebui să te uiți la namespace-uri pe un cluster comun sau la baze de date efemere cu branching. Și dacă ai mai mult de o mână de developeri, ai grijă la limitele de rată ale API-ului CloudFormation: douăzeci de cdk deploy concurente într-un cont le vor lovi.
Pentru o echipă de trei, care livrează mai multe pull request-uri pe zi pe un stack ce se creează în șase minute, e cea mai bună investiție în developer experience pe care am făcut-o. Review-urile s-au accelerat, iar „merge pe mașina mea” a încetat să fie un argument, pentru că mașina e aceeași.
Vrei preview-uri pentru propriul stack, pe propriul cont AWS? Hai să vorbim.