Conseils de développement généraux

Ce guide présente les bonnes pratiques pour la conception, la mise en œuvre, le test et le déploiement d'un service Cloud Run. Pour obtenir plus de conseils, consultez la page Migrer un service existant.

Écrire des services efficaces

Cette section présente les bonnes pratiques générales de conception et de mise en œuvre d'un service Cloud Run.

Activité en arrière-plan

L'activité d'arrière-plan désigne tout ce qui se produit après la transmission de votre réponse HTTP. Pour déterminer s'il existe une activité en arrière-plan dans votre service qui n'apparaît pas de manière évidente, recherchez dans vos journaux tous les événements consignés après l'entrée de la requête HTTP.

Configurer le processeur pour qu'il soit toujours alloué en vue d'utiliser des activités d'arrière-plan

Si vous souhaitez prendre en charge les activités d'arrière-plan dans votre service Cloud Run, configurez le processeur de votre service Cloud Run pour qu'il soit toujours alloué afin de pouvoir exécuter des activités d'arrière-plan en dehors des requêtes tout en conservant l'accès au processeur.

Éviter les activités d'arrière-plan si le processeur n'est alloué que pendant le traitement des requêtes

Si vous devez configurer votre service pour allouer un processeur uniquement pendant le traitement des requêtes, lorsque le service Cloud Run termine le traitement d'une requête, l'accès de l'instance au processeur est désactivé ou fortement limitée. Vous ne devez pas démarrer de routines ni de threads en arrière-plan qui s'exécutent en dehors du champ d'application des gestionnaires de requêtes si vous utilisez ce type d'allocation de processeur.

Examinez votre code pour vous assurer que toutes les opérations asynchrones sont terminées avant de transmettre votre réponse.

L'exécution de threads en arrière-plan avec ce type d'allocation de processeur peut entraîner un comportement inattendu, car toute requête ultérieure adressée à la même instance de conteneur entraîne la reprise des activités d'arrière-plan suspendues.

Supprimer les fichiers temporaires

Dans l'environnement Cloud Run, le stockage sur disque est un système de fichiers en mémoire. Les fichiers écrits sur le disque consomment de la mémoire disponible pour votre service et persistent parfois entre les appels. Si vous ne supprimez pas explicitement ces fichiers, une erreur pour mémoire insuffisante et un démarrage à froid des conteneurs peuvent se produire.

Signaler les erreurs

Traitez toutes les exceptions et ne laissez pas votre service planter pour cause d'erreurs. Un plantage entraîne un démarrage lent du conteneur alors que le trafic est mis en file d'attente jusqu'au remplacement de l'instance.

Consultez le guide Error Reporting pour savoir comment signaler correctement les erreurs.

Optimiser les performances

Cette section décrit les bonnes pratiques relatives à l'optimisation des performances.

Démarrer les conteneurs rapidement

Les instances étant soumises à un scaling en fonction des besoins, leur temps de démarrage a un impact sur la latence de votre service. Bien que Cloud Run dissocie le démarrage de l'instance et le traitement des requêtes, il peut arriver qu'une requête doive attendre le démarrage d'une nouvelle instance, notamment en cas de scaling à partir de zéro.

La routine de démarrage lance les actions suivantes :

  • Télécharger l'image de conteneur (à l'aide de la technologie de streaming d'image de conteneur de Cloud Run)
  • Démarrer le conteneur en exécutant la commande entrypoint
  • Attendre que le conteneur commence à écouter sur le port configuré

L'optimisation de la vitesse de démarrage du conteneur réduit la latence de traitement des requêtes.

Utiliser l'optimisation du processeur au démarrage pour réduire la latence de démarrage

Vous pouvez activer l'optimisation du processeur au démarrage afin d'augmenter temporairement l'allocation de processeur lors du démarrage de l'instance et ainsi de réduire la latence de démarrage.

Utiliser un nombre minimal d'instances pour réduire les temps de démarrage des conteneurs

Vous pouvez configurer le nombre minimal d'instances et la simultanéité afin de réduire les temps de démarrage des conteneurs. Par exemple, si vous utilisez un nombre minimal d'instances valant 1, cela signifie que votre service est prêt à recevoir au plus ce nombre de requêtes simultanées configurées pour votre service sans que cela ne nécessite le démarrage d'une nouvelle instance.

Notez qu'une requête en attente du démarrage d'une instance est conservée en attente dans une file d'attente comme suit :

  • Si les nouvelles instances démarrent, par exemple lors d'une évolutivité horizontale, les requêtes seront mises en attente pendant au moins le temps de démarrage moyen des instances de conteneur de ce service. Cela inclut le moment où la requête lance un scaling horizontal, par exemple lors d'un scaling à partir de zéro.
  • Si le temps de démarrage est inférieur à 10 secondes, les requêtes sont mises en attente pendant une durée maximale de 10 secondes.
  • Si aucune instance n'est en cours de démarrage et que la requête n'initie pas de scaling horizontal, les requêtes seront mises en attente pendant une durée maximale de 10 secondes.

Utiliser les dépendances à bon escient

Si vous utilisez un langage dynamique avec des bibliothèques dépendantes, telles que l'importation de modules dans Node.js, le temps de chargement de ces modules augmente la latence de démarrage.

Vous pouvez réduire la latence de la façon suivante :

  • Réduisez le nombre et la taille des dépendances pour créer un service allégé.
  • Chargez tardivement le code rarement utilisé, si votre langage le permet.
  • Utilisez des optimisations de chargement de code telles que l'optimisation du chargeur automatique de l'éditeur PHP.

Utiliser des variables globales

Dans Cloud Run, vous ne pouvez pas supposer que l'état du service est préservé entre les requêtes. Toutefois, Cloud Run réutilise des instances individuelles pour diffuser le trafic en cours. Vous pouvez donc déclarer une variable dans un champ d'application global pour permettre la réutilisation de sa valeur lors d'appels ultérieurs. Il n'est pas possible de savoir à l'avance si une requête individuelle peut bénéficier de cette réutilisation.

Vous pouvez également mettre en cache des objets en mémoire s’ils sont coûteux à recréer à chaque demande de service. Changer la logique de requête en appliquant le champ d'application global améliore les performances.

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();
  }
}

Effectuer une initialisation différée des variables globales

L'initialisation des variables globales a toujours lieu au démarrage, ce qui augmente le temps de démarrage du conteneur. L'initialisation tardive des objets rarement utilisés permet de différer le coût horaire et de réduire les temps de démarrage des conteneurs.

L'inconvénient de l'initialisation paresseuse est une latence accrue pour les premières requêtes envoyées aux nouvelles instances. Cela peut entraîner une sur-échelle et des requêtes abandonnées lorsque vous déployez une nouvelle version d'un service qui traite activement de nombreuses requêtes.

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

    # 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();
  }
}

Utiliser un environnement d'exécution différent

Vous pouvez accélérer les temps de démarrage en utilisant un environnement d'exécution différent.

Optimiser la simultanéité

Les instances Cloud Run peuvent diffuser plusieurs requêtes simultanément, jusqu'à une simultanéité maximale configurable. Ceci diffère de Cloud Functions, qui utilise concurrency = 1.

Cloud Run ajuste automatiquement la simultanéité jusqu'à la valeur maximale configurée.

La simultanéité maximale par défaut de 80 est adaptée à de nombreuses images de conteneurs. Vous devez toutefois :

  • La réduire si votre conteneur n'est pas en mesure de traiter de nombreuses requêtes simultanées.
  • L'augmenter si votre conteneur est capable de gérer un volume important de requêtes.

Régler la simultanéité pour votre service

Le nombre de requêtes simultanées pouvant être traitées par chaque instance peut être limité par la pile technologique et l'utilisation de ressources partagées, telles que les variables et les connexions à la base de données.

Pour optimiser votre service de façon à obtenir une simultanéité stable maximale, procédez comme suit :

  1. Optimisez vos performances de service.
  2. Définissez le degré de simultanéité compatible attendu dans votre configuration de simultanéité au niveau du code. Les piles technologiques n'exigent pas toutes ce paramètre.
  3. Déployez votre service.
  4. Définissez la simultanéité de Cloud Run pour votre service sur une valeur égale ou inférieure à celle de la configuration au niveau du code. S'il n'existe pas de configuration au niveau du code, appliquez le niveau de simultanéité attendu.
  5. Utilisez des outils de tests de charge compatibles avec une simultanéité configurable. Vous devez tester la stabilité de votre service en fonction de la charge et de la simultanéité attendues.
  6. Si le service fonctionne mal, revenez à l'étape 1 pour améliorer les performances ou à l'étape 2 pour réduire la simultanéité. Si le service fonctionne correctement, revenez à l'étape 2 et augmentez la simultanéité.

Poursuivez l'itération jusqu'à trouver la simultanéité stable maximale.

Mettre en correspondance la mémoire et la simultanéité

Chaque requête traitée par votre service nécessite une certaine quantité de mémoire supplémentaire. Ainsi, lorsque vous augmentez ou diminuez la simultanéité, veillez également à ajuster votre limite de mémoire.

Éviter un état global modifiable

Si vous souhaitez tirer parti de l'état global modifiable dans un contexte de simultanéité, prenez des mesures supplémentaires au niveau du code pour garantir la sécurité de l'opération. Réduisez les conflits en limitant les variables globales à une initialisation et une réutilisation uniques, comme décrit ci-dessus dans la section relative aux Performances.

Si vous utilisez des variables globales modifiables dans un service qui traite plusieurs requêtes en même temps, veillez à appliquer des verrous ou des mutex pour éviter les conditions de concurrence.

Compromis entre débit, latence et coûts

Ajuster le paramètre du nombre maximal de requêtes simultanées peut vous aider à équilibrer le compromis entre le débit, la latence et les coûts de votre service.

En général, un nombre inférieur de requêtes simultanées entraîne une latence et un débit inférieurs par instance. Avec un nombre maximal de requêtes simultanées inférieur, moins de requêtes se disputent les ressources dans chaque instance, et chacune d'elles offre de meilleures performances. Cependant, comme chaque instance peut diffuser moins de requêtes à la fois, le débit par instance est plus faible et le service nécessite davantage d'instances pour diffuser le même trafic.

À l'inverse, un nombre maximal de requêtes simultanées plus élevé entraîne généralement une latence et un débit plus élevés par instance. Les requêtes peuvent avoir besoin d'attendre l'accès à des ressources telles que le processeur, le GPU et la bande passante mémoire dans l'instance, ce qui augmente la latence. Cependant, chaque instance peut traiter davantage de requêtes à la fois, de sorte que le service nécessite moins d'instances dans son ensemble pour traiter le même volume de trafic.

Considérations liées au coût

La facturation Cloud Run est basée sur le temps d'instance. Si Le processeur est toujours alloué, le temps d'instance correspond à la durée de vie totale de chaque instance. Si le processeur n'est pas alloué en permanence, le temps d'instance correspond à la durée pendant laquelle chaque instance a traité au moins une requête.

L'impact du nombre maximal de requêtes simultanées sur la facturation dépend de votre schéma de trafic. La réduction du nombre maximal de requêtes simultanées peut réduire votre facture si le paramètre le plus bas produit les effets suivants :

  • Latence réduite
  • Les instances effectuent le travail plus rapidement
  • Les instances s'arrêtent plus rapidement, même si un plus grand nombre d'instances est nécessaire

Mais l'inverse est également possible : la réduction du nombre maximal de requêtes simultanées peut augmenter la facturation si l'augmentation du nombre d'instances n'est pas compensée par la réduction du temps d'exécution de chaque instance grâce à l'amélioration de la latence.

Le meilleur moyen d'optimiser la facturation consiste à effectuer des tests de charge à l'aide de différents paramètres de nombre maximal de requêtes simultanées pour identifier celui qui permet d'obtenir la durée d'instance facturable la plus faible, comme indiqué dans la métrique de surveillance container/billable_instance_time.

Sécurité des conteneurs

De nombreuses pratiques générales de sécurité logicielles s'appliquent aux services conteneurisés. Certaines pratiques sont soit spécifiques aux conteneurs, soit conformes à la philosophie et à l'architecture des conteneurs.

Pour améliorer la sécurité des conteneurs, procédez comme suit :

  • Utilisez des images de base activement gérées et sécurisées, telles que des images de base fournies par Google ou des images officielles de Docker Hub.

  • Appliquez les mises à jour de sécurité en effectuant régulièrement une recompilation des images de conteneur et un redéploiement de vos services.

  • Ne placez dans le conteneur que ce qui est nécessaire pour exécuter le service. Le code, les packages et les outils supplémentaires augmentent les failles de sécurité potentielles. Reportez-vous plus haut pour connaître l'impact sur les performances.

  • Implémentez un processus de compilation déterministe incluant des versions de logiciels et de bibliothèques spécifiques. Vous éviterez ainsi que du code non vérifié soit inclus dans votre conteneur.

  • Définissez votre conteneur pour qu'il s'exécute via un profil utilisateur autre que root à l'aide de l'instruction Dockerfile USER. Un utilisateur spécifique peut être déjà configuré dans certaines images de conteneur.

  • Empêchez l'utilisation des fonctionnalités en preview à l'aide de règles d'administration personnalisées.

Automatiser les analyses de sécurité

Activez l'analyse des failles pour détecter les failles de sécurité dans les images de conteneurs stockées dans Artifact Registry.

Créer des images de conteneurs minimales

Les images de conteneurs volumineuses peuvent augmenter les failles de sécurité, car elles contiennent plus que ce dont le code a besoin.

En raison de la technologie de streaming d'images de conteneur de Cloud Run, la taille de votre image de conteneur n'affecte pas les temps de démarrage des conteneurs ni le temps de traitement des requêtes. La taille de l'image de conteneur n'est pas non plus comptabilisée dans la mémoire disponible de votre conteneur.

Pour créer un conteneur minimal, envisagez de partir d'une image de base légère, telle que :

L'image de base sous Ubuntu est plus grande, mais elle est couramment utilisée avec un environnement de serveur plus complet et prêt à l'emploi.

Si votre service dispose d'un processus de compilation riche en outils, procédez plutôt par étapes pour préserver la légèreté du conteneur au moment de l'exécution.

Les ressources ci-dessous vous permettront d'en savoir davantage sur la création d'images de conteneur allégées :