Bulkheads – דפוס עיצוב של חוסן (Resiliency Design Pattern)

בספינה, bulkheads הן מחיצות פיזיות בין חלקי גוף-הספינה. כאשר יש דליפה באזור מסוים בגוף הספינה, רק חלל אחד יתמלא בעוד ה bulkheds מונעים מחללים נוספים בספינה להתמלא במים בעקבות הפגיעה.
אם תוכננו בצורה נכונה, הצפה של חלל אחד – לא תטביע את הספינה. הנזק הוא יחסי לגודל החלל שהוצף.
זה לא מצב רצוי, אז זה מצב טוב הרבה יותר – מספינה טובעת.
[ב]

ירידה לעומקו של עניין

רעיון ה bulkhead[א] הוא רעיון עקרוני ליציבות של מערכות, שאותו ניתן לראות בשימוש גם בעולם התוכנה.

מצבי-כשל של מערכות עשויים לנבוע מאינספור מצבים – שלא את כולם ניתן אפילו לחזות. דפוס ה bulkhead מאפשר להתגונן מ"כשל בלתי ידוע" על ידי בידוד נזק אפשרי לאיזור מוגבל במערכת.

בניגוד ל redundancy (רעיון דומה), ב bulkhead כל החלקים הם בשימוש. כלומר: אין חלקים יתירים.
ב redundancy התסריט הנפוץ הוא פגיעה ללא הפרעה לשירות, אך ב bulkhead – ישנה פגיעה חלקית במקום מלאה (תמורת חסכון עלויות ה redundancy).

ל Bulkheads יש גם מחירים: בניית המנגנון, תחזוקתו, ויצירת חוסר יעילות מסוים במערכת – ולכן נרצה להשתמש בו בעיקר במערכות בעלות חשיבות עסקית גבוהה לארגון, וגם אז – במקרים בהם התשואה של הפתרון תהיה גבוהה יחסית להשקעה.

