Două povești despre același pipeline de deploy, spuse împreună pentru că a doua există din cauza primei.
Prima e un bug care nu e bug: am reconstruit o imagine de container, am împins-o sub același tag, am rulat cdk deploy, l-am văzut raportând no changes și am petrecut o oră înțelegând de ce producția rula în continuare codul vechi. A doua e ce s-a întâmplat când CI-ul nostru a fost indisponibil o vreme, din motive de facturare în afara controlului nostru, și aveam nevoie de un mod de a face deploy de pe un laptop mai sigur decât cel prin care șterseserăm cândva trei servicii de pe un laptop.
Partea întâi: CloudFormation compară string-uri, nu octeți
Serviciul App Runner e o resursă CloudFormation, iar imaginea pe care o rulează e o proprietate string: ImageIdentifier: 123456.dkr.ecr.us-east-1.amazonaws.com/api:abc1234. Când rulezi cdk deploy, CloudFormation compară noul template cu ultimul pe care l-a aplicat. Dacă string-ul e identic, nu e diff, nu e update, iar App Runner continuă să servească ce a tras ultima dată.
E un comportament corect și mușcă într-o singură situație: tag-ul e același, dar imaginea din spatele lui nu e. Am dat de asta când am reconstruit același commit cu un argument de build schimbat. Același SHA git, același tag api:abc1234, alți octeți în ECR, iar CloudFormation, corect, n-a văzut nimic de făcut. Tag-ul din registry arăta acum spre un digest nou; serviciul rula în continuare pe cel vechi; nimic nicăieri nu era greșit, în afară de așteptarea noastră.
Există două rezolvări oneste. Prima e o regulă: fiecare deploy mută SHA-ul. Dacă vrei o altă imagine, comite ceva. E simplă și e ce am făcut o vreme, și pică în momentul în care cineva trebuie să reconstruiască o imagine dintr-un motiv care nu implică o schimbare de cod, cum ar fi un patch de securitate al imaginii de bază.
A doua e fixarea pe digest. După ce imaginea e împinsă, scriptul de deploy întreabă ECR la ce se rezolvă tag-ul și pasează digest-ul, nu tag-ul, în template:
digest=$(aws ecr describe-images --repository-name api --image-ids imageTag="$TAG" \
--query 'imageDetails[0].imageDigest' --output text)
npx cdk deploy platform-prod --context apiImage="$REPO/api@$digest"
// în stack
imageRepository: { imageIdentifier: this.node.tryGetContext('apiImage'), imageRepositoryType: 'ECR' }
Acum template-ul se schimbă ori de câte ori se schimbă octeții, și doar atunci. Un rebuild al aceluiași commit cu conținut diferit e un deploy real. O re-rulare cu conținut identic e un no-op autentic. Comportamentul CloudFormation nu s-a schimbat; doar i-am dat string-ul care înseamnă cu adevărat „imaginea”.
Digest-ul apare în consola CloudFormation și în cdk diff ca un hash ilizibil de 64 de caractere. Afișăm tag → digest în output-ul de deploy, ca un om să poată spune totuși ce pleacă.
Partea a doua: deploy de pe laptop, în siguranță
CI-ul a dispărut o vreme. Platforma tot avea nevoie de deploy-uri. Învățaserăm deja, scump, că cdk deploy de pe mașina unui developer cu contextul greșit șterge resurse de producție, așa că „rulează-l local” nu era o opțiune pe care s-o mai acceptăm. Alternativa era un script a cărui treabă e să facă un deploy de pe laptop să se comporte ca un deploy din CI: aceleași intrări, aceleași bariere, nicio cale de a livra ce nu e în git.
A crescut la vreo 300 de linii de bash. Iată ce fac liniile alea, în ordine.
Preflight. Scriptul refuză să ruleze dacă arborele de lucru are schimbări necomise, dacă --env prod e dat fără --yes, dacă branch-ul curent nu e main pentru un deploy de prod sau dacă identitatea AWS pe care urmează s-o folosească nu e rolul de deploy așteptat pentru contul ăla. Fiecare refuz afișează o propoziție care spune ce e de făcut. Cam 40 de linii, și cele mai valoroase 40 din fișier.
Worktree. Creează un git worktree detașat la origin/main (după un git fetch) într-un director temporar și face totul de acolo. Orice e în copia de lucru a developerului, stash-uit, pe jumătate editat sau experimental, nu poate pleca. SHA-ul acelui worktree e identificatorul de release pentru tot ce urmează.
Arhivă în S3. git archive al worktree-ului, încărcat într-un bucket de build sub SHA. CodeBuild construiește din arhiva aia, nu dintr-o conexiune GitHub, deci intrarea build-ului e aceiași octeți fie că e declanșat de CI, fie de script.
Trei build-uri în paralel. Câte un proiect CodeBuild per aplicație, pornite împreună, interogate împreună. Construirea a trei imagini Next.js pe Docker-ul unui laptop ar dura opt minute și ar încălzi camera; pe trei instanțe CodeBuild durează trei. Proiectele sunt aceleași pe care le folosește CI.
Digest-uri. Ca în partea întâi. Scriptul rezolvă fiecare tag și afișează un tabel app → tag → digest.
Diff și confirmare. cdk diff contra mediului țintă, afișat integral. Dacă diff-ul conține vreo ștergere de resursă, scriptul o evidențiază și cere operatorului să tasteze numele stack-ului ca să continue, nu doar y. Ăsta e pasul care ar fi prevenit incidentul.
Deploy. cdk deploy cu digest-urile ca context și --require-approval never, pentru că aprobarea s-a dat deja la pasul anterior, cu informație mai bună decât dă propriul prompt al CDK.
Verificare și notificare. Lovește ruta de health până raportează noul SHA, face grep pe bundle-ul de client după același SHA și postează un mesaj pe canalul echipei cu mediul, SHA-ul, numele operatorului și rezumatul diff-ului. Dacă verificarea pică, scriptul o spune zgomotos și iese cu cod nenul; nu face rollback automat, pentru că o verificare picată înseamnă de obicei „uită-te la ea”, nu „anuleaz-o”.
Cum arată la folosire
./scripts/deploy.sh --env staging
./scripts/deploy.sh --env prod --yes
./scripts/deploy.sh --env prod --dry-run # synth + diff, fără build, fără deploy
Trei flag-uri. Tot restul e decis de script din git și din cont. Nu există nicio cale de a pasa o imagine custom, un branch custom sau o valoare de context custom, intenționat.
L-am scrie din nou?
Da, și l-am scrie din prima zi, nu după incident și după pana de CI. Nu pentru că deploy-urile de pe laptop sunt bune, ci pentru că scriptul e pipeline-ul. Când CI-ul s-a întors, workflow-ul lui a devenit: checkout, asumă rolul OIDC, rulează ./scripts/deploy.sh --env prod --yes. O singură cale de cod, exersată din ambele locuri, cu barierele în cod, nu într-un fișier YAML care rulează doar pe mașina altcuiva.
Cele 300 de linii sunt mult pentru un script de shell. Au înlocuit o pagină de runbook care zicea „ai grijă”, care e mai scurtă și nu funcționează.
Dacă deploy-ul tău e un cdk deploy pe care cineva îl rulează de unde se nimerește, te putem ajuta să-l facem plictisitor.