Docker Multi-Stage Builds: איך מקטינים אימג' פי 10

מאת צוות מדיה דיל · 03.09.2026 · טכנולוגיה · 7 דק׳ קריאה

ההבדל בין Single-Stage ל-Multi-Stage Build, COPY --from=builder, בחירת Base Image רזה, פחות CVEs, וניצול Layer Caching נכון.

אימג' Docker בגודל 1.2 גיגה-בייט לאפליקציית Node.js פשוטה הוא סימן ברור לבעיה: כלי Build, קבצי מקור, ו-Dev Dependencies שאף אחד לא צריך בפרודקשן נשארים תקועים בכל שכבה של האימג' לצמיתות. Multi-Stage Builds פותר את זה בדיוק — מפריד באופן ניקי בין מה שצריך כדי לבנות לבין מה שצריך כדי להריץ.

הבעיה עם Single-Stage Build

כשכל הפקודות נמצאות באותו Dockerfile Stage — התקנת כלי Build, קומפילציה, התקנת Dev Dependencies לבדיקות — כל אלה נשארים בשכבות האימג' הסופי גם אחרי שהתוצר הסופי מוכן. אימג' Node.js שמכיל TypeScript Compiler, כלי Build ו-Test Runner במקום רק את ה-JavaScript המהודר יכול להיות פי 5-10 גדול ממה שבאמת נדרש בזמן ריצה, וכל בייט מיותר הזה נגרר איתו בכל Pull מ-Registry.

איך Multi-Stage עובד: Builder ו-Runtime

Dockerfile עם כמה בלוקי FROM מגדיר שלבים נפרדים — שלב builder מתקין את כל כלי הפיתוח, מריץ Build, ומייצר תוצרים סופיים; שלב אחרון (runtime) מתחיל מ-Base Image נקי ומעתיק אליו רק את מה שבאמת נדרש להרצה. כל השכבות הכבדות של שלב ה-build פשוט לא קיימות באימג' הסופי כלל.

COPY --from=builder: הפקודה שעושה את הקסם

הפקודה COPY --from=builder מעתיקה קבצים ספציפיים משלב מוקדם יותר ישירות לתוך השלב הנוכחי — למשל תיקיית dist מהודרת, או Binary מקומפל — בלי לגרור איתה אף חלק אחר מהשכבה שממנה היא הגיעה. זו הדרך שבה Docker מאפשר "לזקק" רק את התוצר הסופי מתוך תהליך Build כבד.

בחירת Base Image לשלב הסופי

אימג' alpine קטן משמעותית מ-Debian או Ubuntu מלא, ואימג'י distroless הולכים צעד נוסף: אין בהם אפילו Shell או Package Manager — רק ה-Runtime המינימלי הנדרש להריץ את התהליך. הבחירה הנכונה תלויה בצורך בכלי דיבוג בתוך הקונטיינר עצמו (למשל exec -it עם Shell זמין) מול רצון למינימום מוחלט של שטח תקיפה, כאשר distroless לרוב מחייב דיבוג מבחוץ בלבד דרך כלים ייעודיים.

פחות Packages, פחות CVEs

כל Package מותקן באימג' הוא שטח תקיפה פוטנציאלי — סריקת פגיעויות על אימג' עם עשרות כלי Build מיותרים מחזירה עשרות CVEs שרובם כלל לא רלוונטיים בזמן ריצה, כי הכלים שם רק בשביל ה-Build. אימג' סופי רזה עם רק מה שבאמת נדרש מצמצם דרמטית את משטח הפגיעויות שצריך לעקוב אחריו ולתקן.

סדר נכון ב-Dockerfile וניצול Layer Caching

כל שורה ב-Dockerfile יוצרת Layer שנשמר ב-Cache אם דבר לא השתנה מעליה — לכן כדאי להעתיק קבצי תלויות (package.json) ולהריץ install לפני העתקת שאר קוד המקור, כך ש-Build חוזר אחרי שינוי קוד בלבד לא יתקין תלויות מחדש מאפס בכל פעם. סדר לא נכון גורם ל-Cache Miss מיותר בכל Build, גם כשהתלויות לא השתנו כלל, מה שמאריך משמעותית את זמן ה-CI בכל Commit קטן.

שילוב עם CI/CD: אימג' רזה זה גם Deploy מהיר יותר