הנה שתי דוגמאות מוכרות ליישום של bulkhead שאנו מכירים מהיום-יום:

    • availability zones ב AWS (או המקבילה בעננים אחרים) – כשל של AZ יחיד יפגע בשירות (בטווח הקצר) – אך יאפשר לנו להמשיך את השירות כרגיל ב AZs האחרים.
      • לצורך כך מושקעים ב Amazon מאמצים רבים על מנת לוודא ש AZ אינם תלויים זה בזה, ושכשל באחד ה AZ (הצפה, נפילת מתח, בעיית תוכנה, וכו') – לא יגרור כשל של ה AZ האחרים.
      • כמובן שבתכנון מערכת המשתמשת ב AWS עלינו ליצור יתירות של שירותים חיוניים (למשל: NAT gateway או בסיס-נתונים) על מנת שנוכל להמשיך ולרוץ בזמן ש AZ אחד כשל.
    • תהליכים במערכת ההפעלה – מערכת ההפעלה יוצרת הפרדה בין תהליכים (processes) שונים כך שכשל בתהליך אחד לא ישפיע על תהליכים אחרים: תהליך אחד קורס – והשאר יכולים להמשיך לרוץ ללא הפרעה.
    • למען הדיוק הטכני שווה לציין שההפרדה הזו אינה bullet proof כאשר מדובר בגישה למשאבים משותפים.
      למשל: תהליך שגוזל 100% CPU עלול להיות מתוזמן (לחלופין) על כל ה cores של המכונה ולשתק בפועל את כולה. עלינו להצמיד את התהליך (בעזרת CPU binding / affinity) ל core מסוים – בכדי לקבל הגנה טובה בפני תסריט ה "CPU 100%". עניין דומה קיים לגבי זיכרון, גישה ל I/O, או כל משאב משותף אחר.

היישום שאני רוצה להתמקד בו הוא יישום אפליקטיבי של מערכת (ווב).

יישום בסיסי של bulkheads: להפריד את השרתים שלנו לשני clusters שונים (ומבודדים זה-מזה) ולנתב בקשות שונות ל cluster שונה. החלוקה יכולה להיות עבור microservice בודד, קבוצה של microservices, או אולי אפילו כל המערכת.

חלוקה גנרית וחסרת בידול היא לרוב רעיון רע ליישום של רעיון ה bulkhead.
אם נחלק טראפיק לפי סיומת כתובת ה IP (זוגי/אי-זוגי) הרי לא באמת עזרנו למערכת – אקראיות יכולה לגרום שקבוצה אחת תדרוש יותר משאבים, אך לא תקבל אותם: את המשאבים חייבנו להיות "חצי-חצי".

החכמה ביישום bulkhead מוצלח היא חלוקה סלקטיבית ע"פ שני קריטריונים:

  • מאפייני כשל (failure conditions) – כך שתעבורה מסוג I עלולה לכשול בעוד תעבורה מסוג II עשויה לעבוד כרגיל.
  • יתרון עסקי (financial benefit) – כאשר יש חשיבות עסקית מאחורי סוגי התעבורה השונים שעשויה להצדיק מצב בו תעבורה סוג I שורדת בעוד תעבורה סוג II כושלת.
Bulkhead מוצלח עשוי להיות על בסיס שני הקריטריונים, או רק אחד מהם.
הנה כמה דוגמאות ליישום של Bulkhead ברמה האפליקטיבית:
 
הדוגמה הקלאסית היא כנראה הפרדה בין לקוחות משלמים ללקוחות לא-משלמים. 
נניח: אתר שנותן שירות מוגבל בחינם, אך שירות משופר בתשלום (freemium).
שימו לב שהחלוקה היא עסקית.
וריאציה מקובלת: שני clusters:

  • Cluster A – ללקוחות משלמים
  • Cluster B – ללקוחות שאינם משלמים.
אם יש בעיה בפיצ'ר של לקוחות לא-משלמים שגורם לבעיה – לקוחות משלמים יכולים (ובצדק!) להמשיך ליהנות משירות תקין.
אפשר לשים יותר חומרה ומשאבים, קונפיגרציות יותר אמינות (גם אם עולות יותר) – ב cluster של הלקוחות המשלמים.החולשה של המודל היא במאפייני הכשל: דווקא הלקוחות המשלמים מקבלים כנראה יותר יכולות, ולכן יש סבירות גבוהה יותר שדווקא הטראפיק שלהם ייתקל בבאג כלשהו – שלא יקרה ללקוחות ה"חינמיים".
קצת פדיחה אם Cluster A נפל – בעוד cluster B עובד כרגיל…

תת וריאציה היא ש Cluster B יקבל תעבורה של שני סוגי הלקוחות: משלמים ולא-משלמים.
במקרה של תקלה – אפשר לדחות לקוחות לא-משלמים כליל מהמערכת. אם יש משהו שיציל את התעבורה של לקוחות משלמים (נניח: עוד חומרה) – אדרבא!
אם יש כשל שנובע מ"פיצ'ר חינמי" (נניח: פרסומות) – יש הגיון עסקי רב לבודד את הכשל מלקוחות משלמים.
הוריאציה הזו הגיונית ככל ש Cluster B גדול מ Cluster A (נניח: פי כמה מונים).

דוגמה: הפרדה בין לקוחות ישירים ללקוחות דרך שותפים עסקיים — בהנחה שה flow של שותפים עסקיים הוא שונה (למשל: חישוב מחיר שונה, מעקב שונה, ועדכון השותף העסקי בזמן אמת – וכו').

כאן שני הקריטריונים באים לידי מימוש: בהנחה שלטראפיק של שותפים עסקיים יש מאפייני כשל אחרים (במידה מספיקה) + יש צידוק עסקי להגן על לקוחות ישירים גם במחיר פגיעה בלקוחות המגיעים דרך שותפים עסקיים.

בדוגמה הזו יש ל bulkheads פוטנציאל גדול יותר להשיג שיפור ממשי מהדוגמה הקודמת.

דוגמה: הפרדה לפי שווקים

למשל:

  • Cluster ללקוחות ארה"ב
  • Cluster ללקוחות מערב אירופה
  • Cluster ללקוחות מזרח-אירופה
  • Cluster ללקוחות אנגליה
בהנחה שעבור כל מדינה יש חלקי קוד ייחודים המתאימים לרגולציה ו/או settings מעט שונים שהם מקובלים יותר (בגלל הבדלים בין השירות במדינות) – העלולים לגרום לתנאי כשל שונים.
ייתכן ויש בעצם 20 מדינות בהן עובדים, כאשר לכל מדינה יש תצורת עבודה מעט שונה. אבל – מאוד יקר לנהל 20 clusters, וגם אחוז המשאבים המבוזבז (כי לא משתפים אותם) – יגדל ויתעצם.
ניתוח של תנאי הכשל (אלו מדינות משתמשות בפיצ'רים שונים –> חשיפה לתנאי כשל פוטנציאלים שונים) והמשמעות העסקית מובילה אותנו לחלוקה ל-4 clusters.
במידה וכל השווקים (לאחר ה clustering) הם בעלי חשיבות עסקית דומה, הפוטנציאל של bulkheads המתואר זה תלוי בעצם בתנאי-כשל שונים משמעותית בין ה clusters. ככל שתנאי הכשל שונים בין ה clusters – כך ההצדקה להצבת bulkheads הולכת ועולה.

להזכיר: כאשר אותו מצב כשל מתרחש בכל התסריטים – כל ה clusters ייפגעו, וההפרדה לא תעזור.

דוגמה אחרונה: מנגנון חדש מול מנגנון ישן ("canary release")
כאשר יש שכתוב של חלקים משמעותיים של המערכת, ובמיוחד כאשר המערכות הללו תלויות גם בשירותי צד-שלישי חדשים (= תנאי כשל נוספים) – ייתכן ויש הצדקה להפריד את התעבורה שעוברת במנגנון החדש והישן לזמן מסוים.
היום, בעידן הענן, לא קשה לעשות הפרדה כזו – אפילו אם היא תחיה, נאמר, לחודשים בודדים.
ה bulkheads יאפשרו שכשל מתגלגל במנגנון החדש, לא יפגע במאסה של הביזנס — שפועל עדיין על המנגנון הישן.

אמנם כל הדוגמאות שנתתי הן ברמת ה cluster האפליקטיבי, אבל הרעיון של Bulkhead הוא כללי ויכול להיות מיושם ברמות שונות. למשל: ברמת ה thread pool או רמת הסכמה בבסיס הנתונים.

אזהרת Patterns!!! (גנרית)

Bulkheads הוא סוג של דפוס עיצוב (Design Pattern) – ודפוסי עיצוב הם דבר "מדליק" המושכים אותנו ליישם אותם.
כני אדם, אנחנו נוטים לנגן שוב בראשנו את הסיפור כיצד השימוש ב Pattern "הציל את המצב" ומלבישים את הסיפור ההוא (שקרה במקום אחר, ואנחנו לא באמת מודעים לפרטים עד הסוף) עלינו, על המערכת שלנו, ועל הארגון שלנו.

הסיפור יכול להישמע טוב – ועדיין להיות לא-בר-קיימא למערכת / לארגון שלכם.

מהנדסי תוכנה, נוטים סטטיסטית לאימוץ מופרז ולא מוצדק (Overuse) של דפוסי עיצוב.
לכן: ההמלצה היא לאמת דפוס-עיצוב רק לאחר שהוכחה הבעיה, ולא כהכנה מראש — (תנו לי, אני ״אוהב״ את הביטוי הזה:) כ ״הכנה למזגן״. (ביטוי מטעה מיסודו, ולכן בזוי בעיני).

אני רק מקווה שפוסט זה יצליח לייצר יותר תועלת (פתרון בעיות אמיתיות) מנזק (over-engineering).
זו דילמה שיש לי לפני כל פרסום פוסט שעוסק ב"דפוס-עיצוב".

שיהיה בהצלחה!

[א] בעולם הספנות bulkheads נקראים גם partitions. המונח "partitions" בעולם התוכנה הוא מאוד מוכר ומתייחס בעולם לרעיון מעט אחר, ולכן בהקשר לתוכנה משתמשים רק במונח bulkheads על מנת לתאר … bulkheads.

[ב] התרשים הזה הוא חלק מהסבר כיצד למדו בעולם הספנות לבנות bulkheads נכונים יותר: על bulkheads שבקצוות האוניה (בעיקר בחרטום) עלול להיות מופעל לחץ גדול יותר ברגע שהוא דולף, ואז הספינה נוטה ומתחילה לשקוע – ועל כן חשוב לבנות אותם חזקים יותר, משאר ה bulkheads בספינה.
בהשלכה אלינו: bulkheads יש לבנות בחכמה: הם לא פתרון קסם לכל מצב.