Fault-Tolerance בארכיטקטורת Microservices, ובכלל

לא כתבתי כבר המון זמן: חופש, ילדים, ועניינים שונים – ולאחרונה לא מעט עבודת ייצוב בלתי-צפויה במערכת שלנו.

למה בלתי צפויה?

ובכן, אתחיל במעט רקע: לאחרונה פירקנו מתוך המערכת הראשית שלנו (\"המונוליט\") כ 7 מיקרו-שירותים חדשים, חלקם קריטיים לפעולה תקינה של המערכת. כל שירות הותקן בסביבה של High Availability עם שניים או שלושה שרתים ב Availability Zones שונים באמזון. חיברנו Monitoring לשירותים – פעולות סטנדרטיות. בהמשך תכננו לפתח את היציבות אפילו יותר, ולהוסיף Circuit Breakers (דפוס עיצוב עליו כתבתי בפוסט קודם) – בכדי להתמודד בצורה יפה יותר עם כשלים חלקיים במערכת.

עד כאן דיי סטנדרטי, טוב ויפה. התכוננו לכך שכל שרת במערכת יכול ליפול בכל רגע, וללא התרעה. אפילו כמה שרתים בו-זמנית.

הבעיה שנתקלנו בה הגיעה מכיוון בלתי-צפוי: משהו שנראה כמו התנהגות חריגה של הרשת.

– \"נראה כמו\" ?

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

  • Latency בתוך ה Data Center (בין AZs) שקופץ מ 2 מילי-שניות בממוצע לכ 80 מילי-שניות בממוצע. 80 מילי-שניות הוא הממוצע, אבל גם לא נדיר להיתקל ב latency של 500 מילי-שניות – כאילו השרת נמצא ביבשת אפריקה (ולא ממש ב Data Center צמוד, עם קווי תקשורת dedicated).
  • גלים של אי-יציבות, בהם אנו חווים אחוז גבוה מאוד של timeouts (קריאות שלא נענות בזמן סביר), במקרים הקיצוניים: יותר מ 1% מניסיונות ה tcp connections שאנו מבצעים – נכשלים (בצורת timeout – לאחר זמן מה).
אנחנו רגילים לתקשורת-לא מושלמת בסביבה של אמזון – אבל זה הרבה יותר ממה שהיינו רגילים אליו עד עתה. בניגוד לעבר – אנו מתנסים לראשונה בכמות גדולה של קריאות סינכרוניות שהן גם קריטיות למערכת (כמה אלפי קריאות בדקה – סה\"כ).
זה נקרא \"ענן\" – אבל ההתנהגות שאנו חווינו דומה הרבה יותר ללב-ים: לעתים שקט ונוח – אבל כשיש סערה, הטלטלה היא גדולה.
מקור: http://wallpoper.com/

סימני הסערה

החוויה החריגה הראשונה שנתקלנו בה – היא starvation: בקשות בשירותים השונים הממתינות בתור לקבל CPU (ליתר דיוק: להיות מתוזמנות ל process של ה Application Server – אולי אספר עוד בפוסט נפרד), וממתינות זמן רב – שניות. אותן קריאות שבד\"כ מטופלות בעשרות מילי-שניות.

כאשר מוסיפים nodes ל cluster, למשל 50%, 100% או אפילו 200% יותר חומרה – המצב לא משתפר: זמני ההמתנה הארוכים (שניות ארוכות) נותרים, ויש אחוז גבוה של כישלונות.

בדיקה מעמיקה יותר גילתה את הסיבה ל starvation: שירות A מבקש משהו משירות B, אך כ 3% מהקריאות לשירות B לא נענות תוך 10 שניות (להזכיר: זמן תגובה ממוצע הוא עשרות בודדות של מילי-שניות).
תוך זמן קצר, כמעט כל התהליכים עסוקים ב\"המתנות ארוכות\" לשירות B – והם אינם פנויים לטיפול בעוד בקשות.
כאשר כל טרנזקציה בשירות B מבצעת כ 3 קריאות לשירות A (\"מה זה משנה? – הן כ\"כ מהירות\"), הסבירות ל\"תקיעת\" הטרנזקציה על timeout עולה מ 1% לכ 3% – כאשר יש כ 1% התנתקויות של connections.

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

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

הפעולה המידית הייתה:

  • הפחתת ה timeouts ל-2 שניות, במקום 10 שניות. הקשר: השירות הכי אטי שלנו עונה ב 95% מהקריאות, גם בשעות עומס – בפחות מ 500 מילי-שניות.
  • צמצום מספר הקריאות בין השירותים: בעזרת caches קצרים מאוד (כ 15 שניות) או בעזרת קריאות bulk. למשל: היה לנו שירות D שבכל טרנזקציה ביצע 12 קריאות לשירות E, למה? – חוסר תשומת לב. שינוי הקוד לקריאת bulk לא הפך את הקוד למסורבל.
    בעצם ריבוי הקריאות, השירות בעצם הכפיל את הסיכוי שלו בפי-12, להיתקע על timeout כלשהו. קריאה אחת שמטפלת ב 12 הבקשות היא יעילה יותר באופן כללי, אך גם מצמצת את סבירות ההתקלויות ב timeouts.
    בכל מקרה: קריאה שלא נענתה בזמן סביר – היא כבר לא רלוונטית.
כששטים במים עמוקים – חשוב לבנות את כלי-השיט כך שיהיה יציב.
מקור: http://www.tutorvista.com/

המשך הפתרון

הסיפור הוא עוד ארוך ומעניין, אך אתמקד בעיקרי הדברים:

  • באופן פרדוקסלי, השימוש ב timeouts (כישלון מהיר) – מעלה את רמת היציבות של המערכת.
  • השימוש ב timeouts + צמצום מספר הקריאות המרוחקות, כמו שהיה במקרה שלנו, בעצם מקריב כ 1% מהבקשות (קיצרנו את ההמתנה ל-2 שניות, אך לא סיפקנו תשובה מלבד הודעת שגיאה) – על מנת להציל את 99% הבקשות האחרות, בזמני סערה (אין ל timeout השפעה כאשר הכל מתנהג כרגיל).

הקרבה של כ 1% מהבקשות הוא עדיין קשה מנשוא, ולכן אפרט את המנגנון שהתאמנו לבעיה.
זו דוגמה נפלאה כיצד דפוס העיצוב המקובל (למשל: Circuit Breaker) הוא כמעט חסר חשיבות – למקרה ספציפי שלנו (הכישלונות הם לא של השרת המרוחק – אלא בדרך הגישה אליו). אם היינו מחברים circuit breakers בכל נקודה במערכת – לא היינו פותרים את הבעיה, על אף השימוש ב \"דפוס עיצוב מקובל ל Fault-Tolerance\".

המנגנון שהרכבנו, בנוי מכמה שכבות:

  1. Timeouts – על מנת להגן על המערכת בפני starvation (ומשם: cascading failures).
  2. Retries – ביצוע ניסיון תקשורת נוסף לשירות מרוחק, במידה וה connection התנתק.
  3. Fallback (פונקציה נקודתית לכל endpoint מרוחק) – המספקת התנהגות ברירת מחדל במידה ולא הצלחנו, גם ב retry – לקבל תשובה מהשירות המרוחק.
  4. Logging and Monitoring – שיעזרו לנו לעשות fine-tune לכל הפרמטרים של הפתרון. ה fine-tuning מוכיח את עצמו כחשוב ביותר.
  5. Circuit Breakers – המנגנון שיעזור לנו להגיע ל Fallbacks מהר יותר, במידה ושרת מרוחק כשל כליל (לא המצב שכרגע מפריע לנו).

אני מבהיר זאת שוב: Timeouts ללא Fallback או Retries זו בעצם הקרבה של ה traffic. היא יותר טובה מכלום, במצבים מסוימים – אך זו איננה התנהגות רצויה כאשר מדובר בקריאות שחשובות למערכת.

בגדול מאוד, בכל קריאה מרוחק – הוספנו מנגנון שמתנהג בערך כך:

===================================================

  1. בצע קריאה מרוחקת (עם timeout של עד 2000 מילי-שניות, בד\"כ פחות).
    1. אם הקריאה הצליחה – שמור אותה ב fallback cache (הסבר – מיד).
  2. אם היה timeout – ספק תשובה מתוך ה fallback cache (פשרה).
===================================================

חשוב להבהיר: ה Fallback cache הוא Cache נפרד מ Cache רגיל (אם יש כזה). הוא ארוך טווח (כרגע: אנו מנסים 24 שעות), ולעתים הוא שומר מידע פחות ספציפי – כדי שיישאר נהיל ולא יהיה גדול מדי.

ע\"פ המדידות שלנו, בזמנים טובים רק כ 1 מ 20,000 או 25,000 קריאות תגיע ל fallback – כלומר משתמש אחד מקבל תשובה שהיא פשרה (degraded service).
בזמני סערה, אחוז הקריאות שמגיעת מתוך ה fallback מגיע ל 1 מ 500 עד 1 ל 80 (די גרוע!) – ואז הפשרה היא התנהגות משמעותית.

באחוזים שכאלו – הסיכוי שבקשה לא תהיה ב fallback cache היא סבירה (מאוד תלוי בשירות, אבל 10% הוא לא מספר מופרך). למקרים כאלו יש לנו גם התנהגות fallback סינתטית – שאינה תלויה ב cache.

מצב ברור שבו אין cache – כאשר אנו מעלים מכונה חדשה. ה caches שלנו, כיום, הם per-מכונה – ולא per-cluster ע\"מ לא להסתמך על קריאות רשת בזמן סערה. למשל: יש לנו שירות אחד שניסינו cache מבוזר של רדיס. זה עובד מצוין (גם ביצועים, ו hit ratio מוצלח יותר) – עד שפעם אחת זה לא עבד מצוין…. (והמבין יבין).

כלומר, המנגנון בפועל נראה דומה יותר לכך:

===================================================

  1. בצע קריאה מרוחקת (עם timeout של עד 2000 מילי-שניות, בד\"כ פחות).
    1. אם הקריאה הצליחה – שמור אותה ב fallback cache (הסבר מייד).
  2. אם היה timeout – ספק תשובה מתוך ה fallback cache (פשרה), או שתספק fallback סינתטי.
===================================================
איך מגדירים fallback סינתטי? זה לא-פשוט, ותלוי מאוד בתסריט הספציפי. כקו-מנחה יש לחשוב על:
  • ערכי ברירת-מחדל טובים.
  • התנהגות שתמנע מפיצ\'רים פחות חשובים לעבוד (למשל: לא להציג pop-up פרסומי למשתמש – אם אין מספיק נתונים להציג אותו יפה)
  • להניח שהמצב שאנו לא יודעים לגביו – אכן מתרחש (כן… המונית עדיין בדרך).
  • להניח שהמצב שאנו לא יודעים לגביו – התרחש בצורה הרעה ביותר (למשל: לא הצלחנו לחייב על הנסיעה)
  • אפשרות: הציגו הודעת שגיאה נעימה למשתמש ובקשו ממנו לנסות שוב. המשתמש לרוב מגיב בטווח של כמה שניות – זמן ארוך כ\"כ (בזמני-מחשב), שמספר דברים עשויים להשתנות לטובה במצב ה caches / הידע שלנו בפרק זמן שכזה.

תמונה מלאה יותר

אני מדלג מעט קדימה… ומוסיף את שני המרכיבים הנוספים למנגנון:

כיוון שהבעיות הן בעיות של אקראיות (כמעט) – ל retry יש סיכוי גדול לעזור.
גילינו למשל, תוך כדי ניסיונות ו tuning את הדבר הבא: רוב הכישלונות שאנו חווים הם ברמת יצירת ה connection (\"לחיצת-יד משולשת\") והרבה פחות בעת קריאת נתונים ברשת.
עוד דבר שגילינו, הוא שלמרות שהצלחות בפתיחת connection מתרחשות ב 99.85% בפחות מ-3 מילי-שניות, הניסיון לעשות retry לפתיחת connection מחדש לאחר כ 5 מילי-שניות – כמעט ולא שיפר דבר. לעומת-זאת, ניסיון retry לפתיחת connection מחדש לאחר כ 30 מילי-שניות עשה פלאים – וברוב הגדול של הפעמים הסתיים ב connection \"בריא\".
האם מדובר בסערה שאיננה אקראית לחלוטין, או שיש לכך איזה הסבר מושכל שתלוי בסביבת הריצה (AWS, וירטואליזציה, וכו\')? קשה לומר – לא חקרנו את העניין לשורש. נסתפק בכך כ retry לאחר 30 מילי-שניות – משפר את מצבנו בצורה משמעותית.
עניין חשוב לשים לב אליו הוא שעושים קריאות חוזרות (retry) רק ל APIs שהם Idempotent, כלומר: לא יהיה שום נזק אם נקרא להם פעמיים. (שליפת נתונים – כן, חיוב כרטיס אשראי – לא).
שימוש ב Cache קצר-טווח הוא עדיין ואלידי ונכון
כל עוד לא מערבבים אותו עם ה fallback cache.
מכאן התוצאה היא:
===================================================

  1. בצע קריאה מרוחקת
    1. נסה קודם לקחת מה cache הרגיל
    2. אם אין – בצע קריאה מרוחקת (עם timeout של עד 2000 מילי-שניות לקריאה, בד\"כ פחות + timeout של 30ms ליצירת connection).
      1. אם היה timeout + במידת האפשר – בצע קריאה שנייה = retry.
      2. אם הקריאה הצליחה – שמור אותה ב fallback cache.
  2. אם לא הצלחנו לספק תשובה – ספק תשובה מתוך ה fallback cache (פשרה), או שתספק fallback סינטתי.
    1. ספק למשתמש חווית-שימוש הטובה ביותר למצב הנתון. חוויה זו ספציפית לכל מקרה.
===================================================
תהליך זה מתרחש לכל Endpoint משמעותי, כאשר יש לעשות fine-tune ל endpoints השונים.

שימו לב להדרגה שנדרשת ב timeouts: אם שירות A קורא לשירות B שקורא לשירות C – ולכל הקריאות יש timeout של 2000 מילי-שניות, כל timeout בין B ל C –> יגרור בהכרח timeout בין A ו B, אפילו אם ל B היה fallback מוצלח לחוסר התשובה של C.

בגלל שכל קריאה ברשת מוסיפה כ 1-3 מילי-שניות, ה timeouts צריכים ללכת ולהתקצר ככל שאנו מתקדמים בקריאות.
ה retry – מסבך שוב את העניין.

כרגע אנחנו משחקים עם קונפיגורציה ידנית שהיא הדרגתית (cascading), קרי: ה timeout בקריאה C <– B תמיד יהיה קצר מה timeout בקריאה בין B <– A.

אחד הרעיונות שאני כרגע משתעשע בהם הוא להעביר \"SLA לזמן תגובה\" כפרמטר בקריאה ל endpoint. כלומר: לומר לשירות \"יש לך 1980 מילי-שניות לענות לבקשה, או שאני מתעלם ממנה\". משהו נוסח \"הודעה זו תשמיד את עצמה בתוך 20 שניות\" של סדרת סרטי \"משימה בלתי-אפשרית\".

באופן זה השירות שמקבל את הבקשה יוכל באופן מושכל לחלק את הזמן שניתן לו בין ה endpoint מהם הוא זקוק לתשובה, או שאולי אפילו יחליט לעובר ל fallback ישר – מחוסר זמן. זה פשוט… ומסובך – באותו הזמן.

כמובן שאת כל המנגנון יעטוף גם Circuit Breaker – זה קצת פחות דחוף עכשיו….

כך נראתה השמדה-עצמית בשנות השמונים.
מקור: http://tvtropes.org/

סיכום

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

מה שחשוב הוא להבין מה הבעיה – ולפתור אותה. לא לפתור את הבעיות שהיו ל Amazon, נטפליקס או SoundCloud.

באופן דומה, אני ממליץ לכם לא לרוץ ולממש את המנגנון שתיארתי – אלא אם אתם נתקלים בהתנהגות דומה.

אם למישהו מכם, יש ניסיון בהתמודדות דומה – אשמח לשוחח על כך. אנא כתבו לי הודעה או שלחו לי מייל (liorb [at] gett.com) ואשמח להחליף תובנות.

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

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

לינקים רלוונטיים:

AWS Noiseness

על Circuit Breakers ויציבות של מערכות מבוזרות

דמיינו 2 חנויות מכולת כמעט זהות: \"המכולת של שמואל\" ו\"המכולת של הלל\".

בשתי חנויות המכולת יש מוצרים איכותיים שאתם אוהבים, מוזיקה טובה מתנגנת, השירות מצוין, הן פתוחות 24/7, והמחירים – הוגנים. אתם אוהבים את המכולות הללו, ואתם אוהבים לקנות בהן.

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

במכולת של הלל – המצב הפוך. הם מוכנים לכך שלא יהיו מוצרים מסוימים זמינים. הם קוראים למצב הזה partial service או degraded service (הם קצת גיקים עם המונחים שלהם). המכולת פתוחה 24/7 למעט מקרים נדירים בהם הם סוגרים אותה (כאשר הלל צריך לצאת לאיזה סידור ואין לו מחליף), אבל כאשר אני הולך לקנות משהו – ייתכן ואחזור ללא קוטג\' הביתה. לפעמים לא אכפת לי, אבל לפעמים זה מבאס. אם זה ממש חשוב לי אפילו אקפוץ למכולת אחרת להביא את הקוטג\'. אבל בגלל שאני אוהב את החוויה במכולת – אני עדיין אחזור אליה בקנייה הבאה.
במקום לדאוג ל\"אפס חוסרים במלאי\", החבר\'ה של הלל עסוקים בכך שחוסר של מוצר מסוים במלאי – לא יגרום למכולת להיסגר מעצמה. זה מאוד מוזר אבל הם מספרים שאם לא יעשו שום דבר, מחסור בטונה פתאום יגרום למכולת להסגר מעצמה. גיקים – אמרנו כבר?

איזה מודל של מכולת נשמע לכם יותר הגיוני?
אם אתם חיים בישראל – כנראה שאתם מכירים את המכולת של הלל (בווריאציה פחות או יותר מגניבה), ואני מניח שהמודל הזה נשמע עדיף: עדיף לחזור הביתה בלי קוטג\' – מאשר לחזור בידיים ריקות כי המכולת סגורה.

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

בצורה בלתי מובנת, האינטואיציה של רובנו היא ללכת בגישה הראשונה.
הסבר אחד הוא שגישה זו ישימה בעת שהמערכת עוד קטנה – וקשה לנו להתרגל לרעיון שכללי המשחק השתנו עם הזמן.
הסבר שני הוא שאנו \"מהנדסים באופי\" שחושבים מנקודת מבט של \"המוצר שאו בנינו ויש לנו אחריות עליו\" וקצת פחות מנקודת המבט של חווית השירות שמתקבלת בסוף. אם היינו צרכנים של המוצר – קרוב לוודאי שהיינו מעדיפים את חווית השימוש השנייה (בד\"כ. ישנם כמובן גם מקרים בהם הראשונה עדיפה, למשל: בניתוח לב פתוח, לא היינו רוצים להתחיל ניתוח אם חסר משהו… 🙂 ).

Circuit Breakers

המנגנון הבסיסי המאפשר את מודל \"המכולת של הלל\" הוא ה Circuit Breaker.
לצורך הדיון אניח שמדובר במערכת בארכיטקטורת Micro-Services (בקיצור: MSA) והשירותים השונים הם הרזולוציה לבידוד בפני תקלות. ניתן לעשות אותו הדבר במערכת \"מונוליטית\", אם כי המשמעת העצמית הנדרשת היא לעתים מעט גבוהה יותר.

ה Circuit Breaker הוא מן Proxy לשירות מרוחק – אשר רק דרכו עושים את הקריאות. השירות המרוחק יכול להיות:
א. זמין
ב. לא זמין
ג. זמין – אך כושל (יש errors)
ד. זמין אבל אטי

אם ה Circuit Breaker מזהה שהשירות המרוחק אינו עובד כמו שצריך – הוא \"יוצר נתק\" (מטאפורה: פותח את המעגל) ולא מאפשר לקריאות נוספות לעבור. הפונקציות השונות מקבלות אינדיקציה על המצב הזה ופועלות כמיטב יכולתן לספק שירות חלקי – שעדיף על חוסר שירות.

במשך הזמן, ה Circuit Breaker נותן לקריאות בודדות לעבור ומנסה לזהות אם המערכת המרוחקת חזרה לספק שירות תקין. אם כן – הוא \"סוגר את המעגל\" ומחזיר את המצב לקדמותו.

מקורות: \"דפוס העיצוב\" של ה Circuit Breaker הוצג לראשונה ב 2007 ע\"י מייקל נייגארד, בספר המצויין (!!): !Release It.
ב 2014 מרטין פאולר כתב פוסט בבלוג על דפוס העיצוב (הוא לא חידש שם, רק הסביר יותר פשוט), ומאז זהו ה reference המקובל.

המימוש הפנימי של ה Circuit Breaker הוא לרוב כמכונת מצבים פשוטה:

מקור: orgsync/stoplight (מימוש ברובי של Circuit Breaker)

  • המצב ההתחלתי הוא \"מעגל סגור\" (ירוק).
  • אם יש מספר מסוים של תקלות (מוצג בתרשים כ \"fail\") – עוברים למצב \"אדום\".
    • בד\"כ לא מדובר בתקלה יחידה אלא threshold של סדרת תקלות. למשל: רצף של 5 exceptions, כאשר ה threshold הוא של 5.
  • במצב \"אדום\" – כל ניסיון קריאה לשירות המרוחק ייענה בערך החזרה מסוים (או Exception) מצדו של ה Circuit Breaker שאומר \"תסתדרו בלי!\" (כלומר: בלי השירות).
  • לאחר זמן מה (נניח: 120 שניות) של מצב אדום, ה circuit breaker יעבור למצב צהוב – ניסיון להחזיר שירות.
    • הוא יאפשר למספר נתון של קריאות לעבור לשירות המרוחק כדי לבדוק את התגובה. למשל: 10 קריאות. לכל שאר הקריאות הוא עדיין יחזיר את התשובה \"תסתדרו בלי!\".
    • אם ב 10 הקריאות הללו, הוא מזהה treshhold מסוים בעייתי, למשל: רצף של 3 exceptions מצד השירות המרוחק (לרוב ה threshold של המצב הצהוב הוא יותר מחמיר מזה של המצב הירוק) – הוא חוזר למצב אדום.
    • אחרת – הוא מחזיר את המערכת למצב ירוק.
כמו שאתם מבינים – יש המון וריאציות אפשריות של התנהגות של Circuit Breakers:
  • אפשר לשים thresholds שונים ומשונים. למשל, משהו שלא הזכרנו: שניסיונות חזרה למצב הצהוב יקרו בתדירות משתנה: יש גישה של המתנה של 2 דקות ואז כל 30 שניות (כאשר השירות המרוחק הוא חשוב) ויש גישה של להאריך את זמני הניסיון, למשל: פעם ראשונה 2 דקות, אח\"כ 5 דקות, וכל פעם נוספת – 10 דקות (כאשר השירות המרוחק פחות חשוב ודווקא שגיאה ממנו היא לא נעימה).
  • כאשר circuit breaker מופעל – כנראה שתרצו שסוג של alert יעלה ל monitoring, הרי מדובר בהחלטה לתת שירות פחות טוב. מתי ואיך להעלות את ה alert – עניין לבחירה.
  • לעתים יש אפשרות של override ידני – אפשרות לקבע מצב ירוק/אדום/צהוב של ה circuit breaker בהתערבות ידנית.
  • אולי הכי חשוב: כיצד ה circuit breaker מאתר שגיאה של השירות המרוחק?
    • האם ע\"י ניטור ה responses של ההודעות שחזרו מהשירות המרוחק (למשל: HTTP 5xx)?
    • האם ע\"י ביצוע בדיקה יזומה (proactive) לשרת המרוחק (למשל: שליחת pinging או בדיקת health-check)?
    • אם מדובר על אטיות, ה circuit breaker יכול למדוד את מהירות החזרה של קריאות מהשרת המרוחק. אפשר להגיב לממוצע של קריאות אטיות, או לעתים להסתכל על אחוזון מסוים. למשל: אם 5% מהקריאות אטיות מ 10 שניות, אנו רוצים לנתק – מכיוון שזה אומר שירות גרוע למשתמש הקצה. האם מנתקים רק את הקריאות האטיות או את כולן?!
    • וכו\'
תוכלו למצוא מספר מימושים שונים של Circuit Breakers, בכל שפת תכנות כמעט (לא ראיתי באסמבלי ;-)), אבל לא נדיר המקרה בהם תרצו לממש גם וריאציה משלכם – במידה ויש לכם מקרה חשוב שלא מטופל ע\"י המימושים הזמינים.

לא נדיר גם מצב בו אתם משתמשים בכמה circuit breakers שונים בתוך המערכת. תלוי כמה גדולה ומורכבת היא.
ההתעסקות עם circuit breakers גם היא יכולה להיות משמעותית, וכדאי לשים לב ש:

  • אתם לא נסחפים לאזור ה over-optimization שאיננו משתלם מבחינת ההשקעה.
  • אתם יוצרים מערכת של circuit breaker שהיא מורכבת מדי לניטור ושליטה בזמן אירוע אמת ב Production (כאשר אתם לא יכולים לענות על שאלות כמו: \"מדוע x התנתק\"? או \"אילו ניתוקים היו בזמן נתון\").

אלו דוגמאות מהעולם האמיתי של ל Partial Service ניתן לתת? הנה כמה שאני נתקלתי בהן:

  • לוגים, לוגים, לוגים! בעם הייתה לנו מערכת עם שירות ירוד כמעט יומיים (!!) עד שהבנו שהיא נופלת כל הזמן כי הדיסק מלא ופעולות כתיבה ללוג נכשלות. אם כתיבה ללוג נכשלת – עדיף לא לכתוב לוגים, מאשר לגרום ל IO exceptions שמשבשים תהליכים שלמים במערכת. 
    • בווריאציה אחרת מערכת מרוחקת לדיווח של בעיות (סוג של alerts) הגיבה ב latency של 4 שניות, וגרמה לשיבושים רבים בשירות שדיווח לה על בעיות זניחות, יחסית.
  • שירות ש\"מצייר\" מסלול נסיעה על המפה של החשבונית (בעולם המוניות). אם הוא לא זמין / מגיב היטב – שלח חשבוניות בלי ציור של המסלול, מה הבעיה?
  • שירות שמבצע סליקה של תשלומים. עדיף לשמור את סכום העסקה ולנסות לחייב כמה דקות מאוחר יותר (תחת סיכון של חיובים, עד סכום מסוים, ללא כיסוי) – מאשר לדחות על הסף את כל העסקאות, אפילו בפרק זמן קצר יחסית (כמה דקות).
  • וכו\' וכו\'

Throttling

עוד וריאציה דומה של Circuit Breaker היא מנגנון throttling (\"להחזיק אצבע על הקשית כך שלא יהיה זרם חזק מדי\").

למשל (מקרה אמיתי שנתקלתי בו): ידוע שיש שרת Backend מרוחק שאין לו יכולות scale. המערכת שלנו – עם scale משתנה. אם נפנה לשרת המרוחק בקצב גבוה מדי, הוא עלול להתרסק (לא רק \"להגיב לאט\") – מה שיפגע בחזרה, ובצורה מורגשת בשירות שלנו.

ניתן לייצר circuit breaker ופשוט לספק שירות חלקי בזמן שהוא \"שוכב מעולף על הרצפה\" או שאפשר להגביל, מתוך אינטרס הדדי, את קצב הקריאות אליו – לכזה שלא יקשה עליו יותר מדי (ולהוסיף circuit breaker, במידה והדברים לא מתנהגים כפי שציפינו).

המנגנון הוא דיי פשוט (אתאר את המימוש הספציפי) – עוקבים אחר מספר הבקשות הפעילות לשרת המרוחק בעזרת distributed, non-blocking lock (קל יותר באנגלית) על בסיס רדיס: לפני קריאה לשרת המרוחק מנסים \"לקבל\" Lock – אם מצליחים – מבצעים את הקריאה. אם לא מצליחים (כי כבר יש 10 קריאות פתוחות) – מחזירים הודעת \"תסתדרו בלי!\" לפונקציה שביקשה את השירות, ונותנים לה לספק שירות חלקי.

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

TCP timeouts

עוד נקודה ששווה להזכיר היא ה timeouts שאנו קובעים לתקשורת.
Timeouts הם מנגנון הגנה ממעלה ראשונה: אם שרת מרוחק לא מגיב – לא נרצה לתקוע thread (או אפילו request) לאורך זמן. תסריט נפוץ הוא שירות אטי, שגורם לשירות שקורא לו גם להיות אטי (כי הוא עסוק בלהמתין), שגורם גם הוא לשירותים האחרים התלויים בו – להיות אטיים באותו האופן.

למרות ש TCP הוא \"reliable protocol\", גם לו יש תקלות: בעיקר התנתקויות. כאשר קצב הקריאות בין שירותים הולך גדל – אנו נחווה תקלות אלו יותר ויותר.

התנהגות נפוצה היא לספק אותו ה timeout ליצירת ה connection וביצוע הקריאה עצמה (פעולת \"קריאה\"). אבל:

  • פעולת ה connection היא פעולה פשוטה ומהירה – היא אורכת, באופן טיפוסי, שבריר של שנייה (בניכוי network latency).
  • פעולת הקריאה לרוב גורמת לשירות השני לעבוד: לקרוא מידע מבסיס הנתונים, לקרוא לשירותים אחרים, לבצע עבודת CPU משמעותית וכו. זמן מקובל לקריאה שכזו הוא כ 100ms וגם לא נדיר להיתקל במצב של 1000ms ויותר (שוב: בניכוי network latency).

לכן, אם נגדיר timeouts באופן הבא:

  • עבור פעולת ה connection של ה TCP – נגדיר timeout בסך ה: tolerable latency
  • עבור פעולת ה read של ה TCP – נגדיר timeout בסך: tolerable latency + tolerable server time

נוכל לצמצם בצורה מורגשת זמני המתנה מיותרים.

כמו כן, אם יש לנו timeout על connection – כדאי לשקול בחיוב פעולת retry מיידית.
ניתן להראות ש timeout קצר ליצירת connection + ביצוע retry יחיד – היא מדיניות שברוב המקרים תהיה עדיפה בהרבה על יצירת connection עם timeout ארוך.

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

אחד הנתונים הידועים ב AWS הוא ש latency בין AZs יכול להיות עד 10ms. אם מסתכלים על הנתונים עצמם רואים ש 10ms הוא לא ממוצע, אלא אירוע נדיר יחסית: בבדיקות שערך Matthew Barlocker (חברת Lucid Software) – הוא ראה שבאחוזון ה 99.85% מקבלים latency בין AZ של 3ms בלבד:

הערה: אמזון מתעדפת נמוך (de-prioritize) קריאות ICMP (פרוטוקול השליטה של TCP/IP, הכולל גם את פקודת ה ping) – ולכן לא כדאי להסתמך על Ping להערכת ה latency ב AWS.

מסקנה אפשרית אחת היא שסביר לקבוע TCP Timeout של 3ms כאשר ליצירת connection באותו ה AWS region.
על פעולות HTTP GET ניתן לשקול (תלוי במקרה) מדיניות דומה של מתן timeouts קצרים יחסית (יש לקחת בחשבון את זמן השרת + network latency) – עם אפשרות ל retry.

\"Be Resilient\" vs. \"Fail Fast\"

בתור אייטם אחרון אני רוצה להזכיר דילמה נפוצה בתחום היציבות של מערכות:

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

מדיניות טובה אחרת אומרת: Fail Fast – אם המערכת מזהה שגיאה או סבירות גבוהה לשגיאה, עליה \"לשבור את הכלים באותו הרגע\" – וכך יהיה קל יותר לאתר את התקלה. אם התקלה נגררת ונגררת – ה\"שבר\" צפוי להיות בשלב מאוחר יותר במערכת, בשלב שיהיה קשה מאוד להבין ממנו מה מקור הבעיה האמיתי (root cause).

כיצד 2 גישות חכמות אלו משתלבות זו עם זו בעולם של מערכות מבוזרות, השאופות ליציבות גבוהה?

למרות הסתירה הבסיסית, ניתן לשלב את שתי הגישות:

  • אם מזהים כשל שנראה שניתן \"לספוג\" – אפשר לבצע Alert Fast, בשאיפה שה Alert מספיק ברור ומשמעותי כדי שיהיה ניתן לנטר אותו ולטפל בו בזמן סביר.
  • אם מזהים כשל שנראה שלא ניתן \"לספוג\" אותו – זהו באמת מצב שכדאי לעשות Fail Fast.
פעמים רבות, מה שמתחיל כ\"כשל חלקי\" שניתן לספוג אותו – מתגלגל בהמשך לכשל משמעותי יותר שלא ניתן עוד לספוג (\"כמות הופכת לאיכות\"  – של הכשל). אם אתם בוחרים בכיוון של Alerts – כדאי שאלו יהיו Alerts שתוכלו, ואכן תתייחסו אליהם – בזמן סביר.

סיכום

בעוד שמערכת יחידה (\"מונוליטית\") מתעסקים באופטימיזציה של ה up-time (מדד ברור למדי) – במערכות מבוזרות, המציאות יכולה להיות מורכבת / גמישה ברבה.

אם המערכת המבוזרת מתוכננת נכון – היא יכולה להימצא במצבים שונים של כשלים חלקיים לאורך הזמן.
לא עוד מצב \"למעלה\" מול מצב \"למטה\", אלא יותר בנוסח \"כואב לי קצת הראש, אבל אני סה\'\'כ בסדר\" 🙂

היכולת של מערכת להימצא במצבים שונים של כשלים-חלקיים מוסיפה מורכבות למערכת – אך מאפשרת להגיע לזמינות (availability) גבוהה יותר כאשר מדובר במערכת מורכבת.
העצה של מרטין פאוולר ל MonolithFirst היא טובה בהיבט הזה: מערכת מבוזרת היא בהחלט מורכבת יותר – ומבחינת זמינות  משתלם לעבור למודל מבוזר רק כאשר כלל המערכת הגיע לסף מסוים של מורכבות.
זמינות גבוהה של מערכת מושגת לא רק על ידי תכנון נבון, אלא גם ע\"י אימונים ושיפור תמידי.
Chaos Monkey, למשל, הוא כלי Open Source ש\"יפיל\" לכם בצורה יזומה אך אקראית שירותים (או שרתים) במערכת – כדי שתוכלו לבדוק את ההתמודדות שלכם, ולשפר את צורות התגובה שלכם לכישלון שכזה.
בשלב ראשון ניתן להפעיל אותו בסביבת בדיקות – בה נפילה היא למידה ללא נזק, ובהדרגה ניתן להתקדם לסביבות יותר ויותר מציאויתיות ומחייבות. אם אתם מסוגלים להפעיל אותו ב production, בשעות העומס שלכם, ולשרוד עם שירות סביר – אז אתם בליגה העולמית!
שיהיה בהצלחה!