לבנות למדה Runtime

Published
לבנות למדה Runtime

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

בפוסט זה נרים את המכסה מעל קומפוננטה פנימית בלמדה שנקראית Runtime.

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

נתחיל בלימוד על המבנה של סביבת הריצה של הלמדה.

סביבת הריצה (Lambda Execution Environment)

מה קורה כשהלמדה שכתבתם נקראית? מתחיל רצף פעולות, שמתוארות בתרשים הבא.

רצף הפעולות בקריאת למדה
רצף הפעולות בקריאה ללמדה

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

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

  2. לאחר הורדת הקוד, מאותחלת סביבה בתוך קונטיינר, הסביבה הזאת נקראית Execution Environment, והיא מכילה בנוסף לקוד שלכם, שירותים נוספים פנימיים שמאפשרים קוד שלכם לרוץ. הקונטיינר הוא טכנולוגיה ייחודית ל-AWS שנקרא Firecracker, זו טכנולוגית קונטיינרים שאחד היתרונות המרכזיים שלה הוא המהירות שבה הקונטיינר עולה והמשאבים המועטים שהוא דורש מהממכונה שבה הוא רץ.

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

  4. ורק כעת הגענו למנה העיקרית שהיא קריאה לקוד שנמצא בתוך ה-handler.

פעולות 1 עד 3 (אלו שצבועות בכחול) הם חלק מהלמדה cold start.

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

מה קורה בפנים

lambda execution environment
סביבת הריצה

סביבת הריצה מכילה מספר תהליכים (process) שרצים בנוסף לקוד שלכם:

  1. xray damon - הזכרנו אותו בפוסט על xray , הוא התהליך שמקבל את הטרייסים שהsdk מייצר ומעביר אותם הלאה לשירות ה-xray.

  2. logs daemon - הלוגים שנכתבים נשלחים ל-cloudwatch logs באמצעות התהליך הזה.

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

  4. Runtime - זה התהליך שלשמו התכנסנו, הוא מריץ את הקוד שלכם, מזין אותו בקלט ומחזיר את הפלט לסרוויס של הלמדה.

בנוסף לתהליכים שרצים ישנן מספר נקודות קצה, אלו ה-API’s שהסרוויס של הלמדה שם בתוך סביבת הריצה כדי שהוא יוכל לתקשר עם התהליכים הפנימיים, לדוגמא, ה-Runtime מקבל את הקלט ומחזיר את הפלט לסרוויס של הלמדה באמצעות אותן נקודות קצה.

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

Lambda Runtime

ה-Runtime הוא תהליך שמריץ את הקוד שלכם והוא איש הקשר בין הקוד שלכם לסרוויס של הלמדה והוא עושה זאת באמצעות ממשק ה-API שהסרוויס מספק.

הוא מתרגם את הקלט שהסרוויס של הלמדה שלח לתוכן שהקוד שלכם מבין, הרי הקוד שלכם כתוב בשפת פיתוח X, צריך לתרגם את הקלט למשהו ששפת הפיתוח מבינה, לדוגמא python dictionary במידה ואתם כותבים בפייתון.

ומצד שני הוא מתרגם את הפלט של הקוד שלכם למשהו שה-API של הסרוויס של הלמדה מבין, לדוגמא תרגום ה-python dictionary ל-json.

במידה והקוד שלכם זורק exception, מישהו צריך לתרגם את השגיאה הזאת לקריאת API עבור הסרוויס של הלמדה כדי שהוא יוכל לדווח אחורה שקרתה תקלה.

המשמעות של איש הקשר הזה הוא שה-Runtime פעמים רבות יהיה כתוב בשפה שבה הקוד שלכם כתוב, כי הוא צריך להבין NodeJS Exception, ו-Python Dictionaries וכדומה, כדי שהוא יוכל לבצע תרגום לשפה האוניברסילית שהוא ה-API של הסרוויס של הלמדה.

ה-API של הסרוויס של הלמדה מכתיב את צורת הפעולה של ה-Runtime, בחלק הבא נעבור על מכונת המצבים שכל Runtime צריך לממש.

מכונת מצבים

ה-Runtime מכיל שלושה מצבים:

  1. אתחול

  2. ריצה

  3. כיבוי

Runtime flow
Runtime flow