אימג' קטן משמעותית תופס פחות זמן ב-Push ו-Pull מ-Registry, מה שמקצר משמעותית את זמן ה-Deploy בכל Pipeline — קריטי בזרימת GitOps שבה כל שינוי בקוד מטריגר Build ופריסה אוטומטיים, ובזמינות לפריסה בEdge שבו גודל האימג' משפיע ישירות על מהירות ה-Cold Start.

אימג' Docker כבד מדי שגורר Deploy איטי? נשמח לעזור לכם לצמצם אותו משמעותית בוואטסאפ.

Multi-Stage לשפות עם Binary מקומפל: Go כדוגמה

שפות שמקומפלות ל-Binary סטטי, כמו Go, מדגימות את היתרון של Multi-Stage Build בצורה הקיצונית והברורה ביותר: שלב ה-builder מתקין את כל שרשרת הכלים לקומפילציה — מהדר, ספריות פיתוח, תלויות בנייה — אבל שלב ה-runtime הסופי יכול להתחיל אפילו מאימג' scratch כמעט ריק לחלוטין, ולהעתיק אליו רק את קובץ ה-Binary הבודד שנוצר בסוף תהליך הקומפילציה. אין שם שום Runtime, ספרייה, או מערכת הפעלה נלווית מעבר למינימום המוחלט ההכרחי, מה שהופך את האימג' הסופי לזעיר משמעותית ביחס לתהליך הבנייה שהיה כרוך בייצור אותו Binary בודד.

Cache Mount ב-BuildKit: האצת זמן Build

מעבר ל-Layer Caching הרגיל שכבר קיים ב-Docker, BuildKit תומך ב-Cache Mount ייעודי (mount=type=cache) שמאפשר לשמור תיקיית Cache של מנהל חבילות — כמו Cache של node_modules או Cache של pip — בין ריצות Build שונות לגמרי, גם כשהשכבה עצמה משתנה ולא נשמרת ב-Cache הרגיל של Docker. זה מקצר משמעותית זמני Build חוזרים כשמוסיפים או משנים תלויות, בלי להשפיע כלל על גודל האימג' הסופי, כי תוכן ה-Cache Mount לא נכנס בכלל לשכבות האימג' הסופי עצמו, ורק משמש לזירוז תהליך ההתקנה בכל הרצה מחדש.

הרצת בדיקות בשלב Build נפרד שלא מגיע לאימג' הסופי

אפשר להוסיף שלב ביניים ייעודי להרצת בדיקות (Test Stage) שמריץ את חבילת הבדיקות המלאה, כולל כל תלויות הפיתוח והבדיקה, בלי שהתוצאה שלו — או התלויות הכבדות שהוא השתמש בהן לצורך הריצה — משפיעה בכלל על אימג' ה-runtime הסופי שעולה בסופו של דבר לפרודקשן. אם הבדיקות נכשלות בשלב הזה, ה-Build כולו נכשל ועוצר מיד, מה שמונע דחיפה של קוד שבור, אבל השכבות הכבדות של הבדיקות עצמן אף פעם לא נגררות הלאה לאימג' הפרודקשן, גם כשהבדיקות עוברות בהצלחה.

סריקת פגיעויות על האימג' הסופי בלבד

הרצת סריקת אבטחה (Image Scanning) כשלב ב-CI/CD אמורה לרוץ על אימג' ה-runtime הסופי שבאמת עולה לפרודקשן, לא על שלב ה-builder עם כל כלי הפיתוח שלא מגיעים לתוצר הסופי בכלל. סריקה על שלב הביניים תדווח על עשרות CVEs שרובם כלל לא רלוונטיים כי הם קיימים רק בכלים שמשמשים לבנייה ולא רצים אף פעם בפרודקשן, מה שיוצר "רעש" מיותר לצוות האבטחה. Multi-Stage Build בדיוק מאפשר להפריד בין השניים ולסרוק רק את מה שבאמת חשוב.

תיוג גרסאות לאימג' שנבנה ב-Multi-Stage

תיוג (Tagging) עקבי של האימג' הסופי — לפי מספר גרסה, Commit Hash, או שילוב של השניים — מאפשר לדעת בדיוק איזו גרסת קוד רצה כרגע בפרודקשן ולבצע Rollback מהיר לגרסה קודמת במקרה הצורך. חשוב לוודא שהתיוג חל על אימג' ה-runtime הסופי בלבד ולא בטעות על שלב הביניים, כדי שלא יידחף בטעות ל-Registry אימג' ביניים כבד עם כלי Build שלא נועד להגיע לשם כלל.

השפעת גודל האימג' על זמן Cold Start בסביבות Serverless

בסביבות Serverless, שבהן קונטיינר חדש עשוי לעלות מאפס בכל פעם שאין מופע חם זמין (Cold Start), גודל האימג' משפיע ישירות על משך הזמן הזה — אימג' כבד שצריך להימשך מ-Registry ולהיטען לזיכרון לוקח יותר זמן להתחיל לרוץ מאימג' רזה. במערכות רגישות לזמן תגובה, שבהן כל מילישנייה של Cold Start מורגשת למשתמש, הקטנת האימג' באמצעות Multi-Stage היא לא רק שיפור אחסון אלא שיפור ביצועים ישיר ומדיד.

כן — גם בפרויקט קטן, ההפרדה בין שלב בנייה לשלב ריצה משתלמת, כי היא לא מוסיפה כמעט מורכבות אך כבר חוסכת גודל אימג' ומצמצמת משטח תקיפה. אין באמת סף גודל פרויקט שמתחתיו כדאי לוותר על הגישה הזו לטובת Single-Stage.

Docker לא מתריע על כך אוטומטית — אם מפנים COPY --from לשלב הלא נכון, בפועל פשוט מקבלים קבצים שגויים או חסרים באימג' הסופי, לרוב מתגלה רק כשהאפליקציה נכשלת להריץ בפרודקשן. בדיקת Build מקומית מלאה, כולל הרצת האימג' הסופי בפועל לפני Push ל-Registry, חושפת טעויות כאלה מוקדם ולא רק בסביבת הייצור.

שאלות נפוצות

האם Multi-Stage Build עובד גם עם Docker Compose?

כן, Dockerfile עם כמה שלבים עובד בדיוק כרגיל גם כשהוא מוגדר כשירות בתוך docker-compose.yml — Compose פשוט מריץ את אותו תהליך Build, כולל כל השלבים המוגדרים בו, ומשתמש בתוצאה הסופית שהתקבלה בסוף התהליך כפי שהוגדרה בקובץ.

כמה שלבים (Stages) כדאי להשתמש בהם?

אין מספר קבוע שמתאים תמיד — שני שלבים (builder ו-runtime) מספיקים לרוב המקרים הפשוטים והנפוצים. כשיש צורך גם בבדיקות נפרדות, גם בהתקנת תלויות מרובות שכבות, או בהפרדה בין קומפילציית Frontend ל-Backend באותו פרויקט, שלבים נוספים מסייעים לארגן את התהליך בבירור, אבל תמיד כדאי לשמור על הכמות המינימלית שעדיין ברורה ונוחה לתחזוקה שוטפת.

האם Multi-Stage Build מאט את זמן ה-Build עצמו?

לא באופן מהותי — כל שלב עדיין נהנה מ-Layer Caching רגיל בדיוק כמו קודם, כך שבנייה חוזרת בלי שינוי בשלבים המוקדמים לא חוזרת עליהם מאפס בכל פעם. הזמן שנחסך ב-Push, Pull והפעלה בזכות אימג' סופי קטן משמעותית לרוב עולה בהרבה על כל תקורה שולית שנוצרת בזמן ה-Build עצמו.

אפשר להשתמש ב-Multi-Stage בלי BuildKit?

Multi-Stage Build עצמו הוא תכונה בסיסית של Docker שקיימת גם ללא BuildKit מופעל. עם זאת, תכונות מתקדמות כמו Cache Mount זמינות רק כשה-BuildKit מופעל, ורוב גרסאות Docker העדכניות מפעילות אותו כברירת מחדל ממילא, כך שלרוב המשתמשים זה כבר פעיל בלי צורך בהגדרה נוספת.

מה ההבדל בין Multi-Stage Build ל-.dockerignore?

הם פותרים בעיות משלימות, לא זהות בכלל: .dockerignore מונע מקבצים מיותרים — כמו node_modules מקומי או קבצי .git — להיכנס בכלל להקשר ה-Build ולאימג' הביניים מלכתחילה, בעוד Multi-Stage מסנן שכבות שלמות של כלי Build שכן נדרשו במהלך העבודה בפועל, אך לא נחוצים בזמן ריצה בפרודקשן. שילוב שניהם יחד באותו פרויקט נותן את התוצאה הרזה והמצומצמת ביותר האפשרית.

תגיות: Docker · Multi-Stage Builds · DevOps · Container Optimization

← חזרה לבלוג · צור קשר