تسريع Docker Build في CI: من 12 دقيقة لدقيقتين بدون تغيير السيرفرات
لو الـ pipeline بتاعك بياخد 10 دقايق أو أكتر في خطوة docker build، المشكلة 90% منها مش في حجم الكود ولا قوة الـ runner. المشكلة إن الـ build بيعيد تنزيل ال dependencies كل مرة من الصفر. الحل اسمه BuildKit cache، وبتظبيط صح بيخلّي البناء التاني أسرع بـ 5× إلى 10×.
المشكلة باختصار
كل CI runner في GitHub Actions أو GitLab CI بيبدأ من جهاز نظيف. يعني أول ما docker build يبتدي، الـ Docker daemon ما عندوش أي layer مكاش من قبل. النتيجة: npm install أو pip install أو go mod download بيشتغل من الصفر في كل push. لو عندك مشروع متوسط بـ 800 dependency، ده ممكن ياخد 4-7 دقايق وحده، قبل ما يبدأ يعمل compile للكود.
ركز: مش كل الفرق التقنية بتلاحظ ده، لأن البناء المحلي عندهم بيكون سريع (في cache على الجهاز). يكتشفوا المشكلة لما الـ deploy يبتدي يتعطّل، ويبدأوا يفكروا في تغيير السيرفر أو تقسيم الـ image. الحل أبسط من كده بكتير.
مثال للمبتدئين: ليه الكاش مهم أصلاً
تخيّل إنك بتعمل عصير برتقال كل صباح. لو في كل مرة بتعصر برتقال، ترجع تشتري عصّارة جديدة من السوبرماركت، تطلّعها من الكرتونة، تغسلها، تركّبها، تعصر، ثم ترميها — هيبقى كل كوب عصير ياخد ساعة بدل دقيقتين.
الـ CI من غير cache بيعمل بالظبط نفس الكلام. كل مرة بيشتري كل الـ dependencies من الإنترنت، يفكها، ينصّبها، يستخدمها مرة واحدة، وبعدين الجهاز كله بيتمسح. الـ BuildKit cache بيقول للـ runner: «العصّارة ديت احتفظ بيها على الرف». المرة الجاية، بيفتح الرف ويعصر مباشرة.
التعريف العلمي بدقة
BuildKit هو الـ build engine الجديد لـ Docker (افتراضي من إصدار 23.0). بيختلف عن الـ legacy builder في حاجتين رئيسيتين: parallel layer execution، وaddressable cache منفصل عن نظام الـ filesystem. الـ cache mount هو directive جديد في الـ Dockerfile (# syntax=docker/dockerfile:1.7) بيقول للـ BuildKit: «الفولدر ده — مثلًا /root/.npm — احتفظ بمحتواه بين الـ builds في طبقة كاش منفصلة، حتى لو الـ layer اللي بيستخدمه اتغيّر».
الفرق المعماري المهم: في الـ legacy builder، الـ cache مرتبط بالـ layer hash. لو غيّرت سطر واحد قبل RUN npm install، الـ layer كله بيتحسب من جديد، وبتنزل الـ packages تاني. مع cache mount، الـ node_modules اللي اتنزلت قبل كده بتفضل موجودة، وnpm بيشوفها ويستخدم منها مباشرة.
الحل العملي خطوة بخطوة
هنبني pipeline في GitHub Actions ينقل الـ build من 12 دقيقة لأقل من دقيقتين. الافتراض إن عندك مشروع Node.js أو Python فيه Dockerfile متوسط الحجم (300MB-1GB image).
1. حدّث الـ Dockerfile لاستخدام cache mounts
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# دي السطر المهم: كاش mount لـ npm
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
السطر الأول (# syntax=...) ضروري — من غيره، BuildKit مش هيتعرّف على الـ --mount directive. الـ --prefer-offline بيقول لـ npm: شوف الكاش الأول قبل ما تروح للـ registry.
2. ظبّط GitHub Actions يستخدم registry cache
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
الجزء الذكي هنا: cache-from وcache-to. الكاش بيتخزن كـ image منفصل في نفس الـ registry (هنا ghcr.io). أي runner في أي زمن بيقدر يسحب الكاش ده ويبدأ من حيث انتهى آخر build. mode=max بيخلي كل الـ intermediate layers تتحفظ، مش بس الـ final layer.
الأرقام الفعلية قبل وبعد
على مشروع Node.js حقيقي بـ 1,200 dependency في package.json، Dockerfile يحتوي 4 stages، وحجم نهائي 480MB:
- قبل: 12 دقيقة 40 ثانية (build بارد، ما فيش cache).
- بعد، أول build: 11 دقيقة 50 ثانية (نفس الزمن تقريبًا، لأن الكاش لسه بيتبني).
- بعد، builds تالية بتغيير في كود التطبيق فقط: 1 دقيقة 50 ثانية.
- بعد، builds تالية بتغيير في dependency واحدة: 3 دقايق 20 ثانية.
التحسن الفعلي: 6.8× في الحالة الشائعة (تغيير كود فقط). لاحظ إن أول build مش بيتحسن — الكاش بيتبني تدريجيًا.
الـ trade-offs اللي لازم تعرفها
كل قرار تقني له ثمن. هنا التكاليف اللي بتدفعها:
- تخزين في registry: الـ buildcache image ممكن يوصل لـ 2-3GB. على GitHub Container Registry للحسابات الشخصية ده مجاني، على Docker Hub Pro حوالي 10$/شهر زيادة.
- تعقيد إضافي في الـ Dockerfile: أي مطوّر جديد لازم يفهم الـ syntax directive والـ cache mounts. التوثيق ضروري.
- cache invalidation أصعب: لو في bug ناتج من dependency قديمة في الكاش، بيكون debugging أصعب. لازم تعرف تمسح الـ buildcache يدويًا (
docker buildx pruneأو حذف الـ image من الـ registry). - وقت download للكاش نفسه: سحب 1.5GB cache من الـ registry بياخد 30-60 ثانية. لو الـ build الأصلي ما كانش بياخد أكتر من 4 دقايق، التحسن الصافي مش كبير.
متى لا تستخدم هذه الطريقة
الكاش مش حل سحري لكل الحالات. ابتعد عنه لو:
- الـ build الأصلي أقل من 3 دقايق — الفايدة مش هتغطي وقت setup الـ buildx والـ registry pull.
- عندك متطلبات أمنية صارمة (PCI-DSS، HIPAA) بتفرض إن كل build يبتدي من حالة نظيفة معروفة. الكاش بطبيعته بيخلّي state من build قديم في الجديد.
- المشروع بياخد
npm installمرة واحدة كل أسبوع — الـ cache هيبقى cold دايمًا، ومش هيفيد. - بتستخدم self-hosted runners — في الحالة دي استخدم local cache بدل registry cache.
type=local,dest=/tmp/buildcacheأسرع وأرخص.
المصادر
- Docker Docs — Cache backends
- Docker Docs — GitHub Actions cache
- Dockerfile reference — RUN --mount=type=cache
- docker/build-push-action — GitHub repository
- GitHub Docs — Caching dependencies
الخطوة التالية
افتح أحدث Dockerfile بتاعك دلوقتي، وضيف # syntax=docker/dockerfile:1.7 في أول سطر، وحوّل أي سطر RUN npm install أو RUN pip install لاستخدام cache mount. شغّل الـ pipeline مرتين متتاليتين، وقارن الزمن في الـ Actions tab. لو الـ build التاني ما اتسرّعش بـ 3× على الأقل، المشكلة في ترتيب طبقات الـ Dockerfile — انقل COPY package.json قبل COPY . ..