באתחול, ה-Runtime נטען לזיכרון, ממש התהליך עצמו. לדוגמא, במידה וה-Runtime היה Node, אז ה-Executable של Node היה רץ. אבל לאחר טעינת ה-Runtime לזיכרון, מתבצע אתחול נוסף והוא טעינת הקוד של מפתח הלמדה לזיכרון ואתחולו, משמעות התהליך ברמה הפרקטית של הלמדה הוא טעינה של כל מה שהוא מחוץ ל-handler. שימו לב, זה חלק מה-cold start שהזכרתי למעלה.

לאחר האתחול, ה-Runtime מוכן לקבל קריאות מבחוץ, ולהעבירם לתוך ה-handler, זהו שלב הריצה. כאן מגיעה נקודה מאוד מעניינת, ה-Runtime הוא סוג של Event loop, כלומר לולאה אינסופית שבכל פעם שהלמדה מוטרגת, הסרוויס של הלמדה מודיע ל-Runtime שישנה קריאה וה-Runtime קורא ל-handler. ה-Runtime לעולם לא ייצא מהלולאה, זו לולאה אינסופית.

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

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

API

ישנן שלוש קריאות API:

  1. next - קריאת API זו, מסמנת לסרוויס של הלמדה שאנו כ-Runtime מוכנים לקבל את פרטי הקריאה הבאה. זו קריאת API. שהיא חוסמת (Blocking) כלומר, ה-Runtime יחכה כמה זמן שנדרש לתשובה מה-API הזה ובזמן הזה לא יעשה כלום. דרך אגב, זו גם הנקודה שבה הקונטיינר של הלמדה נכנס למצב שינה, אך זה לפוסט אחר. התשובה לקריאה הזאת, היא ה-Payload שמגיע ל-Handler.

  2. reponse - זו קריאת API שמחזירה את תשובת ה-Handler לסרוויס של הלמדה, כל עוד לא בוצעה קריאה ל-response, הסרוויס של הלמדה יחשוב שה-handler עדין רץ וזה ייחשב כחלק מזמן הריצה של הלמדה.

  3. error - דיווח על שגיאה, במידה והיתה תקלה בריצה של הלמדה, exception לדוגמא, נדווח על התקלה באמצעות קריאת ה-API הזאת.

API Calls
API Calls

שלב קריאות ה-API הוא לולאה אינסופית.

אנחנו מוכנים לכתוב את ה-Runtime שלנו, להזכירכם, השפה של ה-Runtime תיכתב באותה שפה שבה הקוד של ה-Handler ייכתב. אנחנו נכתוב Runtime עבור שפת LLRT, אז קודם נבין זריז את נבכי השפה (היא לא מורכבת) ואז נעבור לכתיבת ה-Runtime שיודע להריץ קוד בשפה הזאת.

LLRT

LLRT היא שפת פיתוח קלת משקל, מבוססת על ג׳אווה סקריפט, שתוכננה לתת מענה לביקוש הגובר ליישומי סרברלס מהירים ויעילים. LLRT מציעה זמן אתחול מהיר עד פי 10 ועלות כוללת נמוכה עד פי 2 בהשוואה לזמני ריצה של NodeJS.

זהו תרגום בעברית של התאור הרשמי.

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

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

  2. הקומפיילר של NodeJS מבצע אופטימיזציות שונות של הקוד כגון JIT, שמאוד מועילות במקרים שהקוד שלך רץ לזמן ממושך, אך פחות יעילות כשהוא רץ בתוך למדה שהיא זמנית (Ephemeral) מטבעה.

LLRT (Low Latency Runtime), נבנתנה במיוחד עבור סביבות כגון למדה ומשפרת את הנקודות הנ״ל. היא קטנה בגודל, מכיוון שהיא לא מכילה את כל מה ש-NodeJS מכילה והיא אינה מבצעת אופטימיזציות שהן פחות מתאימות לסביבות מוגבלות כגון למדה.

ניתן לראות באתר הרשמי של LLRT, השוואת ביצועים ואכן המספרים מאוד מרשימים של פי 10 במהלך אתחול של Cold Start ועד פי 2 במהלך אתחול של Warm Start.

אז איך נראה קוד של LLRT, הוא נראה כמו כל קוד של ג׳אווהסקריפט תקני, לדוגמא

1import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
2
3const client = new DynamoDBClient({});
4
5export const handler = async (event) => {
6 await client.send(
7 new PutItemCommand({
8 TableName: process.env.TABLE_NAME,
9 Item: {
10 id: {
11 S: Math.random().toString(36).substring(2),
12 },
13 content: {
14 S: JSON.stringify(event),
15 },
16 },
17 })
18 );
19 return {
20 statusCode: 200,
21 body: "OK",
22 };
23};

