טיפים כלליים לפיתוח

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

איך לכתוב שירותים אפקטיביים

בקטע הזה מתוארות שיטות מומלצות כלליות לתכנון ולהטמעה של שירות Cloud Run.

פעילות ברקע

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

הגדרת חיוב לפי מכונה לשימוש בפעילויות ברקע

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

אם אתם משתמשים בחיוב לפי בקשות, הימנעו מפעילויות ברקע

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

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

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

מחיקת קבצים זמניים

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

דיווח על שגיאות

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

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

אופטימיזציה של הביצועים

בקטע הזה מתוארות שיטות מומלצות לאופטימיזציה של הביצועים.

הפעלה מהירה של קונטיינרים

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

התרחיש לפתיחת היום כולל:

  • הורדת קובץ האימג' של הקונטיינר (באמצעות טכנולוגיית הסטרימינג של קובץ האימג' של הקונטיינר ב-Cloud Run)
  • הפעלת הקונטיינר באמצעות הפקודה entrypoint.
  • ממתינים שהקונטיינר יתחיל להאזין ביציאה שהוגדרה.

אופטימיזציה של מהירות ההפעלה של הקונטיינר מצמצמת את זמן האחזור של עיבוד הבקשות.

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

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

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

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

שימו לב שבקשה שממתינה להפעלת מכונה תישאר בהמתנה בתור באופן הבא:

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

שימוש חכם ביחסי תלות

אם אתם משתמשים בשפה דינמית עם ספריות תלויות, כמו ייבוא מודולים ב-Node.js, זמן הטעינה של המודולים האלה מתווסף לזמן האחזור של ההפעלה.

כדי לקצר את זמן הטעינה של האפליקציה:

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

שימוש במשתנים גלובליים

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

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

Node.js

const functions = require('@google-cloud/functions-framework');

// TODO(developer): Define your own computations
const {lightComputation, heavyComputation} = require('./computations');

// Global (instance-wide) scope
// This computation runs once (at instance cold-start)
const instanceVar = heavyComputation();

/**
 * HTTP function that declares a variable.
 *
 * @param {Object} req request context.
 * @param {Object} res response context.
 */
functions.http('scopeDemo', (req, res) => {
  // Per-function scope
  // This computation runs every time this function is called
  const functionVar = lightComputation();

  res.send(`Per instance: ${instanceVar}, per function: ${functionVar}`);
});

Python

import time

import functions_framework


# Placeholder
def heavy_computation():
    return time.time()


# Placeholder
def light_computation():
    return time.time()


# Global (instance-wide) scope
# This computation runs at instance cold-start
instance_var = heavy_computation()


@functions_framework.http
def scope_demo(request):
    """
    HTTP Cloud Function that declares a variable.
    Args:
        request (flask.Request): The request object.
        <http://flask.pocoo.org/docs/1.0/api/#flask.Request>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>.
    """

    # Per-function scope
    # This computation runs every time this function is called
    function_var = light_computation()
    return f"Instance: {instance_var}; function: {function_var}"

Go


// h is in the global (instance-wide) scope.
var h string

// init runs during package initialization. So, this will only run during an
// an instance's cold start.
func init() {
	h = heavyComputation()
	functions.HTTP("ScopeDemo", ScopeDemo)
}

// ScopeDemo is an example of using globally and locally
// scoped variables in a function.
func ScopeDemo(w http.ResponseWriter, r *http.Request) {
	l := lightComputation()
	fmt.Fprintf(w, "Global: %q, Local: %q", h, l)
}

Java


import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;

public class Scopes implements HttpFunction {
  // Global (instance-wide) scope
  // This computation runs at instance cold-start.
  // Warning: Class variables used in functions code must be thread-safe.
  private static final int INSTANCE_VAR = heavyComputation();

  @Override
  public void service(HttpRequest request, HttpResponse response)
      throws IOException {
    // Per-function scope
    // This computation runs every time this function is called
    int functionVar = lightComputation();

    var writer = new PrintWriter(response.getWriter());
    writer.printf("Instance: %s; function: %s", INSTANCE_VAR, functionVar);
  }

