Skip to content
Un monorepo cu trei aplicații: cum construim doar ce s-a schimbat și am dus pipeline-ul de la opt minute la trei
← ← Înapoi la Idei Development

Un monorepo cu trei aplicații: cum construim doar ce s-a schimbat și am dus pipeline-ul de la opt minute la trei

Trei aplicații Next.js, patru pachete partajate, un singur repository. Asta e forma platformei pe care o rulăm pentru un client din SUA, și e forma corectă: tipuri partajate, un singur lockfile, un singur pull request pentru o schimbare care atinge API-ul și cele două front-end-uri care îl consumă. Partea greșită era pipeline-ul, care reconstruia toate cele trei aplicații la fiecare commit, inclusiv la un commit care schimba un README.

Opt minute per push. Trei job-uri CodeBuild, trei build-uri Docker, trei push-uri de imagine, indiferent dacă se schimbase ceva în aplicațiile alea sau nu. Iată cum am ajuns la trei minute pe un commit tipic și sub un minut pe un commit doar cu documentație, cu tooling-ul care a făcut-o, capcana în care am căzut și singurul caz în care reconstruirea a tot e corectă.

Structura

.
├── apps/
│   ├── web/          # aplicația Next.js publică
│   ├── admin/        # aplicația Next.js internă
│   └── api/          # route handler-e Next.js, desfășurate ca serviciu propriu
├── packages/
│   ├── db/           # schemă, migrări, helper-e de query
│   ├── ui/           # componente partajate
│   ├── config/       # preset-uri eslint, tsconfig, tailwind
│   └── types/        # tipuri TypeScript partajate, client de API generat
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml

pnpm workspaces pentru graful de pachete, Turborepo pentru graful de task-uri. Fiecare aplicație depinde de un subset al pachetelor; web și admin depind de ui, toate trei depind de db și types, totul depinde de config. Graful ăla de dependențe e tot input-ul pentru „ce s-a schimbat”.

apps/web apps/admin apps/api packages/ui packages/db packages/types packages/config schimbi packages/ui → build web + admin · schimbi packages/db → build toate trei · schimbi README → build nimic

Pasul 1: lași Turborepo să decidă ce e afectat

Turborepo cunoaște deja graful. Sintaxa --filter cu un interval git îl întreabă ce workspace-uri s-au schimbat, direct sau printr-o dependență, de la un commit încoace:

# ce aplicații au nevoie de build pentru push-ul ăsta?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'

--affected compară arborele de lucru cu origin/main implicit (configurabil cu TURBO_SCM_BASE), parcurge graful de dependențe și întoarce aplicațiile care depind tranzitiv de ceva ce s-a schimbat. O schimbare în packages/ui întoarce web și admin. O schimbare în apps/api/app/orders/route.ts întoarce api. O schimbare în README.md nu întoarce nimic.

Scriptul de deploy citește lista aia și pornește un job CodeBuild per aplicație din ea, în paralel, în loc să pornească mereu trei. Asta e toată schimbarea din fluxul de control al pipeline-ului: zece linii de shell care înlocuiesc o listă hard-codată de trei.

Două lucruri pe care Turborepo le consideră „afectează tot”, corect: lockfile-ul și config-ul rădăcină. Un bump de dependență în pnpm-lock.yaml reconstruiește toate cele trei aplicații, pentru că oricare ar fi putut prelua noua versiune. La fel o schimbare în turbo.json sau în package.json-ul rădăcină. Am încercat o dată să excludem lockfile-ul ca să accelerăm un bump de rutină și am livrat o aplicație cu o dependență veche. Nu face asta.

Pasul 2: cache-uiești output-urile de build, remote

În interiorul build-ului fiecărei aplicații, Turborepo cache-uiește output-urile task-urilor indexate după un hash al input-urilor: fișiere sursă, dependențe, variabile de mediu pe care le declari. Un al doilea build cu aceleași input-uri restaurează output-ul din cache în loc să ruleze next build. Local e automat. În CI, fiecare job CodeBuild pornește de pe un disc gol, deci cache-ul trebuie să trăiască undeva partajat.

Rulăm un mic remote cache self-hosted, o implementare open-source a API-ului de remote cache Turborepo, pe un t4g.nano care scrie în S3. Fiecare job CodeBuild și fiecare mașină de developer arată spre el:

// .turbo/config.json, sau prin env în CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
# în buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web

Efectul: packages/types și packages/db sunt construite o singură dată, de job-ul care ajunge primul, și fiecare alt job le restaurează într-o secundă. Un build de web care a schimbat o pagină restaurează pachetele partajate din cache și rulează next build doar pentru web în sine. Remote cache-ul costă $3 pe lună pentru instanță și bănuți pentru S3.

Pasul 3: cache-uiești și layer-ele Docker

Build-ul Docker e cealaltă jumătate a timpului. O imagine Node multi-stage are un layer scump de pnpm install care se schimbă doar când se schimbă lockfile-ul, și un layer de next build care se schimbă la fiecare commit. BuildKit poate exporta cache-ul de layer-e într-un registry și îl poate importa la următorul build:

docker buildx build \
  --cache-from type=registry,ref=$ECR/web:buildcache \
  --cache-to   type=registry,ref=$ECR/web:buildcache,mode=max \
  --build-arg RELEASE_SHA=$SHA \
  -t $ECR/web:$SHA --push .

Cu cache-ul cald, layer-ul de pnpm install e restaurat în zece secunde în loc să ruleze nouăzeci. Layer-ul de next build tot rulează, dar cache-ul Turborepo din interiorul lui înseamnă că în mare parte restaurează și el. mode=max cache-uiește etapele intermediare, ceea ce face etapa de builder refolosibilă; imaginea de cache trăiește în ECR sub un tag fix și costă câteva sute de megabyți de stocare.

Cifrele

Tip de commit Înainte După Ce rulează
Doar documentație, infra, config CI 8 min 40 s Turborepo zice că nimic nu e afectat; scriptul de deploy sare toate build-urile; doar CDK diff
O pagină în apps/web 8 min 2 min 50 s Un job CodeBuild; pachetele partajate din remote cache; layer-ul Docker de install din cache-ul de registry
Schimbare în packages/ui 8 min 3 min 20 s Două job-uri în paralel, web și admin; api neatins
Schimbare în packages/db 8 min 3 min 40 s Trei job-uri în paralel; cache-urile tot lovesc pentru layer-ele de install
Bump de lockfile 8 min 6 min Trei job-uri; layer-ul de install reconstruit peste tot; cache-ul Turborepo invalidat; corect

Commit-ul median e al doilea rând. De la opt minute la sub trei, și, pentru că job-urile care rulează sunt în paralel, maximul e acum cazul de lockfile la șase, nu vechiul opt de la fiecare commit.

Durata pipeline-ului pe tip de commit doar docs8:00 → 0:40 o aplicație8:00 → 2:50 packages/ui8:00 → 3:20 packages/db8:00 → 3:40 bump de lockfile8:00 → 6:00 · corect gri: înainte, fiecare commit · verde: după · galben: cazul care ar trebui să reconstruiască tot, și o face

Capcana: o aplicație afectată care n-a fost construită

După două luni, un deploy a livrat admin fără o schimbare de care admin avea nevoie. Schimbarea era în packages/types, un client de API generat, iar admin depinde de types, deci Turborepo ar fi trebuit s-o semnaleze. N-a făcut-o, pentru că clientul generat era produs de un script care rula în afara grafului de task-uri Turborepo, scriind fișiere în packages/types/generated/, care era în .gitignore. Turborepo hash-uiește input-uri urmărite. Output-ul generat nu era urmărit, deci din punctul lui de vedere types nu se schimbase.

Rezolvarea a fost să facem generarea un task Turborepo cu output-uri declarate, ca hash-ul să includă input-urile generatorului (spec-ul OpenAPI) și output-ul să fie cache-uit și restaurat ca oricare altul. Lecția se generalizează: orice produce input-uri de build trebuie să fie în interiorul grafului, altfel graful te minte. Am auditat fiecare script din package.json pentru același pattern și am mai găsit unul.

Când reconstruiești tot oricum

  • S-a schimbat lockfile-ul sau config-ul rădăcină. Turborepo face asta singur; nu suprascrie.
  • Un bump al imaginii de bază în Dockerfile-uri. Dockerfile-ul e un input pentru cache-ul Docker, nu pentru Turborepo, deci facem grep pe diff după Dockerfile și forțăm toate trei.
  • Un tag de release. Fiecare release cu tag reconstruiește toate trei de la zero, fără cache-uri, ca artefactul pentru o versiune să fie reproductibil din sursă și nu din ce s-a nimerit să fie în cache în ziua aia. Durează opt minute, o dată pe săptămână, și e build-ul pe care l-am vrea dacă ar trebui vreodată să explicăm exact ce s-a livrat.

Versiunea scurtă

Lasă unealta care cunoaște deja graful de dependențe să decidă ce se construiește. Pune cache-ul de task-uri undeva unde ajunge fiecare builder. Cache-uiește layer-ul Docker scump în registry. Ține fiecare generator în interiorul grafului. Reconstruiește tot la schimbări de lockfile și la tag-uri de release, intenționat. De la opt minute la trei, pentru o zi de configurare și $3 pe lună.

Dacă monorepo-ul tău construiește tot la fiecare push, putem lega asta într-o zi; partea de Turborepo e o oră, cache-ul Docker e celelalte șapte.