האם ישנם חסרונות, כן, על רגל אחת, אתם מוזמנים להרחיב על כך באתר הרשמי:

  1. אין תמיכה מלאה בכל הספריות שקיימות ב-NodeJS, לדוגמא אין תמיכה ב-https (כן יש תמיכה ב-fetch).

  2. מכיוון שאין תמיכה ב-JIT, ישנם אפליקציות שיחוו דגרגציה בביצועים, לדוגמא אפליקציות שעושות data processing לאורך זמן.

אז אנו נשתמש ב-LLRT על מנת להריץ את Runtime שאנו נכתוב, שימו לב שה-Runtime שלנו יצפה שהקוד של ה-handler יהיה כתוב גם ב-LLRT.

בניית ה-Runtime

אנו נבנה את הפתרון באמצעות הקונסול, נתחיל ביצירת למדה ריקה.

למדה ריקה

  1. גשו לקונסול של הלמדה ולחצו על Create function.

  2. עבור ה-Runtime בחרו Amazon Linux 2023, בבחירה הזאת, אנו אומרים ל-AWS, שאנחנו בעצם נממש את ה-Runtime, אנחנו לא מקבלים שום דבר מוכן. AWS מספקת Runtimes מוכנים כגון NodeJS או Python. במידה ורוצים לייצר אחת בעצמנו, תמיד נבחר ב-Amazon Linux 2023.

  3. בחרו arm64 בארכיטקטורה, אין סיבה טובה לבחירה הזאת מעבר לכך שהבינארי שנשתמש בו על מנת לקמפל קוד של LLRT, קומפל במקור לרוץ על arm64.

יצירת למדה
יצירת למדה ריקה
  1. לחצו על Create function. חכו מספר שניות עד יצירת הלמדה.

  2. מחקו את הקבצים שנוצרו לכם, אין לנו צורך בהם. ניתן למחוק אותם באמצעות סימונם ולחיצה על הכפתור השמאלי של העכבר.

מחיקת קבצים
מחקו את כל הקבצים הללו

Bootstrap

כשסביבת הריצה מעוניינת להריץ את ה-Runtime, היא מחפשת קובץ בודד, שהוא נקודת הכניסה של ה-Runtime, הקובץ נקרא bootstrap ללא שום סיומת והוא צריך להיות executable.

  1. גשו לרשימת הקבצים, לחצו על הכפתור הימני של העכבר ובחרו לייצר קובץ חדש, קראו לו bootstrap.

  2. הוסיפו לו את הקוד הבא:

1#!/bin/sh
2echo Hello Runtime!

מכיוון שאנו מייצרים את הקובץ האמצעות העורך של הלמדה, הקובץ אוטומטית מקבל הרשאות ריצה (Executable).

  1. לחצו על Deploy ואז Test.

  2. אתם אמורים לראות פלט דומה לבא:

1Response:
2{
3 "errorType": "Runtime.ExitError",
4 "errorMessage": "RequestId: c5a00f54-3b3c-4dab-9afb-17972753fc32 Error: Runtime exited without providing a reason"
5}
6
7Function Logs:
8Hello Runtime!
9INIT_REPORT Init Duration: 6.23 ms Phase: init Status: error Error Type: Runtime.ExitError
10Hello Runtime!
11INIT_REPORT Init Duration: 10.38 ms Phase: invoke Status: error Error Type: Runtime.ExitError
12START RequestId: c5a00f54-3b3c-4dab-9afb-17972753fc32 Version: $LATEST
13RequestId: c5a00f54-3b3c-4dab-9afb-17972753fc32 Error: Runtime exited without providing a reason
14Runtime.ExitError
15END RequestId: c5a00f54-3b3c-4dab-9afb-17972753fc32
16REPORT RequestId: c5a00f54-3b3c-4dab-9afb-17972753fc32 Duration: 30.77 ms Billed Duration: 31 ms Memory Size: 128 MB Max Memory Used: 3 MB
17
18Request ID: c5a00f54-3b3c-4dab-9afb-17972753fc32

בואו ננתח את הפלט:

  1. שורות 8 ו-10: סביבת הריצה ניסתה להריץ את ה-Runtime שלנו פעמיים, ניתן לראות זאת באמצעות ההדפסה הכפולה של Hello. למעשה בוצע כאן retry, ריצה מחודשת שסביבת הריצה מבצעת במקרה של תקלה באתחול ה-Runtime. כיצד יודעים שהבעיה היא בשלב האתחול?

  2. שורה 9: הלוגים מראים שהכשלון קרה בשלב האתחול. מדוע נכשל?

  3. שורה 13: הריצה נכשלה בגלל שה-Runtime סיים, סביבת הריצה של הלמדה, מצפה שה-Runtime ירוץ כל עוד הוא לא התבקש לסיים.

השלב הבא הוא הוספת ה-LLRT והרצה של סקריפט בסיסי.

LLRT Layer

אנחנו בונים Runtime המבוסס על LLRT, ולכן נצטרך לכלול את הקובץ הבינארי של LLRT כחלק מהלמדה שלנו. כרגע אנחנו מבצעים את הכל באופן ידני, אך בסביבת ייצור נהוג לארוז את הקובץ הבינארי ואת קובץ האתחול (bootstrap) בתוך שכבת למדה (Lambda Layer).

  1. שכפלו את הקוד שמלווה את הבלוג הזה.

  2. בתוך תיקיית binaries, מצאו את הקובץ llrt-linux-arm64-full-sdk.zip. זהו קובץ ה-LLRT הבינארי, שהוכן עבור ארכיטקטורת arm64 עם תמיכה מלאה ב-AWS SDK.

כעת נייצר שכבת למדה חדשה שמכילה את הבינארי:

  1. עברו אל Layers בתפריט השמאלי של הקונסול.

  2. לחצו על Create layer.

  3. הגדירו את השכבה:

    1. תנו לה שם (למשל, LLRT).

    2. העלו את קובץ ה-ZIP משלב 2.

    3. בחרו arm64 כארכיטקטורה תואמת.

    4. בחר Amazon Linux 2023 עבור ה-Runtime.

    5. ההגדרות הללו יסייעו ל-AWS להתריע בפניך על בחירת שכבה שאינה מתאימה להגדרות הלמדה. לדוגמא, השכבה שאנו מייצרים כאן, אינה מתאימה במידה ותבחרו בפייתון.

  4. לחצו על Create.

LLRT as Layer
הוספת LLRT כשכבה

הרצה ראשונית

בוא נבדוק את השכבה והקובץ הבינארי לפני שניישם את סביבת הריצה המלאה:

  1. חזרו לפונקציית הלמדה.

  2. הוסיפו את השכבה שיצרתם זה עתה:

    1. במסך Add layer, בחר Custom layers.

    2. בחר את שכבת LLRT (או כל שם אחר שניתן לה) מהתפריט הנפתח.

    הוספת שכבה
    הוספת שכבה
    1. קבצי השכבה זמינים תחת /opt בלמדה. עדכנו את קובץ bootstrap כך שיראה כך:

1#!/bin/sh
2
3/opt/llrt hello.js

אנו פשוט מריצים את הקומפיילר על קובץ בשם hello.js

  1. צרו קובץ בשם hello.js עם התוכן הבא:

1console.info("Hello Lambda")
  1. לחץ על Deploy ואז על Test.

תתקבלנה שגיאות מוכרות: שגיאות חוזרות המעידות על retry והעובדה שזה קרה בשלב האתחול, אך שימו לב, ישנה גם הודעה חדשה:

1FATAL Error: "handler" is not a function in "hello"

מדוע זה קרה?

לקובץ הבינארי של LLRT יש יכולות מובנות של סביבת הריצה של Lambda (כגון ניהול לולאת האירועים, בדיוק כזאת שאנו נבנה), כפי שמופיע ב-קוד המקור:

1if env::var("AWS_LAMBDA_RUNTIME_API").is_ok() && env::var("_HANDLER").is_ok() {
2
3        // ...
4
5        start_runtime(&vm).await
6
7} else {
8
9    start_cli(&vm).await;
10
11}

הקוד עצמו כתוב בראסט והוא בודק האם משתני סביבה מסוימים קיימים כדי לזהות אם הוא פועל בתוך למדה. במידה והוא פועל בתוך למדה הוא מתחיל לרוץ כ-Runtime, הוא מחפש handler וזו בדיוק השגיאה אנו רואים, מכיוון שאנו מעוניינים לכתוב את ה-Runtime, אנחנו צריכים ״לעבוד״ עליו כדי להריץ אותו כ-CLI עצמאי, ולכן נסתיר את אחד מאותם משתני סביבה. זהו טיפול שאנו עושים ספיציפית בגלל הצורה שהבינארי של LLRT מתנהג, זה לא יקרה בכל Runtime אחר שנכתוב.

עדכון קובץ bootstrap:

1LOCAL_HANDLER=$_HANDLER env -u _HANDLER /opt/llrt hello.js

כאן אנחנו שומרים את ערך _HANDLER ב-LOCAL_HANDLER לשימוש עתידי, ומסירים את _HANDLER מהסביבה. _HANDLER הוא משתנה סביבה שקיים בסביבת הריצה של כל למדה והוא מכיל את הערך שהוגדר להיות ה-handler של הלמדה.

6. לחץ על Deploy ו-Test שוב.

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

שלב האתחול

שלב האתחול מורכב משלושה חלקים:

  1. טעינת הקובץ הבינארי של LLRT לזיכרון (והרצתו)

  2. ייבוא ה-handler שהוגדר במשתנה הסביבה _HANDLER

  3. ביצוע הקריאה הראשונה לנקודת הקצה /next, אשר נחשפת על ידי שירות הלמדה בסביבת ההרצה

טעינת LLRT

  1. צרו קובץ חדש בשם runtime.mjs (משתמשים בסיומת .mjs כדי לאפשר top level await)

  2. הוסיפו את הפונקציות הבאות לעזר ברישום לוגים:

1/**
2 *
3 * @param {string} message
4 * @param {...any} args
5 */
6function info(message, ...args) {
7 console.info(
8 `[RUNTIME][${new Date().toISOString()}] INFO: ${message}`,
9 ...args
10 );
11}
12
13/**
14 *
15 * @param {string} message
16 * @param {...any} args
17 */
18function error(message, ...args) {
19 console.error(
20 `[RUNTIME][${new Date().toISOString()}] ERROR: ${message}`,
21 ...args
22 );
23}
24
25info("Starting Melio's Runtime");

  1. בקובץ bootstrap, שנו:

LOCAL_HANDLER=$_HANDLER env -u _HANDLER /opt/llrt hello.js

ל:

LOCAL_HANDLER=$_HANDLER env -u _HANDLER /opt/llrt runtime.mjs

  1. משתנה הסביבה LOCAL_HANDLER מחליף עבורנו את המשתנה _HANDLER כדי שנדע מהו ה-handler שאנחנו צריכים לטעון.

  2. לחצו על Deploy ו-Test שוב.

ייבוא ה-handler

כעת נממש את ייבוא ה-handler, שהוא הקוד שרץ בכל הפעלה של הלמדה.

1. הוסיפו את הקוד הבא בקובץ runtime.mjs לפני info("Starting Melio's Runtime"):

1/**
2 *
3 * @returns {Promise<Function>}
4 */
5async function initializeHandler() {
6 info("Initializing runtime...");
7 // Invoke the handler. Get it dynamically from LOCAL_HANDLER environment variable. Remember that the structure is filename.method
8 if (!process.env.LOCAL_HANDLER) {
9 const errorString =
10 "Handler not defined in environment variable LOCAL_HANDLER";
11 error(errorString);
12 await initializationError("Runtime.MissingHandler", errorString, []);
13 process.exit(1);
14 }
15 const [handlerFile, handlerMethod] = process.env.LOCAL_HANDLER.split(".");
16
17 // Try to loads js file and if not found, loads mjs file
18 let module = undefined;
19 try {
20 module = await import(`./${handlerFile}.js`);
21 } catch (e) {
22 try {
23 module = await import(`./${handlerFile}.mjs`);
24 } catch (e) {
25 const errorString = `Handler file ${handlerFile} not found`;
26 error(errorString);
27 await initializationError("Runtime.MissingHandlerFile", errorString, []);
28 process.exit(1);
29 }
30 }
31
32 if (handlerMethod in module === false) {
33 const errorString = `Handler method ${handlerMethod} not found in ${handlerFile}`;
34 error(errorString);
35 await initializationError("Runtime.MissingHandlerMethod", errorString, []);
36 process.exit(1);
37 }
38
39 info("Initialization complete");
40 return module[handlerMethod];
41}
  • שורות 8-14: אנו בודקים אם משתנה הסביבה LOCAL_HANDLER קיים.

  • שורה 15: אנו מפענחים את ערך ה-handler בפורמט קובץ.פונקציה. זו נקודה מעניינת, אין שום ספסיפיקציה שמחייבת אותנו שככה יראה המבנה של ה-handler, נהוג לעשות זאת, אך זה לא מחייב. כשאתם תבנו את ה-Runtime שלכם, תוכלו לקבל כל החלטה לגבי המבנה של הקובץ שבו נמצא ה-handler. כמו כן, תראו בהמשך שה-handler לא חייב להיות handler 😉.

  • שורות 18-30: אנו מנסים לייבא את המודול תחילה כ-.js, ואם זה נכשל, כ-.mjs.

  • שורות 32-37: אנו מוודאים שהשיטה שהוגדרה קיימת ומיוצאת.

  • שורה 40: לבסוף, אנו מחזירים את פונקציית המטפל לשימוש עתידי.

  • בכל שלב, אם נתקלנו בבעיה, אנו מדווחים עליה לשירות הלמדה.

