2015-09-06

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




תגובה 1:

  1. אנונימי26/9/15 17:53

    חומר זהב!
    שומר בצד ליום סגריר כשנגיע לבעיות דומות.
    תודה רבה!

    השבמחק