BuildKit cache mounts: الدليل العملي لتسريع Docker build
لو الـ docker build بتاعك في CI بياخد 6–10 دقايق وأغلب الوقت في npm install أو pip install أو apt-get install، المشكلة مش في السيرفر ومش محتاج تكبّره. BuildKit cache mounts بتخلّي نفس الخطوة تخلص في 30–60 ثانية بدون ما تغيّر سطر واحد في الكود.
المشكلة باختصار
كل build في CI بيبدأ من زيرو تقريبًا. الـ runner جديد، الـ disk فاضي، وأي layer فيها RUN npm ci بتحمّل كل الـ packages من الإنترنت كل مرة. حتى لو عدّلت سطر في كود الـ app بس، الـ package manager بيعيد الشغل كله لو الـ package.json اتلمس، لأن الـ layer cache بيشتغل all-or-nothing.
النتيجة العملية: فريق من 10 مطورين بيدفع 10 push/يوم، وكل push بيستنى 8 دقايق في الـ pipeline. ده 80 دقيقة compute يومي مهدرة في تحميل نفس الـ node_modules مرة بعد مرة. وبتنعكس على تكلفة الـ CI وعلى سرعة feedback loop للمطورين.
ليه الـ layer cache الطبقي بيفشل في CI
الـ Docker layer cache الكلاسيكي بيعتمد على قاعدة: "لو inputs الـ step ما اتغيّرتش، استخدم الطبقة اللي قبل." مشكلته الأساسية إنه all-or-nothing. مثلاً، لو package.json فيه dependency جديدة واحدة، الـ layer كلها بتتعاد، وnpm ci بيحمّل الـ 500 package من أول وجديد.
كمان في CI، الـ runner غالبًا ephemeral (بيتعمل ويتمسح)، فأي cache على الـ disk بيروح بعد كل build إلا لو استخدمت حل صريح زي GitHub Actions cache أو registry-based cache.
Cache mounts: الفكرة بمثال بسيط
تخيّل إنك كل صباح بتعمل قهوة. الطريقة الأولى: تروح السوبرماركت وتشتري علبة بن جديدة كل يوم. الطريقة دي "layer cache"، لو السوبرماركت قفل أو السعر زاد، طابور ده معناه بطء. الطريقة التانية: عندك علبة بن ثابتة في المطبخ، وبتشتري بس اللي خلص. دي cache mount: مخزن ثابت بين الـ builds، مش بيتحط في الـ image النهائية، وبيستفيد بس من التغيير.
الصياغة العلمية: الـ cache mount هو directory بـ BuildKit بيعمله mount على step محدد وقت الـ build، مخزّن في BuildKit cache backend (مش طبقة في الـ image). الـ directory ده persistent عبر الـ builds المتتالية، وأي package manager بيستخدم نفس المسار بيلاقي الملفات اللي نزّلها قبل كده. النتيجة: pip install بيحمّل بس الـ wheels الجديدة بدل الـ 50 package كلهم.
أمثلة تنفيذية جاهزة
أول حاجة، تأكد إن BuildKit شغّال. في Docker 23+ ده default. لو أقدم، فعّله بالأمر التالي أو بسطر syntax في أول الـ Dockerfile:
export DOCKER_BUILDKIT=1
# أو في أول الـ Dockerfile:
# syntax=docker/dockerfile:1.4
npm
# syntax=docker/dockerfile:1.4
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build
pip
# syntax=docker/dockerfile:1.4
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
apt
ملاحظة مهمة: apt مش بيدعم concurrent writes على نفس الـ cache، فلازم sharing=locked. وكمان محتاج تلغي الـ docker-clean الافتراضي علشان الملفات تفضل محفوظة:
# syntax=docker/dockerfile:1.4
FROM debian:bookworm-slim
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
> /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
curl git build-essential
Go
# syntax=docker/dockerfile:1.4
FROM golang:1.22
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app ./cmd/server
الأرقام: قبل وبعد
الأرقام دي من تجارب موثقة في Docker docs و Depot blog و PythonSpeed:
- مشروع Python بـ 50 dependency:
pip installمن 45 ثانية إلى 3 ثواني عند cache hit. - مشروع Go بـ 300 dependency: build من 90 ثانية إلى 8 ثواني عند cache hit.
- CI pipeline شائع ورد في oneuptime blog: من 8 دقايق إلى حوالي دقيقة بعد تفعيل BuildKit cache.
الافتراض: المقارنة دي بتفترض إن الـ cache backend محفوظ بين الـ builds (persistent). لو شغّال على GitHub Actions مثلاً، محتاج تضيف cache-to: type=gha,mode=max في docker/build-push-action علشان الـ BuildKit cache يتنقل بين الـ runs. من غير ده، كل run هيبدأ من الصفر والأرقام دي مش هتتحقق.
Trade-offs اللي لازم تعرفها
- الـ CI runner لازم يحافظ على الـ cache. GitHub Actions بشكل افتراضي مش بيحفظ cache mounts جوا runner cache. محتاج حل زي
cache-to: type=ghaأو الـ buildkit-cache-dance trick. - Space على الـ host. الـ cache بياكل disk. لو بتبني 10 image مختلفة، ممكن تلاقي 5–10GB مشغولة. شغّل
docker buildx prune --keep-storage=10GBكل فترة. - Builds متوازية. لو عندك CI بيبني كذا target في نفس الوقت، حدد
sharing=lockedللأدوات اللي مش بتدعم concurrent writes (أبرزها apt و composer). - الـ cache ممكن يبوظ في حالات نادرة (corrupt wheel في pip مثلاً). الحل
docker buildx prune --filter type=exec.cachemount.
متى لا تستخدم هذه الطريقة
مش كل build محتاج cache mount. تجنّبها في:
- Image فيها الـ cache نفسه مطلوب. الـ cache mount مش بيتحط في الـ image النهائية. لو محتاج الـ packages تفضل جوا الـ image كـ cache (مش عادي لكنه يحصل في use cases معينة)، مش هينفع.
- Single-shot builds. لو بتبني image مرة واحدة وخلاص، مفيش cache تستفيد منه. الـ overhead من ضبط BuildKit مش مبرّر.
- بيئات بدون BuildKit. لو شغّال على Docker إصدار أقدم من 18.09 أو على platform مش داعم، السطر هيتجاهل من غير ما يفيد.
- Dependencies بتتغيّر في كل build. لو الـ
package.jsonبيتعدّل كل مرة بشكل جوهري، الـ cache hit rate هيكون منخفض والفايدة هامشية.
المصادر
- Docker Docs — Optimize cache usage in builds
- Depot — How to use cache mounts to speed up Docker builds
- PythonSpeed — Speed up pip downloads in Docker with BuildKit
- vsupalov.com — How to Speed Up Your Dockerfile with BuildKit Cache Mounts
- Docker Docs — Cache management with GitHub Actions
الخطوة التالية
افتح الـ Dockerfile الرئيسي بتاعك، وحدد السطر اللي فيه npm ci أو pip install أو apt-get install. ضيف قبل الأمر --mount=type=cache,target=<المسار المناسب> حسب الأمثلة فوق. شغّل الـ build مرتين على نفس المكنة وقارن الـ time. لو الثاني مش أسرع بشكل ملحوظ، ده معناه إن BuildKit مش شغّال أو الـ cache backend مش persistent — ابدأ من الشك ده قبل أي حاجة تانية.