2. אחרי info("Starting Melio's Runtime"), הוסיפוconst handler = await initializeHandler();

תקשורת עם שירות הלמדה

התקשורת עם שירות הלמדה מתבצעת דרך נקודת הקצה

http://${process.env.AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime

משתנה הסביבה AWS_LAMBDA_RUNTIME_API מכיל את הכתובת המקומית של השירות. המפרט המלא נמצא ב-תיעוד AWS. זה ה-Runtime API.

שגיאות אתחול מדווחות ל-Lambda דרך:

נתיב

/runtime/init/error

שיטה

POST

Headers

Lambda-Runtime-Function-Error-Type

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

  1. הוסף את הפונקציה הבאה מעל initializeHandler:

1const baseUrl = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime`;
2
3/**
4 *
5 * @param {string} reason
6 * @param {string} errorMessage
7 * @param {string[]} stackTrace
8 * @returns {Promise<void>}
9 */
10async function initializationError(reason, errorMessage, stackTrace) {
11 const res = await fetch(`${baseUrl}/init/error`, {
12 method: "POST",
13 headers: {
14 "Lambda-Runtime-Function-Error-Type": reason,
15 },
16 ErrorRequest: JSON.stringify({
17 errorMessage,
18 errorType: reason,
19 stackTrace: stackTrace,
20 }),
21 });
22 if (!res.ok) {
23 error("init/error failed", await res.text());
24 }
25 info("init/error success", res.status, res.statusText);
26 info("headers", res.headers);
27 const body = await res.text();
28 info("body", body);
29}
  • שורות 9–19 הן המקום שבו הקסם קורה. מכיוון ש-LLRT תומך רק ב-fetch, אנו משתמשים בו כדי לתקשר עם נקודת הקצה שסופקה על ידי שירות הלמדה.

  • שורה 14 - אנו מספקים את הסיבה לשגיאה ב-header.

  1. לחצו על Deploy ו-Test שוב.

  2. כעת תוכל לראות שגיאה בסגנון:

1{
2
3  "errorType": "Runtime.MissingHandlerMethod"
4
5}

השגיאה נובעת משורה 35 בפונקציית האתחול.

  1. בקובץ hello.js, הוסיפו את הפונקציה הבאה:

1export function handler(event, context) {
2
3  console.info("inside the handler");
4
5}

זה ה-handler של הלמדה, בדיוק מה שחסר.

  1. לחצו על Deploy ו-Test שוב.

אנחנו עדיין בשלב האתחול, כפי שמעידה השגיאה שקיבלנו. כדי לצאת משלב האתחול, עלינו לקרוא לנקודת הקצה /next.

הקריאה הראשונה ל-/next

נקודת הקצה /next מסמנת לשירות הלמדה שה-Runtime מוכן לטפל בבקשות.

נתיב

/runtime/invocation/next

שיטה

GET

  1. הוסף את הפונקציה הבאה מתחת ל-initializeHandler:

1/**
2 *
3 * @returns {Promise<{requestId: string, deadline: Date, invokedFunctionArn: string, traceId: string, body: any}>}
4 */
5async function next() {
6 const res = await fetch(`${baseUrl}/invocation/next`, {
7 method: "GET",
8 });
9
10 if (!res.ok) {
11 error("next failed", await res.text());
12 return null;
13 }
14 info("next success", res.status, res.statusText);
15 info("headers", res.headers);
16 const body = await res.json();
17 info("body", body);
18
19 return {
20 requestId: res.headers.get("Lambda-Runtime-Aws-Request-Id"),
21 deadline: Number(res.headers.get("Lambda-Runtime-Deadline-Ms")),
22 invokedFunctionArn: res.headers.get("Lambda-Runtime-Invoked-Function-Arn"),
23 traceId: res.headers.get("Lambda-Runtime-Trace-Id"),
24 body,
25 };
26}
  • שורות 6-8: הקריאה בפועל לנקודת הקצה.

  • שורות 16-25: זהו הלב של הפונקציה. הקריאה ל-/next חוסמת עד להפעלת הלמדה. כאשר היא מופעלת, התגובה מ-/next מכילה את תוכן הקריאה ואת המטא-נתונים שלה ב-headers. מטא-נתונים אלה, כגון requestId ו-traceId, מועברים כחלק מה-context או משמשים לשיפור הניטור הפנימי (לדוגמא xray id).

  1. אחרי const handler = await initializeHandler();

    הוסף const event = await next();

  2. לחצו על Deploy ו-Test שוב. ייתכן שתופיע שגיאה כי תהליך bootstrap הסתיים מיד לאחר הביצוע. זה תקין ונפתור זאת בשלב הבא כאשר נממש את ה-event loop.

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

שלב הריצה (Invoke Phase)

ה־runtime טען את המודול שמכיל את ה־handler ואתחל כל מה שמחוץ לו. הוא ביצע את הקריאה הראשונה לנתיב next וכעת הוא רשמית מחוץ לשלב האתחול.

בקטע זה, נממש את שלב הריצה (invoke), שהוא למעשה לולאה אינסופית המבצעת את השלבים הבאים:

  1. קריאה לנתיב /next

  2. העברת תוצאת האירוע ל־handler.

  3. החזרת התגובה חזרה לשירות הלמדה, אשר מפיץ אותה ללקוח הקורא.

לולאה אינסופית

  1. הוסף את הקוד הבא מתחת לשורה const handler = await initializeHandler();:

1while (true) {
2 info("Waiting for next invocation...");
3 const event = await next();
4 info("Received next invocation");
5
6 const context = {
7 getRemainingTimeInMillis: () => event.deadline - Date.now(),
8 functionName: process.env.AWS_LAMBDA_FUNCTION_NAME,
9 functionVersion: process.env.AWS_LAMBDA_FUNCTION_VERSION,
10 memoryLimitInMB: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
11 awsRequestId: event.requestId,
12 logGroupName: process.env.AWS_LAMBDA_LOG_GROUP_NAME,
13 logStreamName: process.env.AWS_LAMBDA_LOG_STREAM_NAME,
14 invokedFunctionArn: event.invokedFunctionArn,
15 // We pull these values from the next endpoint, omitted for brevity
16 // identity: null,
17 // clientContext: null,
18 };
19
20 info("Calling handler");
21 const response = await handler(event.body, context);
22 info("Handler complete", response ? response : "with no response");
23}

אנו מבצעים לולאה אינסופית שבכל שלב של הלולאה -

  • אנו מבצעים קריאה ל-next.

  • בונים את אובייקט ה-context. כמו הקריאה ל-handler, זו קונבנציה, אנחנו לא מחויבים לייצר אובייקט context וגם לא מחויבים שיהיה במבנה כפי ש-AWS החליטו.

  1. לחצו על Deploy ו-Test שוב.

אנחנו מתקדמים, אומנם הקריאה נכשלה, אך הפעם על שגיאה שונה, שגיאה של Timeout, מדוע?

השגיאה נובעת מכך שלא דיווחנו את התשובה של ה-handler חזרה. וכמו תקשורת שה-Runtime מבצע, הדיווח יתבצע באמצעות API.

החזרת תגובה

שירות הלמדה שומר מצב פנימי עבור כל קריאה. כאשר מבצעים קריאה ל־/next לקבלת פרטי הקריאה, יש “לסגור” אותה באמצעות קריאה לנתיב /response, שמוגדר כך:

נתיב

/runtime/invocation/${AwsRequestId}/response

שיטה

POST

AwsRequestId הוא path param שמכיל את המזהה הייחודי של הקריאה שקיבלנו כחלק מהתשובה ל-next.

  1. הוסיפו את הקוד הבא לאחר קריאת ה־handler:

1// ...
2const response = await handler(event.body, context);
3// ...
4await invocationResponse(event.requestId, response);
  1. הוסיפו את הקוד הבא מתחת למתודה initializationError:

1/**
2 * @param {string} requestId
3 * @param {any} response
4 * @returns {Promise<void>}
5 */
6async function invocationResponse(requestId, response) {
7 const res = await fetch(`${baseUrl}/invocation/${requestId}/response`, {
8 method: "POST",
9 body: JSON.stringify(response),
10 });
11 if (!res.ok) {
12 error("invocation response failed", await res.text());
13 }
14 info("invocation response success", res.status, res.statusText);
15 info("headers", res.headers);
16 const body = await res.text();
17 info("body", body);
18}

זו מתודה פשוטה - שימו לב לפרמטר requestId, שמתקבל מתגובת הנתיב next.

  1. לחצו על Deploy ו-Test שוב. וזה עובד! זהו בניתם את ה-runtime הראשון שלכם. כעת נשפר את הטיפול בשגיאות.

טיפול בשגיאות

  1. הוסף את השורה הבאה ל־handler שלך - throw new Error("Lambda failed"); ולחצו על Deploy ו-Test שוב.

אתם תראו פלט דומה לפלט הבא:

1{
2 "errorType": "Runtime.ExitError",
3 "errorMessage": "RequestId: 19914c36-ec5c-4fec-9660-ccbc46e79936 Error: Runtime exited with error: exit status 1"
4}

הודעת השגיאה הזו אינה מאוד מפורטת. נשפר אותה על ידי החזרת פרטי השגיאה בפועל. נשתמש בנתיב טיפול בשגיאות של הלמדה, שמוגדר כך:

נתיב

 /runtime/invocation/${AwsRequestId}/error

שיטה

POST

Headers

Lambda-Runtime-Function-Error-Type

AwsRequestId הוא path param שמכיל את המזהה הייחודי של הקריאה שקיבלנו כחלק מהתשובה ל-next. וה-header מכיל תאור של השגיאה שיוחזר למשתמש.

  1. הוסיפו את פונקציית טיפול השגיאות הבאה:

1/**
2 * @param {string} requestId
3 * @param {string} reason
4 * @param {string} errorMessage
5 * @param {string[]} stackTrace
6 * @returns {Promise<void>}
7 */
8async function invocationError(requestId, reason, errorMessage, stackTrace) {
9 const res = await fetch(`${baseUrl}/invocation/${requestId}/error`, {
10 method: "POST",
11 headers: {
12 "Lambda-Runtime-Function-Error-Type": reason,
13 },
14 body: JSON.stringify({
15 errorMessage,
16 errorType: reason,
17 stackTrace: stackTrace,
18 }),
19 });
20 if (!res.ok) {
21 error("invocation error failed", await res.text());
22 }
23 info("invocation error success", res.status, res.statusText);
24 info("headers", res.headers);
25 const body = await res.text();
26 info("body", body);
27}

שימו לב לשורה 14, אני מחזיר אובייקט שמכיל את פרטי השגיאה כגון stack trace, זה בנוסף ל-header.

  1. החליפו את קוד ה־handler בתוך לולאת האירועים בקוד הכולל טיפול בשגיאות.

במקום:

1info("Calling handler");
2const response = await handler(event.body, context);
3info("Handler complete", response ? response : "with no response");
4await invocationResponse(event.requestId, response);

השתמשו בזה:

1try {
2 // Call the handler
3 info("Calling handler");
4 const response = await handler(event.body, context);
5 info("Handler complete", response ? response : "with no response");
6
7 await invocationResponse(event.requestId, response);
8} catch (e) {
9 error("Error handling event", e);
10 await invocationError(
11 event.requestId,
12 "Runtime.HandlerError",
13 e.message,
14 e.stack.split("\n")
15 );
16 continue;
17}
  1. לחצו על Deploy ו-Test שוב. הפעם תוכלו לראות הודעת שגיאה מפורטת.

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

שלב הסגירה

שלב הסגירה (shutdown) הוא פשוט: כאשר שירות הלמדה מחליט לסיים את פעילות הקונטיינר, הוא שולח אות SIGTERM ל־runtime.

במימוש שלנו, הקובץ ההרצה (executable) ייסגר בצורה מסודרת ללא צורך בקוד נוסף. עם זאת, אם ה־runtime שלנו מנהל משאבים שדורשים ניקוי תקין, ייתכן ונרצה לממש מנגנון סגירה מפורש.

לסיכום, בנינו Runtime מותאם אישית ללמדה שמריץ קוד LLRT. במהלך הדרך למדנו על המבנה הפנימי של סביבת הריצה של למדה, הבנו את מכונת המצבים של Runtime והתנסינו בעבודה מול ה-API של שירות הלמדה. המימוש שלנו כלל את כל שלבי החיים של Runtime - משלב האתחול, דרך שלב הריצה ועד לשלב הסגירה. היכולת להבין ולממש Runtime מאפשרת לנו לא רק להבין טוב יותר כיצד למדות עובדות מתחת למכסה המנוע, אלא גם לייצר פתרונות מותאמים אישית לצרכים הייחודיים שלנו (ועל כך בפוסטים עתידיים).

ביבליאוגרפיה

ניוזלטר

תגובות