A BuildKit cache mount begyorsítja a Docker npm installt – a legtöbb CI-futtatón mégsem marad meg belőle semmi

A RUN --mount=type=cache kiveszi az npm letöltési cache-ét a Docker rétegek alól, így egy lockfile-változás sem dobja el a már letöltött csomagokat. Csak épp a legtöbb hosted CI-futtatón minden build friss gépen indul – ott ez a trükk önmagában nem sokat ér.

Van egy tipikus Dockerfile-minta, amit szinte minden Node.js-projekt átvesz: előbb bemásolod a package.json-t és a package-lock.json-t, lefuttatod az npm ci-t, és csak utána másolod be a teljes forráskódot. Az ötlet az, hogy amíg a függőségek nem változnak, a Docker újrahasznosítja a telepítési réteget, és nem futtatja le újra az installt minden apró kódváltozás után.

A gyakorlatban ez pontosan addig működik jól, amíg valaki nem ad hozzá egy új csomagot. Abban a pillanatban, hogy a package-lock.json akár egyetlen sorral is változik, az egész npm ci réteg érvénytelenné válik, és nem csak az új csomagot tölti le újra, hanem az összeset. Ez nem hiba, hanem a Docker réteg-cache logikájának egyenes következménye – és van rá natív megoldás, amit meglepően kevesen használnak: a BuildKit cache mount.

Diagram, amely összehasonlítja a Docker réteg-cache-t és a BuildKit cache mountot: bal oldalon egy megváltozott lockfile miatt érvénytelenné váló rétegek és a felhőbe visszamutató újratöltés, jobb oldalon egy különálló, buildek között megmaradó cache-tartály, amely az npm letöltési cache-ét tárolja.

A rétegcache csak addig ér valamit, amíg semmi sem változik

A Docker build minden RUN, COPY és ADD utasítást egy-egy rétegként épít fel, és a réteg kulcsa a megelőző rétegek tartalmából és az adott utasításból számolt hash. Ha egy COPY package-lock.json . réteg tartalma megváltozik, az utána következő RUN npm ci réteg cache-e is érvénytelenné válik, függetlenül attól, hogy a lockfile-ban ténylegesen csak egyetlen csomag verziója mozdult el.

Amikor ez megtörténik, az újraépülő réteg egy vadonatúj, üres fájlrendszerből indul. Amit az előző build az npm saját letöltési cache-ébe (~/.npm) írt, az ezzel a réteggel együtt eltűnik, hiszen az sosem volt önálló – csak annak a réteg fájlrendszerének egy alkönyvtára volt. Az npm ci ezért mindent újra letölt a registryből, azokat a csomagokat is, amik egyáltalán nem változtak.

A cache mount kiemeli a letöltési cache-t a rétegek alól

A BuildKit RUN --mount=type=cache opciója pontosan ezt a problémát oldja meg: egy olyan könyvtárat csatol a build lépésbe, amelyet a BuildKit a réteggráftól függetlenül, saját maga tart karban. A mountolt könyvtár tartalma build és build között megmarad, akkor is, ha az adott RUN réteg maga cache-miss lesz. A funkció már jóval korábban, kísérleti BuildKit-szintaxisként is létezett, de mivel a Docker Engine 23.0 óta a BuildKit fut alapértelmezett builderként Linuxon, ma már bármelyik friss Docker-telepítésen elérhető külön bekapcsolás nélkül.

A használatához a Dockerfile elején szükség van a BuildKit szintaxis-pragmára, és a mountot közvetlenül az npm ci elé kell tenni:

# syntax=docker/dockerfile:1
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
    npm ci

FROM node:22-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

A /root/.npm az npm alapértelmezett cache-könyvtára Linuxon, root felhasználóval futtatva – ezt npm-specifikus példaként a Docker hivatalos cache-optimalizálási útmutatója is dokumentálja. A deps stage-ből csak a kész node_modules kerül át a végleges image-be; maga a mountolt könyvtár sosem válik réteggé, tehát az image méretét sem növeli.

Kipróbáltam helyben: új függőséget adtam a package.json-hoz, majd --no-cache kapcsolóval kényszerítettem a réteg teljes újraépítését. Az npm ci réteg ugyan újra lefutott – ez elvárt, hiszen a COPY package.json réteg is megváltozott –, de a korábban már letöltött csomagokért nem ment ki hálózatra: csak az új csomagot töltötte le, a többit a mountolt cache-ből vette elő.

A mount opciói: id, sharing, és amit nem old meg

A Dockerfile-referencia szerint a cache mounthoz tartozik egy id opció (alapértelmezetten a target értéke, de ha egy Dockerfile-ban több, egymástól független cache-re van szükség – például mátrix buildeknél különböző Node-verziókhoz –, érdemes külön id-t adni nekik), és egy sharing opció shared (alapértelmezett), private vagy locked értékekkel. A locked mód biztosítja, hogy párhuzamos buildek ne írjanak egyszerre ugyanabba a cache-be – ezt a Docker dokumentációja kifejezetten az APT-hez ajánlja, mert az kizárólagos hozzáférést vár el a saját adataihoz, de npm-nél sem árt, ha több builder dolgozik egyszerre ugyanazon a gépen.

Amit érdemes tudni: a cache mount csak az npm letöltési cache-ét, vagyis a csomagok tarballjait tartja meg, magát a node_modules-t nem. Az npm ci tehát minden érvénytelen rétegnél ténylegesen lefut, kicsomagolja és linkeli a csomagokat – ez helyi lemezről gyors, de nem ingyenes. Nagy monorepóknál ez valós CPU- és lemezidőt jelent; a cache mount kizárólag a hálózati letöltés költségét veszi el, a telepítés többi részét nem.

A csapda: efemer CI-futtatók

A cache mount tárolása a BuildKit saját, réteggráftól független cache-tárolójában történik, azon a gépen, amelyik a buildet ténylegesen futtatja. Ez nem ugyanaz, mint a --cache-to / --cache-from exportálható build cache, amit a Docker cache backend dokumentációja ír le registry vagy gha (GitHub Actions) backendekkel.

A hosted GitHub Actions futtatók minden job elején egy vadonatúj, eldobható virtuális gépet kapnak – részben pont ezért terjedt el a type=gha cache backend használata a rétegcache átmentésére. A RUN --mount=type=cache tartalma viszont nem ugyanaz, mint a réteg-cache, és a közösségi tapasztalatok szerint a type=gha / type=registry exportálás nem garantáltan hozza vissza a mount-cache tartalmát – ezt jól dokumentálja egy máig nyitott moby/buildkit issue, ahol valaki pontosan ezt a viselkedést jelentette be Go modulokra, de a probléma ugyanúgy érvényes npm-re is. A gyakorlati következmény: hosted, eldobható CI-futtatókon a RUN --mount=type=cache hozzáadása a Dockerfile-hoz önmagában nem old meg semmit, mert minden job egy üres BuildKit-tárolóból indul.

Ott van igazán értelme, ahol a builder lemeze buildek között is megmarad: helyi fejlesztői gépen, self-hosted runneren vagy hosszú életű CI-agenten. Ha a CI hosted, ephemeral runnereken fut, más stratégia kell – például a node_modules explicit cache-elése lockfile-hash alapján a CI saját cache-mechanizmusával, a Docker build cache-től függetlenül.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük