אז איך בעצם ה-Container runtime עושה את מה שהוא עושה, כלומר מריץ קונטיינרים? במאמר הקודם הצגתי באופן כללי מה הם Namespaces ואת ה-namespace הראשון שהתווסף לקרנל של לינוקס, ה-mount namespace. בהמשך לכך אציג את ה-namespaces המרכזיים וכן אסקור שתי תכונות נוספות של הקרנל שמהוות, יחד עם ה-namespaces את המרכיבים הבסיסיים שבאמצעותם ה-runtime יוצר את הקונטיינרים.
בחזרה ל-namespaces, ה-namespace השני שנוסף לקרנל הוא ה-Network Namespace, ובאופן צפוי הוא מממש הפרדה ברמת ממשקי הרשת. ה-namespace מאפשר להגביל את הגישה של תהליכים לכרטיס רשת מסוים. כל כרטיס, פיזי או וירטואלי, יכול להיות משויך ל-namespace בודד. ניתן להגדיר לתהליך כרטיס רשת וירטואלי עם כתובת IP משלו, על הכרטיס ניתן להגדיר טבלת ניתובים עצמאית, הגדרות פייר-וול, האזנה לפורטים וכו'. אפשר לתת לתהליך הרשאות חלקיות או מלאות על כרטיס, התהליך יכול לגשת באמצעותו לרשת ולהאזין על פורטים מבלי להשפיע על השימוש ברשת של תהליכים אחרים שרצים מחוץ ל-namespace. קיימות מספר תצורות שונות למימוש תקשורת עבור קונטיינרים באמצעות ה-Network namespace, הדיון בתצורות השונות נרחב מכדי שאכסה אותו במסגרת פוסט זה, וחשוב מספיק בשביל שאקדיש לו בעתיד פוסט נפרד.
נניח לרגע שבחרנו להגדיר כתובת IP נפרדת לתהליך שלנו, איך נרשום אותה ב-DNS בשם שונה מהשם של מערכת ההפעלה, כך שניתן יהיה לפנות לשירות עם שם ולא עם כתובת IP? איך נאפשר לתהליך לבקש מהמערכת לשנות את שם המחשב (להשתמש ב-syscall של sethostname) מבלי להשפיע על כלל המערכת? כדי להפריד את ההגדרה של שם מחשב ברמת המערכת מהגדרת שם המחשב שתהליך ספציפי רואה התווסף לקרנל ה-UTS namespace, שמספק הפרדה של שם המחשב והדומיין.
אחרי שהגבלנו את הגישה של התהליך למערכת קבצים נפרדת והפרדנו את הגישה שלו לרשת ואת שם המחשב שלו, התהליך שלנו עדיין יכול לראות את כל התהליכים שרצים במערכת. אם יש לתהליך הרשאות מתאימות הוא יכול גם לחסל אותם ולהפיל את המערכת. כדי להגביל את הגישה של התהליך ולמנוע ממנו אינטראקציה עם יתר התהליכים שרצים במערכת נבודד אותו באמצעות PID namespace. הרצה של תהליך ב-PID namespace חדש יוצרת עץ תהליכים מבודד וריק, מלבד התהליך עצמו שמופיע בה עם PID 1. התהליך לא יכול לראות תהליכים אחרים, למעט תהליכים שהוא עצמו יוצר, ואין לו אפשרות לדעת מה ה-PID האמתי שלו בעץ התהליכים של הכללי.
ישנם עוד שלושה סוגים של Namespaces אבל האחרון שאציין כאן הוא ה-User namespace. ה-namespace הזה יוצר הפרדה בין המשתמשים של מערכת ההפעלה לבין המשתמשים שרואה תהליך ב-namespace. הפרדה זו מאפשרת להריץ תהליך בקונטקסט של משתמש רגיל, כאשר מתוך ה-namespace המשתמש שמריץ את התהליך נראה כאילו הוא root (הוא מוצג כמשתמש עם UID 0). התוצאה היא שהתהליך יכול לעשות פעולות שדורשות הרשאות גבוהות במסגרת ה-namespace כל עוד הן לא דורשות הרשאות גבוהות מחוץ ל-namespace, ברמת המערכת (כמו לבצע כיבוי למערכת ההפעלה, לדוגמה). אפשר גם לתת למשתמש הזה הרשאות ליכולות ספציפיות כך שהוא יוכל לבצע פעולות שדורשות הרשאות גבוהות באמצעות תכונה נוספת של הקרנל שה-runtime מנצל ליצירת קונטיינרים, Linux Capabilities.
במערכות UNIX מסורתיות הייתה חלוקה לשניים – תהליכים יכולים לרוץ כ-privileged או כ-unprivileged. תהליכים שרצים בתור root (כלומר, תהליכים שרצים עם UID 0), רצים כ-privileged ורשאים לבצע כל פעולה במערכת מבלי לעבור בדיקת הרשאות. תהליכים עם UID שונה הם unprivileged ובכל פעולה שלהם נערכות בדיקות האם ההרשאות שלהם מאפשרות לבצע את הפעולה.
בשונה מהגישה הבינארית היוניקסית, לקרנל של לינוקס נוספה חלוקה פרטנית יותר של יכולות שיכולות להינתן לתהליכים למספר תחומים שונים. החלוקה הזו מאפשרת להימנע מהגדרת תהליך כ-privileged ובמקום זאת לתת לו יכולות ספציפיות שהוא נדרש להן. לדוגמה, באופן מס,ורתי על מנת לפתוח פורט שמור (כלומר, פורט נמוך מ-1024) צריך הרשאת root. אם רוצים להריץ שרת web על פורט 80 אפשר, במקום להריץ אותו כ-root, להריץ אותו עם משתמש ייעודי שמוגדרת לו הרשאה מתאימה (CAP_NET_BIND_SERVICE) מבלי לתת לו הרשאות גבוהות נוספות.
ובהקשר של הדיון שלנו, ניתן להגדיר לקונטיינר את ההרשאות להן הוא זקוק לצורך פעולה תקינה מבלי לתת לו הרשאות מעבר לכך. התהליך בקונטיינר ירוץ בתור משתמש במסגרת ה-namespace של הקונטיינר מזוהה כ-root בעוד שבאמת המשתמש שמריץ את התהליך הוא משתמש ייעודי שניתנו לו ההרשאות ההכרחיות בלבד. כל עוד התהליך עובד כצפוי, הוא לא ייתקל בבעיה לבצע את כל מה שהוא צריך ויוכל להמשיך להאמין שהוא רץ כ-root אמתי, אבל במידה והתהליך ינסה משום מה לחרוג ממסגרת ההרשאות שהוגדרה לו הוא יכשל.
באמצעות ה-namespaces שיצרנו הצלחנו לבודד את התהליך שלנו ולהגביל את הגישה שלו למשאבים השונים של מערכת ההפעלה. באמצעות capabilities נתנו לתהליך יכולות שונות שהוא נדרש להם. אבל כל זה עדיין לא ימנע מתהליך סורר להפיל לנו את השרת, בכוונה או בטעות, על ידי צריכת כל משאבי המחשוב הזמינים. מבלי שניישם מגבלות על צריכת המשאבים של תהליכים, כל תהליך יכול לשתק לנו את המערכת, ובמוקדם או במאוחר אחד מהם יעשה את זה.
במעין תקדים אופייני לתחום הזה של קונטיינרים, את הפתרון לבעייה הציגה קבוצת מהנדסים מגוגל שפיתחו בקוד פתוח טכנולוגיה שמאפשרת להגדיר מגבלות על משאבים שונים. לאחר כשנתיים של פיתוח, הגרסה הראשונה של cgroups (כלומר Control Groups) שולבה בקרנל של לינוקס.
המסגרת של cgroup מאפשרת להגדיר הקצאות של משאבים. תהליכים שמשויכים ל- cgroup רשאים לצרוך את המשאבים המוגדרים ל-cgroup, ואותם בלבד. אפשר להגדיר ל-cgroup הקצאות של משאבי מערכת כמו מעבד, IO, זכרון וגישה להתקנים ספציפיים. ניתן גם להבטיח סף מינימלי שה-cgroup יקבל. ההגדרות לא מסתכמות במשאבים פיזיים, ניתן להגביל גם משאבים כמו כמות pids מקסימלית שניתן להריץ במסגרת ה-cgroup. מגבלות יכולות להיות מוגדרות כערכים אבסולוטיים (2 ג'יגה זכרון, לא משנה כמה זכרון פנוי במכונה יש) או באופן יחסי לעומס על המכונה (לא יותר מ-25 אחוז ניצולת cpu אם יש תהליכים אחרים שמתחרים על זמן מעבד). בנוסף הגדרת cgroup מאפשרת לנטר את התהליכים ששיכיים ל-cgroup כיחידה. כך הקרנל יכול לנטר את צריכת המעבד של כל התהליכים שב-cgroup ספציפי ולהקפיא אותם זמנית במידה שהם עומדים לחרוג מהמגבלה שהוגדרה להם. התהליכים ב-cgroup רואים את כמות הזכרון שהוגדרה להם בלבד ואם הם חורגים ממנה הקרנל יעיף אותם עם OOM (Out of memory).
זהו, בגדול. אלו המצרכים הנדרשים ליישום קונטיינרים, תכונות של מערכת ההפעלה שמאפשרות שליטה על מה התהליכים רואים (namespaces), מה תהליכים יכולים לבצע (capabilities), ומה הם יכולים לצרוך (cgroups). ההמשך למאמר זה יהיה יותר טכני ואציג בו כיצד הקרנל מציג את המערכת המופרדת שב-namespace לתהליך שרץ בו.
אז איך בעצם ה-Container runtime עושה את מה שהוא עושה, כלומר מריץ קונטיינרים? במאמר הקודם הצגתי באופן כללי מה הם Namespaces ואת ה-namespace הראשון שהתווסף לקרנל של לינוקס, ה-mount namespace. בהמשך לכך אציג את ה-namespaces המרכזיים וכן אסקור שתי תכונות נוספות של הקרנל שמהוות, יחד עם ה-namespaces את המרכיבים הבסיסיים שבאמצעותם ה-runtime יוצר את הקונטיינרים.
בחזרה ל-namespaces, ה-namespace השני שנוסף לקרנל הוא ה-Network Namespace, ובאופן צפוי הוא מממש הפרדה ברמת ממשקי הרשת. ה-namespace מאפשר להגביל את הגישה של תהליכים לכרטיס רשת מסוים. כל כרטיס, פיזי או וירטואלי, יכול להיות משויך ל-namespace בודד. ניתן להגדיר לתהליך כרטיס רשת וירטואלי עם כתובת IP משלו, על הכרטיס ניתן להגדיר טבלת ניתובים עצמאית, הגדרות פייר-וול, האזנה לפורטים וכו'. אפשר לתת לתהליך הרשאות חלקיות או מלאות על כרטיס, התהליך יכול לגשת באמצעותו לרשת ולהאזין על פורטים מבלי להשפיע על השימוש ברשת של תהליכים אחרים שרצים מחוץ ל-namespace. קיימות מספר תצורות שונות למימוש תקשורת עבור קונטיינרים באמצעות ה-Network namespace, הדיון בתצורות השונות נרחב מכדי שאכסה אותו במסגרת פוסט זה, וחשוב מספיק בשביל שאקדיש לו בעתיד פוסט נפרד.
נניח לרגע שבחרנו להגדיר כתובת IP נפרדת לתהליך שלנו, איך נרשום אותה ב-DNS בשם שונה מהשם של מערכת ההפעלה, כך שניתן יהיה לפנות לשירות עם שם ולא עם כתובת IP? איך נאפשר לתהליך לבקש מהמערכת לשנות את שם המחשב (להשתמש ב-syscall של sethostname) מבלי להשפיע על כלל המערכת? כדי להפריד את ההגדרה של שם מחשב ברמת המערכת מהגדרת שם המחשב שתהליך ספציפי רואה התווסף לקרנל ה-UTS namespace, שמספק הפרדה של שם המחשב והדומיין.
אחרי שהגבלנו את הגישה של התהליך למערכת קבצים נפרדת והפרדנו את הגישה שלו לרשת ואת שם המחשב שלו, התהליך שלנו עדיין יכול לראות את כל התהליכים שרצים במערכת. אם יש לתהליך הרשאות מתאימות הוא יכול גם לחסל אותם ולהפיל את המערכת. כדי להגביל את הגישה של התהליך ולמנוע ממנו אינטראקציה עם יתר התהליכים שרצים במערכת נבודד אותו באמצעות PID namespace. הרצה של תהליך ב-PID namespace חדש יוצרת עץ תהליכים מבודד וריק, מלבד התהליך עצמו שמופיע בה עם PID 1. התהליך לא יכול לראות תהליכים אחרים, למעט תהליכים שהוא עצמו יוצר, ואין לו אפשרות לדעת מה ה-PID האמתי שלו בעץ התהליכים של הכללי.
ישנם עוד שלושה סוגים של Namespaces אבל האחרון שאציין כאן הוא ה-User namespace. ה-namespace הזה יוצר הפרדה בין המשתמשים של מערכת ההפעלה לבין המשתמשים שרואה תהליך ב-namespace. הפרדה זו מאפשרת להריץ תהליך בקונטקסט של משתמש רגיל, כאשר מתוך ה-namespace המשתמש שמריץ את התהליך נראה כאילו הוא root (הוא מוצג כמשתמש עם UID 0). התוצאה היא שהתהליך יכול לעשות פעולות שדורשות הרשאות גבוהות במסגרת ה-namespace כל עוד הן לא דורשות הרשאות גבוהות מחוץ ל-namespace, ברמת המערכת (כמו לבצע כיבוי למערכת ההפעלה, לדוגמה). אפשר גם לתת למשתמש הזה הרשאות ליכולות ספציפיות כך שהוא יוכל לבצע פעולות שדורשות הרשאות גבוהות באמצעות תכונה נוספת של הקרנל שה-runtime מנצל ליצירת קונטיינרים, Linux Capabilities.
במערכות UNIX מסורתיות הייתה חלוקה לשניים – תהליכים יכולים לרוץ כ-privileged או כ-unprivileged. תהליכים שרצים בתור root (כלומר, תהליכים שרצים עם UID 0), רצים כ-privileged ורשאים לבצע כל פעולה במערכת מבלי לעבור בדיקת הרשאות. תהליכים עם UID שונה הם unprivileged ובכל פעולה שלהם נערכות בדיקות האם ההרשאות שלהם מאפשרות לבצע את הפעולה.
בשונה מהגישה הבינארית היוניקסית, לקרנל של לינוקס נוספה חלוקה פרטנית יותר של יכולות שיכולות להינתן לתהליכים למספר תחומים שונים. החלוקה הזו מאפשרת להימנע מהגדרת תהליך כ-privileged ובמקום זאת לתת לו יכולות ספציפיות שהוא נדרש להן. לדוגמה, באופן מס,ורתי על מנת לפתוח פורט שמור (כלומר, פורט נמוך מ-1024) צריך הרשאת root. אם רוצים להריץ שרת web על פורט 80 אפשר, במקום להריץ אותו כ-root, להריץ אותו עם משתמש ייעודי שמוגדרת לו הרשאה מתאימה (CAP_NET_BIND_SERVICE) מבלי לתת לו הרשאות גבוהות נוספות.
ובהקשר של הדיון שלנו, ניתן להגדיר לקונטיינר את ההרשאות להן הוא זקוק לצורך פעולה תקינה מבלי לתת לו הרשאות מעבר לכך. התהליך בקונטיינר ירוץ בתור משתמש במסגרת ה-namespace של הקונטיינר מזוהה כ-root בעוד שבאמת המשתמש שמריץ את התהליך הוא משתמש ייעודי שניתנו לו ההרשאות ההכרחיות בלבד. כל עוד התהליך עובד כצפוי, הוא לא ייתקל בבעיה לבצע את כל מה שהוא צריך ויוכל להמשיך להאמין שהוא רץ כ-root אמתי, אבל במידה והתהליך ינסה משום מה לחרוג ממסגרת ההרשאות שהוגדרה לו הוא יכשל.
באמצעות ה-namespaces שיצרנו הצלחנו לבודד את התהליך שלנו ולהגביל את הגישה שלו למשאבים השונים של מערכת ההפעלה. באמצעות capabilities נתנו לתהליך יכולות שונות שהוא נדרש להם. אבל כל זה עדיין לא ימנע מתהליך סורר להפיל לנו את השרת, בכוונה או בטעות, על ידי צריכת כל משאבי המחשוב הזמינים. מבלי שניישם מגבלות על צריכת המשאבים של תהליכים, כל תהליך יכול לשתק לנו את המערכת, ובמוקדם או במאוחר אחד מהם יעשה את זה.
במעין תקדים אופייני לתחום הזה של קונטיינרים, את הפתרון לבעייה הציגה קבוצת מהנדסים מגוגל שפיתחו בקוד פתוח טכנולוגיה שמאפשרת להגדיר מגבלות על משאבים שונים. לאחר כשנתיים של פיתוח, הגרסה הראשונה של cgroups (כלומר Control Groups) שולבה בקרנל של לינוקס.
המסגרת של cgroup מאפשרת להגדיר הקצאות של משאבים. תהליכים שמשויכים ל- cgroup רשאים לצרוך את המשאבים המוגדרים ל-cgroup, ואותם בלבד. אפשר להגדיר ל-cgroup הקצאות של משאבי מערכת כמו מעבד, IO, זכרון וגישה להתקנים ספציפיים. ניתן גם להבטיח סף מינימלי שה-cgroup יקבל. ההגדרות לא מסתכמות במשאבים פיזיים, ניתן להגביל גם משאבים כמו כמות pids מקסימלית שניתן להריץ במסגרת ה-cgroup. מגבלות יכולות להיות מוגדרות כערכים אבסולוטיים (2 ג'יגה זכרון, לא משנה כמה זכרון פנוי במכונה יש) או באופן יחסי לעומס על המכונה (לא יותר מ-25 אחוז ניצולת cpu אם יש תהליכים אחרים שמתחרים על זמן מעבד). בנוסף הגדרת cgroup מאפשרת לנטר את התהליכים ששיכיים ל-cgroup כיחידה. כך הקרנל יכול לנטר את צריכת המעבד של כל התהליכים שב-cgroup ספציפי ולהקפיא אותם זמנית במידה שהם עומדים לחרוג מהמגבלה שהוגדרה להם. התהליכים ב-cgroup רואים את כמות הזכרון שהוגדרה להם בלבד ואם הם חורגים ממנה הקרנל יעיף אותם עם OOM (Out of memory).
זהו, בגדול. אלו המצרכים הנדרשים ליישום קונטיינרים, תכונות של מערכת ההפעלה שמאפשרות שליטה על מה התהליכים רואים (namespaces), מה תהליכים יכולים לבצע (capabilities), ומה הם יכולים לצרוך (cgroups). ההמשך למאמר זה יהיה יותר טכני ואציג בו כיצד הקרנל מציג את המערכת המופרדת שב-namespace לתהליך שרץ בו.
הודעתך לא התקבלה - נסה שוב מאוחר יותר
Oops! Something went wrong while submitting the form