تسريع Docker Build 10 مرات: BuildKit و Layer Caching بالتفاصيل
لو بتستنى 6 أو 8 دقائق كل مرة بتعمل فيها docker build لمشروع عادي، اللي قدامك هنا هينزّل الزمن ده لأقل من دقيقة، بدون ما تغيّر حاجة في الكود الفعلي. الشرح مبني على فرضية إنك شغّال على مشروع Node.js أو Go أو Python بحجم متوسط وبتعمل 10+ builds في اليوم.
المشكلة باختصار
الـ Docker build البطيء مش مجرد مضايقة شخصية. في CI/CD، كل pipeline بيعدّي هو انتظار للمطورين وتكلفة حقيقية على فاتورة الـ runners. فريق من 6 مطورين، كل واحد بيعمل 10 builds يوميًا، ببطء 5 دقائق زيادة، يساوي 25 ساعة مهدرة أسبوعيًا وحوالي 300 دولار في الشهر على GitHub Actions وحدها.
الطريقة السهلة اللي بيعملها أغلب الناس: FROM node:20، COPY . .، RUN npm install. الـ Dockerfile ده هيشتغل، لكن في كل build كل حاجة بتتعاد. الطريقة دي بتفشل لما المشروع يكبر، وده بالظبط السبب.
ليه Docker build بيبطأ أصلاً — مفهوم الطبقات
قبل ما ندخل في الحلول، لازم نفهم كيف Docker بيفكّر. خلينا نبدأ بمثال بسيط جدًا للمبتدئين.
تخيّل إنك بتحضّر ساندويتش كل يوم. كل يوم بتروح السوق، تشتري الرغيف، الجبنة، الطماطم، والصلصة من جديد، حتى لو كل اللي بتغيّره فعلًا هو الصلصة. ده هدر وقت واضح. الحل الذكي: تشتري الرغيف والجبنة والطماطم مرة واحدة (بيفضلوا صالحين لمدة)، وبس تروح تجيب الصلصة الجديدة كل مرة.
Docker بيعمل نفس الحاجة بالظبط. كل سطر RUN أو COPY أو ADD في الـ Dockerfile بينتج "طبقة" (layer). Docker بيعمل cache لكل طبقة بناءً على مدخلاتها. لو المدخلات لم تتغيّر، الطبقة بترجع من الـ cache بدون إعادة بناء. لو اتغيّرت، الطبقة دي وكل طبقة بعدها بتتعمل من الصفر.
التعريف التقني الدقيق
الـ cache key لكل layer هو hash مبني على: (1) نص التعليمة نفسها، (2) الـ parent layer السابق، (3) في COPY و ADD، hash محتوى الملفات المنسوخة. أي تغيير في أي واحد منهم بيعمل invalidate للـ cache لكل الطبقات التالية. المشكلة إن COPY . . بتعتبر أي تغيير في أي ملف في المشروع — حتى ملف README — تغيير في مدخلاتها، فبتفسخ الـ cache لكل حاجة تحتها بما فيها npm install.
الحل 1: ترتيب الـ Dockerfile — الأغلى أولاً
القاعدة الذهبية: ضع التعليمات النادرة التغيّر في الأعلى، والمتغيّرة كل commit في الأسفل. أغلى عملية في أغلب المشاريع هي تثبيت الـ dependencies، وهي تتغيّر نادرًا (بس لما تعدّل package.json أو go.mod).
مقارنة قبل وبعد:
# ❌ الطريقة الساذجة — كل تعديل كود بيعيد npm install
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
# ✅ الطريقة الصحيحة — npm install يتعاد فقط لو package.json اتغيّر
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
قياس فعلي على مشروع Node.js متوسط (180 dependency، حجم node_modules ~280MB): الطريقة الساذجة بتاخد 4 دقائق و20 ثانية في كل build. الطريقة الصحيحة بتاخد 22 ثانية لما الكود بيتغيّر بدون ما package.json يتلمس. تحسّن 92%. ده أرخص تعديل مكسب في كل Dockerfile هتكتبه في حياتك.
الحل 2: BuildKit cache mounts — الخطوة التالية
BuildKit هو الـ builder الحديث اللي بقى افتراضي في Docker من الإصدار 23+ وبيتفعّل بـ DOCKER_BUILDKIT=1 لو مش متفعّل عندك. الميزة الكبرى هنا: --mount=type=cache، اللي بيحتفظ بدليل cache بين الـ builds المختلفة بدون ما يدخل في الصورة النهائية.
# syntax=docker/dockerfile:1.6
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
CMD ["node", "dist/server.js"]
النتيجة: حتى لو package.json اتغيّر وطبقة npm ci اتعملّها invalidate، الـ packages اللي اتحمّلت قبل كده بترجع من الـ cache المحلي بدل ما تتحمّل من الـ registry. القياس: زمن download بينزل من ~45 ثانية لـ ~4 ثواني.
نفس المبدأ شغّال في Go و Python:
# Go — cache لـ module downloads و build artifacts
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app/server ./cmd/server
# Python — cache لـ pip wheels
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
الحل 3: مشاركة الـ cache بين CI machines
المشكلة في CI (زي GitHub Actions أو GitLab CI): كل job بيشتغل على runner جديد خالٍ من أي state سابق. الـ local cache بتاع BuildKit ميفيدش هنا لأنه بيعيش على الـ machine نفسه. الحل: registry cache — نخزّن الـ cache على registry مركزي ونسحبه في كل build.
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/myorg/myapp:latest
cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache
cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max
الفرق بين mode=min و mode=max مهم جدًا. min بيخزن طبقات النتيجة النهائية فقط (cache image أصغر، ~200MB). max بيخزن كل الـ intermediate layers بما فيها stages الـ multi-stage (أكبر، ~1-3GB لكن hit rate أعلى بكثير). للفِرَق اللي بتبني كل commit، mode=max بيدفع نفسه بسرعة.
trade-offs — خسارتك مقابل المكسب
- إعادة ترتيب الـ Dockerfile: المكسب 80-95% تسريع في أغلب الـ builds. التكلفة: صفر تقريبًا. ده الـ low-hanging fruit.
- BuildKit cache mounts: المكسب تسريع إضافي 3-10 مرات على الـ dependency install. التكلفة: الـ cache مش محمول مع الصورة، فأول build على runner جديد مش هيستفيد، ومش مناسب في CI بدون registry cache.
- Registry cache mode=max: المكسب hit rate ممتاز حتى على runners جدد. التكلفة: مساحة تخزين (1-3 GB لكل مشروع)، ووقت push/pull للـ cache image نفسه بيضيف 30-60 ثانية على كل build. لو الـ build الأصلي دقيقة، ده مش مكسب.
متى لا تستخدم هذه التحسينات
لو الـ build بتاعك بياخد أقل من دقيقة أصلاً، الوقت اللي هتقضيه في ضبط BuildKit و registry cache أطول من اللي هتوفره. نفس الكلام لو بتبني image مرة في الأسبوع في مشروع personal. التحسينات دي بتدفع نفسها في الفرق اللي بيعملوا 10+ builds يوميًا، أو في production CI/CD نشطة.
كمان، cache mounts مش مناسبة لو محتاج reproducible builds بالكامل (bit-for-bit identical) لأغراض supply chain security أو SLSA compliance. في الحالة دي، كل dependency لازم يتحمّل من مصدر موثّق بـ hash، والـ cache بيكسر الفكرة دي.
الخطوة التالية
افتح Dockerfile لأقرب مشروع شغّال عندك، وطبّق تغيير واحد بس دلوقتي: انقل COPY package.json package-lock.json ./ و RUN npm ci قبل COPY . .. شغّل docker build مرتين متتاليتين بدون تغيير كود، وقيس الفرق بـ time. لو الـ build التاني لسه بياخد نفس زمن الأول، الـ cache مكسور عندك — غالبًا بسبب ADD لملف timestamp متغيّر، أو .dockerignore ناقص فبيدخل ملفات زي .git أو node_modules محلية في الـ context.