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 Knative serving. 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 Knative serving.

Éviter les activités d'arrière-plan

Lorsqu'une application exécutée sur Knative serving termine le traitement d'une requête, l'accès de l'instance de conteneur au processeur est désactivé ou très limité. Par conséquent, 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.

L'exécution de threads en arrière-plan peut entraîner un comportement inattendu, car toute requête ultérieure adressée à la même instance de conteneur reprend l'activité en arrière-plan suspendue.

L'activité d'arrière-plan désigne tout ce qui se produit après la transmission de votre réponse HTTP. Examinez votre code pour vous assurer que toutes les opérations asynchrones sont terminées avant de transmettre votre réponse.

Si vous pensez qu'une activité d'arrière-plan s'exécute dans votre service sans apparaître de façon évidente, recherchez dans vos journaux tous les événements consignés après l'entrée de la requête HTTP.

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 peuvent se produire.

Optimiser les performances

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

Démarrer les services rapidement

Les instances de conteneur étant mises à l'échelle en fonction des besoins, une méthode classique consiste à initialiser complètement l'environnement d'exécution. Ce type d'initialisation est dénommé "démarrage à froid". Si une requête de client déclenche un démarrage à froid, le démarrage de l'instance de conteneur entraîne une latence supplémentaire.

La routine de démarrage lance les actions suivantes :

  • Démarrage du service
    • Démarrage du conteneur
    • Exécution de la commande entrypoint pour démarrer le serveur
  • Vérification du port de service ouvert

L'optimisation de la vitesse de démarrage du service réduit la latence qui retarde le traitement des requêtes par une instance de conteneur.

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 lors d'un démarrage à froid. 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 Knative serving, vous ne pouvez pas supposer que l'état du service est préservé entre les requêtes. Toutefois, Knative serving réutilise des instances de conteneur 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 tardive des variables globales

L'initialisation des variables globales a toujours lieu au démarrage, ce qui augmente le temps de démarrage à froid. 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 à froid.

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

Optimiser la simultanéité

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

Vous devez conserver le paramètre de simultanéité maximale par défaut, sauf si votre code présente des exigences de simultanéité spécifiques.

Régler la simultanéité pour votre service

Le nombre de requêtes simultanées pouvant être traitées par chaque instance de conteneur 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 Knative serving 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.

Sécurité des conteneurs

De nombreuses pratiques générales de sécurité logicielles s’appliquent aux applications conteneurisées. 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.

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.

Vous pouvez également utiliser l'autorisation binaire pour vous assurer que seules les images de conteneurs sécurisées sont déployées.

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.

Sur Knative serving, la taille de votre image de conteneur n'affecte pas le démarrage à froid ni le temps de traitement des requêtes, et n'est pas 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 :