  private static int lightComputation() {
    int[] numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    return Arrays.stream(numbers).sum();
  }

  private static int heavyComputation() {
    int[] numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    return Arrays.stream(numbers).reduce((t, x) -> t * x).getAsInt();
  }
}

ביצוע אתחול מדורג של משתנים גלובליים

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

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

Node.js

const functions = require('@google-cloud/functions-framework');

// Always initialized (at cold-start)
const nonLazyGlobal = fileWideComputation();

// Declared at cold-start, but only initialized if/when the function executes
let lazyGlobal;

/**
 * HTTP function that uses lazy-initialized globals
 *
 * @param {Object} req request context.
 * @param {Object} res response context.
 */
functions.http('lazyGlobals', (req, res) => {
  // This value is initialized only if (and when) the function is called
  lazyGlobal = lazyGlobal || functionSpecificComputation();

  res.send(`Lazy global: ${lazyGlobal}, non-lazy global: ${nonLazyGlobal}`);
});

Python

import functions_framework

# Always initialized (at cold-start)
non_lazy_global = file_wide_computation()

# Declared at cold-start, but only initialized if/when the function executes
lazy_global = None


@functions_framework.http
def lazy_globals(request):
    """
    HTTP Cloud Function that uses lazily-initialized globals.
    Args:
        request (flask.Request): The request object.
        <http://flask.pocoo.org/docs/1.0/api/#flask.Request>
    Returns:
        The response text, or any set of values that can be turned into a
        Response object using `make_response`
        <http://flask.pocoo.org/docs/1.0/api/#flask.Flask.make_response>.
    """
    global lazy_global, non_lazy_global  # noqa: F824

    # This value is initialized only if (and when) the function is called
    if not lazy_global:
        lazy_global = function_specific_computation()

    return f"Lazy: {lazy_global}, non-lazy: {non_lazy_global}."

Go


// Package tips contains tips for writing Cloud Functions in Go.
package tips

import (
	"context"
	"log"
	"net/http"
	"sync"

	"cloud.google.com/go/storage"
	"github.com/GoogleCloudPlatform/functions-framework-go/functions"
)

// client is lazily initialized by LazyGlobal.
var client *storage.Client
var clientOnce sync.Once

func init() {
	functions.HTTP("LazyGlobal", LazyGlobal)
}

// LazyGlobal is an example of lazily initializing a Google Cloud Storage client.
func LazyGlobal(w http.ResponseWriter, r *http.Request) {
	// You may wish to add different checks to see if the client is needed for
	// this request.
	clientOnce.Do(func() {
		// Pre-declare an err variable to avoid shadowing client.
		var err error
		client, err = storage.NewClient(context.Background())
		if err != nil {
			http.Error(w, "Internal error", http.StatusInternalServerError)
			log.Printf("storage.NewClient: %v", err)
			return
		}
	})
	// Use client.
}

Java


import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;

public class LazyFields implements HttpFunction {
  // Always initialized (at cold-start)
  // Warning: Class variables used in Servlet classes must be thread-safe,
  // or else might introduce race conditions in your code.
  private static final int NON_LAZY_GLOBAL = fileWideComputation();

  // Declared at cold-start, but only initialized if/when the function executes
  // Uses the "initialization-on-demand holder" idiom
  // More information: https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom
  private static class LazyGlobalHolder {
    // Making the default constructor private prohibits instantiation of this class
    private LazyGlobalHolder() {}

    // This value is initialized only if (and when) the getLazyGlobal() function below is called
    private static final Integer INSTANCE = functionSpecificComputation();

    private static Integer getInstance() {
      return LazyGlobalHolder.INSTANCE;
    }
  }

  @Override
  public void service(HttpRequest request, HttpResponse response)
      throws IOException {
    Integer lazyGlobal = LazyGlobalHolder.getInstance();

    var writer = new PrintWriter(response.getWriter());
    writer.printf("Lazy global: %s; non-lazy global: %s%n", lazyGlobal, NON_LAZY_GLOBAL);
  }

  private static int functionSpecificComputation() {
    int[] numbers = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9};
    return Arrays.stream(numbers).sum();
  }

  private static int fileWideComputation() {
    int[] numbers = new int[] {1, 2, 3, 4, 5, 6, 7, 8, 9};
    return Arrays.stream(numbers).reduce((t, x) -> t * x).getAsInt();
  }
}

שימוש בסביבת הפעלה אחרת

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

אופטימיזציה של בו-זמניות (concurrency)

מכונות של Cloud Run יכולות לטפל בכמה בקשות בו-זמנית, עד למספר המקסימלי של בקשות בו-זמניות שניתן להגדיר.

‫Cloud Run משנה באופן אוטומטי את מספר הבקשות בו-זמנית עד למקסימום שהוגדר.

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

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

כוונון של מספר הבקשות בו-זמנית בשירות

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

כדי לבצע אופטימיזציה של השירות למספר מקסימלי של בקשות בו-זמניות יציבות:

  1. אופטימיזציה של ביצועי השירות.
  2. מגדירים את רמת התמיכה הצפויה בו-זמניות בכל הגדרה של בו-זמניות ברמת הקוד. לא כל סטאק תוכנות דורש הגדרה כזו.
  3. פורסים את השירות.
  4. מגדירים את מספר הבקשות בו-זמנית ב-Cloud Run לשירות, כך שיהיה שווה או קטן מכל הגדרה ברמת הקוד. אם אין הגדרה ברמת הקוד, משתמשים במספר הצפוי של פעולות בו-זמניות.
  5. משתמשים בכלים לבדיקת עומסים שתומכים בהגדרת בו-זמניות. אתם צריכים לוודא שהשירות נשאר יציב בעומס ובמקביליות הצפויים.
  6. אם השירות לא פועל כמו שצריך, חוזרים לשלב 1 כדי לשפר את השירות או לשלב 2 כדי להפחית את מספר הבקשות בו-זמנית. אם השירות פועל היטב, חוזרים לשלב 2 ומגדילים את מספר הבקשות בו-זמנית.

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

התאמת הזיכרון למספר הבקשות בו-זמנית

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

הימנעו ממצב גלובלי שניתן לשינוי

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

אם אתם משתמשים במשתנים גלובליים ניתנים לשינוי בשירות שמטפל בכמה בקשות בו-זמנית, הקפידו להשתמש בנעילות או ב-mutexes כדי למנוע מרוץ תהליכים.

היחס בין תפוקה, זמן אחזור ועלות

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

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

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

שיקולי עלות

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

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

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

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

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

Container Security

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

כדי לשפר את אבטחת הקונטיינר:

  • השתמשו בקובצי אימג' בסיסיים מאובטחים ומתוחזקים באופן פעיל, כמו קובצי האימג' הבסיסיים של Google או קובצי האימג' הרשמיים של Docker Hub.

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

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

  • מטמיעים תהליך build דטרמיניסטי שכולל גרסאות ספציפיות של תוכנות וספריות. כך אפשר למנוע הכללה של קוד לא מאומת במאגר התגים.

  • מגדירים את הקונטיינר כך שיפעל כמשתמש שאינו root באמצעות ההצהרה USER בקובץ Docker. יכול להיות שבחלק מקובצי האימג' של הקונטיינרים כבר מוגדר משתמש ספציפי.

  • למנוע את השימוש בתכונות בגרסת טרום-השקה (Preview) באמצעות מדיניות ארגון מותאמת אישית.

סריקת אבטחה אוטומטית

מפעילים סריקת נקודות חולשה כדי לבצע סריקת אבטחה של קובצי אימג' של קונטיינרים שמאוחסנים ב-Artifact Registry.

יצירת קובצי אימג' מינימליים של קונטיינרים

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

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

כדי ליצור קונטיינר מינימלי, כדאי לעבוד עם קובץ אימג' בסיסי ופשוט, כמו:

‫Ubuntu הוא קובץ אימג' גדול יותר, אבל הוא קובץ אימג' בסיסי נפוץ עם סביבת שרת מוכנה לשימוש ושלמה יותר.

אם תהליך ה-build של השירות שלכם כולל הרבה כלים, כדאי להשתמש ב-builds בכמה שלבים כדי שהקונטיינר יהיה קל בזמן הריצה.

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