CI/CD לתמונות Docker: בנייה, סריקה, חתימה
סיפור טיפוסי: מפתח דוחף ל-main, GitLab Runner מבצע docker build && docker push, והתמונה עוברת לייצור. ללא שכבת מטמון (layer caching), הבנייה אורכת 8 דקות בכל commit. ללא סריקה, ה-registry מארח תמונות עם פגיעויות CRITICAL. ללא תיוג סמנטי, היסטוריית התמונות היא latest, latest, latest. נתקלנו בזה עשרות פעמים: בפרויקט אחד הבנייה ארכה 12 דקות, ומחצית מהתמונות ב-registry הכילו פגיעויות ברמת HIGH. הגדרת pipeline נכון פותרת את כל שלוש הבעיות בבת אחת והופכת את הכאוס לתהליך צפוי.
מה Pipeline CI/CD מלא ל-Docker צריך לעשות
Build → Scan → Sign → Publish → Tag. זה לא docker build -t myapp:latest && docker push אחד, אלא חמישה שלבים נפרדים עם כלים ונקודות כשל שונות. כל שלב הוא שלב CI נפרד, כך שאם מתרחשת שגיאה, התהליך כולו לא מופעל מחדש.
ה-pipeline צריך להיכשל אם הסורק מוצא פגיעות CRITICAL — ולעולם לא לדחוף תמונה לא בטוחה ל-registry. זה מונע מהצוות אפילו לדעת שהם מפרסים תמונות עם חורים. מניסיוננו, סריקה אוטומטית מפחיתה אירועי אבטחה ב-70% (ראה אבטחת שרשרת אספקת תוכנה).
Dockerfile רב-שלבי עם BuildKit
הבסיס ל-pipeline תקין הוא Dockerfile טוב. בנייה רב-שלבית מקטינה את התמונה הסופית פי 5–10 ומשפרת את המטמון. השווה: Dockerfile חד-שלבי עם SDK מלא שוקל 1.2 GB, בעוד שרב-שלבי שוקל 150 MB. ההבדל במהירות הפריסה ובעלויות האחסון ברור.
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=cache,target=/root/.npm \
npm run build
FROM node:20-alpine AS runner
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
CMD ["node", "dist/server.js"]
התמונה לייצור אינה מכילה devDependencies, קוד מקור או מטמון. היא רצה תחת משתמש לא-מורשה. BuildKit cache mounts מאיצים את # syntax=docker/dockerfile:1.4 FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci --only=production FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN --mount=type=cache,target=/root/.npm \ npm run build FROM node:20-alpine AS runner RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 WORKDIR /app COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist COPY --from=deps /app/node_modules ./node_modules USER nextjs EXPOSE 3000 CMD ["node", "dist/server.js"] — התקנה מחדש אורכת שניות, לא דקות.
GitLab CI: Pipeline מלא
---
# .gitlab-ci.yml
variables:
DOCKER_BUILDKIT: "1"
IMAGE_NAME: ${CI_REGISTRY_IMAGE}
IMAGE_TAG: ${CI_COMMIT_SHA}
stages:
- build
- scan
- sign
- push
build:
stage: build
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
script:
- docker buildx create --use
- docker buildx build --cache-from type=registry,ref=${IMAGE_NAME}:cache --cache-to type=registry,ref=${IMAGE_NAME}:cache,mode=max --tag ${IMAGE_NAME}:${IMAGE_TAG} --output type=image,push=false --load .
- docker save ${IMAGE_NAME}:${IMAGE_TAG} > image.tar
artifacts:
paths:
- image.tar
expire_in: 1 hour
scan:
stage: scan
image: aquasec/trivy:latest
before_script:
- docker load < image.tar
script:
- trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 --format table ${IMAGE_NAME}:${IMAGE_TAG}
dependencies:
- build
push:
stage: push
image: docker:24
services:
- docker:24-dind
before_script:
- docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
- docker load < image.tar
script:
- docker push ${IMAGE_NAME}:${IMAGE_TAG}
- |
if [ "${CI_COMMIT_BRANCH}" = "main" ]; then
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest
docker push ${IMAGE_NAME}:latest
fi
- |
if [ -n "${CI_COMMIT_TAG}" ]; then
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:${CI_COMMIT_TAG}
docker push ${IMAGE_NAME}:${CI_COMMIT_TAG}
fi
dependencies:
- build
only:
- main
- tags
העברת התמונה בין שלבים דרך npm install מונעת בנייה חוזרת. אנו סורקים בדיוק את התמונה שתיפרס, לא תמונה שנבנתה מחדש. זה חוסך עד 40% מזמן ה-pipeline של CI.
חתימת תמונות עם Cosign
---
sign:
stage: sign
image: cgr.dev/chainguard/cosign:latest
script:
- docker load < image.tar
- cosign sign --yes ${IMAGE_NAME}:${IMAGE_TAG}@$(docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE_NAME}:${IMAGE_TAG})
variables:
COSIGN_EXPERIMENTAL: "1"
dependencies:
- build
only:
- main
- tags
---
Cosign עם חתימה ללא מפתח (keyless signing) משתמש ב-GitLab OIDC token כזהות — אין צורך לאחסן מפתח פרטי. זה מבטיח עמידה ברמת SLSA Level 2 באמצעות attestations חתומים, לפי תקן sigstore לתשתית בלתי-ניתנת לשינוי. החתימה מחוברת לתמונה ב-registry לפי digest. במהלך הפריסה ניתן לאמת: # .gitlab-ci.yml variables: DOCKER_BUILDKIT: "1" IMAGE_NAME: ${CI_REGISTRY_IMAGE} IMAGE_TAG: ${CI_COMMIT_SHA} stages: - build - scan - sign - push build: stage: build image: docker:24 services: - docker:24-dind before_script: - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} script: - docker buildx create --use - docker buildx build --cache-from type=registry,ref=${IMAGE_NAME}:cache --cache-to type=registry,ref=${IMAGE_NAME}:cache,mode=max --tag ${IMAGE_NAME}:${IMAGE_TAG} --output type=image,push=false --load . - docker save ${IMAGE_NAME}:${IMAGE_TAG} > image.tar artifacts: paths: - image.tar expire_in: 1 hour scan: stage: scan image: aquasec/trivy:latest before_script: - docker load < image.tar script: - trivy image --severity CRITICAL,HIGH --ignore-unfixed --exit-code 1 --format table ${IMAGE_NAME}:${IMAGE_TAG} dependencies: - build push: stage: push image: docker:24 services: - docker:24-dind before_script: - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY} - docker load < image.tar script: - docker push ${IMAGE_NAME}:${IMAGE_TAG} - | if [ "${CI_COMMIT_BRANCH}" = "main" ]; then docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:latest docker push ${IMAGE_NAME}:latest fi - | if [ -n "${CI_COMMIT_TAG}" ]; then docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${IMAGE_NAME}:${CI_COMMIT_TAG} docker push ${IMAGE_NAME}:${CI_COMMIT_TAG} fi dependencies: - build only: - main - tags . זהו שלב חובה לעמידה ברמת SLSA Level 2.
אסטרטגיות מטמון
image.tar עבור cache-from/cache-to — המטמון מאוחסן ישירות ב-container registry. עובד על כל GitLab Runner, ללא צורך ב-shared volume. sign: stage: sign image: cgr.dev/chainguard/cosign:latest script: - docker load < image.tar - cosign sign --yes ${IMAGE_NAME}:${IMAGE_TAG}@$(docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE_NAME}:${IMAGE_TAG}) variables: COSIGN_EXPERIMENTAL: "1" dependencies: - build only: - main - tags מאחסן את כל השכבות הביניים, לא רק את הסופית. מטמון registry מהיר פי 5-7 ממטמון דיסק מקומי לבנייה מבוזרת, כפי שהוכח במדדים של צוות Docker BuildKit. בהשוואה למטמון דיסק מקומי, מטמון registry נותן האצה של 30–40% בבנייה מקבילה. התיעוד הרשמי של BuildKit ממליץ להשתמש במטמון registry לבנייה מבוזרת.
חלופה ל-GitHub Actions היא cosign verify ${IMAGE_NAME}:${CI_COMMIT_SHA} עם actions/cache. הוא מאוחסן ב-GitHub Artifacts, מקסימום 10GB, ופג אוטומטית. אבל מטמון registry אמין יותר — הוא אינו תלוי במגבלות artifacts.
אופטימיזציה נוספת למטמון
כדי להאיץ בניות, השתמש ב-`--cache-to` עם `mode=max` ו-`compression=zstd` להקטנת גודל המטמון. בבנייה מקבילה, זה מוסיף עד 20% מהירות נוספת.תיוג סמנטי
| אירוע | תגיות |
|---|---|
| Commit ל-main | type=registry, mode=max |
Git tag cache-from: type=gha |
sha-abc1234, main-latest, v1.2.3, v1.2.3, 1.2.3 |
| PR/MR | 1.2, ללא latest |
| Cron לילי | 1 |
לתיוג סמנטי אוטומטי ב-GitHub Actions — latest. ב-GitLab — סקריפט המבוסס על pr-123-sha ו-nightly-YYYYMMDD. תיוג נכון מאפשר חזרה קלה לכל גרסה וביקורת על שינויים.
למה שכבת מטמון חשובה?
ללא מטמון, כל commit בונה מחדש את כל השכבות: התקנת תלויות, בנייה, העתקה. אם התלויות לא השתנו, הן צריכות להגיע מהמטמון. BuildKit עם docker/metadata-action נותן האצה של פי 5–7 בפרויקט טיפוסי. בפרויקט אחד, קיצרנו את זמן הבנייה מ-9 דקות ל-1.5 דקות — מפתחים הפסיקו לחכות והאיצו את מחזור השחרור. זה מקצץ בעלויות CI runner בכ-$200 לחודש עבור צוות טיפוסי של 10 מפתחים.
איך להגדיר שכבת מטמון ב-GitLab CI?
הוראות שלב-אחר-שלב להוספת שכבת מטמון:
- הפעל Docker BuildKit:
CI_COMMIT_TAGבמשתנים. - הגדר מטמון registry:
CI_COMMIT_SHAו---cache-from registry. - ודא ש-GitLab Runner משתמש ב-Docker-in-Docker (dind) עם מספיק שטח דיסק.
- בדוק invalidation של מטמון: אם
DOCKER_BUILDKIT: "1"משתנה, BuildKit בונה מחדש רק את שלב התלויות, לא את כל ה-pipeline. - נטר את גודל המטמון: נקה מעת לעת רשומות ישנות עם
--cache-from type=registry,ref=${IMAGE_NAME}:cacheאו הגדר מדיניות מחיקה אוטומטית.
איך להימנע מבנייה חוזרת של תמונות?
השתמש ב-artifacts כדי להעביר את התמונה הבנויה בין שלבים. ב-GitLab זה --cache-to type=registry,ref=${IMAGE_NAME}:cache,mode=max; ב-GitHub Actions, package.json + upload-artifact. סריקה וחתימה עובדות עם אותו artifact, לא עם תמונה שנבנתה מחדש. זה מבטל פערים בין מה שנסרק למה שנפרס.
מה כלול בעבודה
- ביקורת על ה-Dockerfile הנוכחי ותצורת CI.
- שכתוב ה-Dockerfile לרב-שלבי עם BuildKit cache mounts.
- הגדרת pipeline עם שלבי build/scan/push.
- הגדרת מטמון registry (cache-from/cache-to).
- הוספת חתימת Cosign עם keyless signing.
- הגדרת תיוג סמנטי המבוסס על git tags.
- תיעוד והדרכת צוות (1–2 שעות).
- תמיכה לשבוע אחד לאחר היישום.
המתודולוגיה המוכחת שלנו מבטיחה פריסות אמינות, והאינטגרציה המוסמכת שלנו מבטיחה אבטחת שרשרת אספקה. לצוות שלנו ניסיון של 5+ שנים ב-Docker CI/CD והוא ביצע 30+ יישומי pipeline דומים לארגונים שונים, מה שמבטיח פתרונות שנבדקו בתעשייה.
| היקף | לוח זמנים |
|---|---|
| Pipeline בסיסי build/scan/push | יום אחד |
| + Cosign + תיוג semver | 2–3 ימים |
| בנייה מרובת-ארכיטקטורות + מספר registries | 3–4 ימים |
העלות מחושבת באופן אישי לאחר ביקורת על תצורת ה-CI הנוכחית ותשתית ה-registry. צור קשר — נעריך את הפרויקט שלך תוך יום עסקים אחד. הזמן יישום pipeline מלא: מביקורת ועד תיעוד. קבל ייעוץ לשיפור ה-pipeline — כתוב לנו.







