Bonnes pratiques pour l'exploitation de conteneurs

Cet article décrit un ensemble de bonnes pratiques permettant de simplifier l’utilisation des conteneurs. Ces pratiques couvrent un large éventail de sujets, de la sécurité à la surveillance, en passant par la journalisation. Leur objectif est de faciliter l’exécution des applications dans Google Kubernetes Engine et dans les conteneurs en général. Un grand nombre de pratiques mentionnées ici ont été inspirées par la méthodologie douze facteurs, qui constitue une excellente ressource pour la création d’applications cloud natives.

Ces bonnes pratiques ne revêtent pas toutes la même importance. En effet, certaines ne vous seront peut-être pas nécessaires pour exécuter correctement une charge de travail de production, mais d'autres en revanche vous seront indispensables. En particulier, l'importance des bonnes pratiques en matière de sécurité est subjective. Votre décision de les mettre en œuvre dépend de votre environnement et de vos contraintes.

Pour tirer pleinement parti de cet article, vous devez déjà être familiarisé avec Docker et Kubernetes. Certaines des bonnes pratiques décrites ici s'appliquent également aux conteneurs Windows, mais la plupart supposent que vous travaillez avec des conteneurs Linux. Consultez la section Bonnes pratiques pour la création de conteneurs pour obtenir des conseils.

Utiliser les mécanismes de journalisation natifs des conteneurs

Importance : HAUTE

Les journaux font partie intégrante de la gestion des applications ; ils contiennent des informations précieuses sur les événements qui se produisent dans l’application. Docker et Kubernetes s'efforcent de faciliter la gestion des journaux.

Sur un serveur classique, vous devez probablement écrire vos journaux dans un fichier spécifique, et gérer la rotation des journaux pour éviter de saturer les disques. Si vous disposez d'un système de journalisation avancé, vous pouvez transférer ces journaux vers un serveur distant afin de les centraliser.

Les conteneurs sont une manière simple et standardisée de gérer les journaux, car vous pouvez les écrire sur stdout et stderr. Docker enregistre ces lignes de journal et vous permet d'y accéder à l'aide de la commande docker logs. En tant que développeur d'applications, vous n'avez pas besoin de mettre en œuvre des mécanismes de journalisation avancés. À la place, utilisez les mécanismes de journalisation natifs des conteneurs.

L’opérateur de la plate-forme doit fournir un système permettant de centraliser les journaux et de les inclure dans l'index de recherche. Dans GKE, ce service est fourni par fluentd et Cloud Logging. D'autres méthodes courantes incluent l'utilisation d'une pile EFK (Elasticsearch, Fluentd, Kibana).

Schéma d'un système de gestion des journaux classique dans Kubernetes.
Figure 1 : schéma d'un système de gestion des journaux classique dans Kubernetes

Journaux JSON

La plupart des systèmes de gestion des journaux sont en fait des bases de données de séries temporelles qui stockent des documents indexés temporellement. Ces documents peuvent généralement être fournis au format JSON. Dans Cloud Logging et dans EFK, une seule ligne de journal est stockée en tant que document, avec quelques métadonnées (informations sur le pod, le conteneur, le nœud, etc.).

Vous pouvez profiter de ce comportement en effectuant une journalisation directement au format JSON avec différents champs. Vous pourrez ensuite rechercher vos journaux plus efficacement en fonction de ces champs.

Par exemple, transformez le journal suivant au format JSON :

[2018-01-01 01:01:01] foo - WARNING - foo.bar - There is something wrong.

Voici le journal transformé :

{
  "date": "2018-01-01 01:01:01",
  "component": "foo",
  "subcomponent": "foo.bar",
  "level": "WARNING",
  "message": "There is something wrong."
}

Cette transformation facilite les recherches dans vos journaux pour tous les journaux de niveau WARNING ou du sous-composant foo.bar.

Si vous décidez d'écrire des journaux au format JSON, sachez que vous devez écrire chaque événement sur une seule ligne pour qu'il soit correctement analysé. Voici à quoi cela doit ressembler :

{"date":"2018-01-01 01:01:01","component":"foo","subcomponent":"foo.bar","level": "WARNING","message": "There is something wrong."}

Comme vous pouvez le voir, le résultat est beaucoup moins lisible qu’une ligne normale de journal. Si vous décidez d'utiliser cette méthode, assurez-vous que vos équipes n'ont pas trop recours à l'inspection manuelle des journaux.

Modèle side-car de l'agrégateur de journaux

Certaines applications (comme Tomcat) sont difficiles à configurer pour écrire des journaux sur stdout et stderr. Étant donné que ces applications écrivent dans différents fichiers journaux sur le disque, la meilleure façon de les gérer dans Kubernetes consiste à utiliser le modèle side-car pour la journalisation. Un side-car est un petit conteneur qui s'exécute dans le même pod que votre application. Pour obtenir des instructions plus détaillées sur les side-cars, consultez la documentation officielle de Kubernetes.

Dans cette solution, vous ajoutez un agent de journalisation dans un conteneur side-car à votre application (dans le même pod), et vous partagez un volume emptyDir entre les deux conteneurs, comme illustré dans l'exemple YAML sur GitHub. Vous configurez ensuite l'application pour qu'elle écrive ses journaux sur le volume partagé, puis vous configurez l'agent de journalisation pour qu'il puisse les lire et les transférer si nécessaire.

Dans ce modèle, puisque vous n'utilisez pas les mécanismes de journalisation natifs Docker et Kubernetes, vous devez gérer la rotation des journaux. Si votre agent de journalisation ne gère pas la rotation des journaux, un autre conteneur side-car dans le même module peut s'en charger.

Modèle side-car pour la gestion des journaux.
Figure 2 : modèle side-car pour la gestion des journaux

Vérifier que vos conteneurs sont sans état et immuables

Importance : HAUTE

Si vous testez des conteneurs pour la première fois, ne les traitez pas comme des serveurs traditionnels. Par exemple, vous pourriez être tenté de mettre à jour votre application dans un conteneur en cours d'exécution, ou d'appliquer un correctif à un conteneur en cours d'exécution en cas de vulnérabilité.

Les conteneurs ne sont pas du tout conçus pour fonctionner de cette façon. Ils sont conçus pour être sans état et immuables.

Sans état

Sans état signifie que n'importe quel état (données persistantes de tout type) est stocké en dehors d'un conteneur. Ce stockage externe peut prendre plusieurs formes, en fonction de vos besoins :

  • Pour stocker des fichiers, nous vous recommandons d'utiliser un magasin d'objets tel que Cloud Storage.
  • Pour stocker des informations telles que des sessions utilisateur, nous vous recommandons d'utiliser un magasin de paires clé/valeur externe à latence faible, tel que Redis ou Memcached.
  • Si vous avez besoin d'un stockage au niveau du bloc (pour les bases de données, par exemple), vous pouvez utiliser un disque externe associé au conteneur. Dans le cas de GKE, nous vous recommandons d'utiliser des disques persistants.

En utilisant ces options, vous supprimez les données du conteneur, ce qui signifie que le conteneur peut être fermé et détruit proprement à tout moment sans risque de perte de données. Si un conteneur est créé pour remplacer l'ancien, vous devez simplement connecter ce nouveau conteneur au même magasin de données, ou le lier au même disque.

Immuabilité

Immuable signifie qu'un conteneur ne sera pas modifié au cours de sa vie : pas de mises à jour, pas de correctifs, pas de modifications de configuration. Pour mettre à jour le code de l'application ou appliquer un correctif, vous devez créer une image et la redéployer. L'immuabilité permet des déploiements plus sûrs et plus reproductibles. Si vous devez effectuer un rollback, redéployez simplement l'ancienne image. Cette approche vous permet de déployer la même image de conteneur dans chacun de vos environnements, en les rendant aussi identiques que possible.

Pour utiliser la même image de conteneur dans différents environnements, nous vous recommandons d'externaliser la configuration du conteneur (port d'écoute, options d'exécution, etc.). Les conteneurs sont généralement configurés avec des variables d'environnement ou des fichiers de configuration installés sur un chemin d'accès spécifique. Dans Kubernetes, vous pouvez utiliser des secrets et des ConfigMaps pour injecter des configurations dans des conteneurs en tant que variables d'environnement ou fichiers.

Pour mettre à jour une configuration, vous déployez un nouveau conteneur (basé sur la même image) avec la configuration mise à jour.

Exemple de mise à jour de la configuration d'un déploiement à l'aide d'un ConfigMap installé en tant que fichier de configuration dans les pods.
Figure 3 : exemple de mise à jour de la configuration d'un déploiement à l'aide d'un ConfigMap installé en tant que fichier de configuration dans les pods

L'association de la caractéristique sans état et de l'immuabilité constitue l'un des arguments de vente des infrastructures basées sur des conteneurs. Elle vous permet d’automatiser les déploiements et d’augmenter leur fréquence et leur fiabilité.

Éviter les conteneurs privilégiés

Importance : HAUTE

Dans une machine virtuelle ou un serveur dédié, vous devez éviter d'exécuter vos applications en tant qu'utilisateur racine pour une raison simple : si l'application venait à être corrompue, un hacker aurait un accès complet au serveur. Pour la même raison, évitez d'utiliser des conteneurs privilégiés. Un conteneur privilégié est un conteneur qui a accès à tous les périphériques de la machine hôte, en contournant presque toutes les fonctionnalités de sécurité des conteneurs.

Si vous pensez avoir besoin d'utiliser des conteneurs privilégiés, envisagez les solutions suivantes :

  • Appliquez des fonctionnalités spécifiques au conteneur via l'option securityContext de Kubernetes ou l'option --cap-add de Docker. La documentation Docker répertorie les fonctionnalités activées par défaut et celles que vous devez activer explicitement.
  • Si votre application doit modifier les paramètres de l'hôte pour pouvoir s'exécuter, modifiez-les dans un conteneur side-car ou dans un conteneur init. Contrairement à votre application, ces conteneurs n'ont pas besoin d'être exposés au trafic interne ou externe, ce qui les isole davantage.
  • Si vous devez modifier des paramètres sysctl dans Kubernetes, utilisez l'annotation dédiée.

Dans Kubernetes, les conteneurs privilégiés peuvent être interdits par une stratégie de sécurité des pods spécifique. La stratégie de sécurité des pods est un objet Kubernetes que l’administrateur de cluster configure et gère, et qui applique des exigences spécifiques aux pods. Dans le cluster Kubernetes, il est impossible de créer des pods qui ne respectent pas ces exigences.

Simplifier la surveillance de votre application

Importance : HAUTE

Tout comme la journalisation, la surveillance fait partie intégrante de la gestion des applications. À bien des égards, la surveillance des applications conteneurisées suit les principes qui s'appliquent à celle des applications non conteneurisées. Toutefois, comme les infrastructures conteneurisées sont généralement très dynamiques, et que la création, puis la suppression de conteneurs y sont fréquentes, vous ne pouvez pas vous permettre de reconfigurer votre système de surveillance chaque fois que cela se produit.

Vous pouvez distinguer deux classes principales de surveillance : la surveillance par boîte noire et la surveillance par boîte blanche. La surveillance par boîte noire consiste à examiner votre application de l'extérieur, comme si vous étiez un utilisateur final. Cette surveillance est utile si le service final que vous souhaitez diffuser est disponible et opérationnel. Comme elle est externe à l'infrastructure, la surveillance par boîte noire reste identique, qu'elle soit utilisée dans des infrastructures traditionnelles ou dans des infrastructures conteneurisées.

La surveillance par boîte blanche consiste à examiner votre application avec un type d'accès privilégié, et à rassembler des métriques sur son comportement qui sont invisibles pour un utilisateur final. Étant donné que la surveillance par boîte blanche doit examiner les couches les plus profondes de votre infrastructure, elle diffère considérablement selon qu'elle est utilisée dans des infrastructures traditionnelles ou des infrastructures conteneurisées.

Prometheus est une solution couramment utilisée dans la communauté Kubernetes pour la surveillance par boîte blanche. Il s'agit d'une option permettant de détecter automatiquement les pods à surveiller. Prometheus extrait le contenu des pods (scraping) afin d'obtenir des métriques, qui doivent respecter un format spécifique. Cloud Monitoring est capable de surveiller les clusters Kubernetes et les applications qu'ils exécutent avec sa propre version de Prometheus. Découvrez comment activer la suite d'opérations Cloud pour GKE.

Pour bénéficier de la surveillance via Prometheus ou la suite d'opérations Cloud, vos applications doivent exposer les métriques au format Prometheus. Les deux méthodes suivantes vous expliquent comment procéder.

Point de terminaison HTTP des métriques

Le point de terminaison HTTP des métriques fonctionne de manière similaire aux points de terminaison mentionnés plus loin dans la section Exposer l'état de l'application. Il expose les métriques internes de l'application, généralement sur un URI /metrics. Voici un exemple de réponse :

http_requests_total{method="post",code="200"} 1027
http_requests_total{method="post",code="400"}    3
http_requests_total{method="get",code="200"} 10892
http_requests_total{method="get",code="400"}    97

Dans cet exemple, http_requests_total correspond à la métrique, et method et code sont des libellés. Le nombre le plus à droite correspond à la valeur de cette métrique pour ces libellés. Ici, depuis son démarrage, l’application a répondu à une requête HTTP GET 97 fois avec un code d’erreur 400.

La génération de ce point de terminaison HTTP est simplifiée par les bibliothèques clientes Prometheus, disponibles pour de nombreux langages de programmation. OpenCensus peut également exporter des métriques à l'aide de ce format (parmi de nombreuses autres fonctionnalités). N'exposez pas ce point de terminaison à l'Internet public.

La documentation officielle de Prometheus donne plus de détails à ce sujet. Vous pouvez également lire le chapitre 6 du livre Ingénierie en fiabilité des sites (SRE) pour en savoir plus sur la surveillance par boîte blanche (et boîte noire).

Modèle side-car pour la surveillance

Un point de terminaison HTTP /metrics ne permet pas de mettre en œuvre toutes les applications. Pour conserver une surveillance standardisée, nous vous recommandons l'utilisation du modèle side-car pour exporter les métriques au bon format.

La section Modèle side-car de l'agrégateur de journaux explique comment gérer les journaux d'application à l'aide d'un conteneur side-car. Vous pouvez utiliser le même modèle pour la surveillance : le conteneur side-car héberge un agent de surveillance qui convertit les métriques telles qu'elles sont exposées par l'application en un format et un protocole compris par le système de surveillance global.

Prenons un exemple concret : les applications Java et les extensions de gestion Java (JMX). De nombreuses applications Java exposent des métriques à l'aide de JMX. Plutôt que de réécrire une application pour exposer les métriques au format Prometheus, vous pouvez vous servir de jmx_exporter. Cet exportateur rassemble les métriques d'une application via JMX, et les expose via un point de terminaison /metrics que Prometheus peut lire. Cette approche présente également l'avantage de limiter l'exposition du point de terminaison JMX, ce qui permet de modifier les paramètres de l'application si besoin.

Modèle side-car pour la surveillance.
Figure 4 : modèle side-car pour la surveillance.

Exposer l'état de l'application

Importance : MOYENNE

Pour faciliter sa gestion en production, une application doit communiquer son état au système global : l'application est-elle en cours d'exécution ? Est-elle opérationnelle ? Est-elle prête à recevoir du trafic ? Comment se comporte-t-elle ?

Kubernetes propose deux types de vérifications d'état : la vérification d'activité et la vérification d'aptitude. Chacune implique un usage spécifique, comme décrit dans cette section. Vous pouvez mettre en œuvre les deux méthodes de différentes manières (y compris en exécutant une commande à l'intérieur du conteneur, ou en vérifiant un port TCP), mais la méthode recommandée consiste à utiliser les points de terminaison HTTP décrits dans ces bonnes pratiques. Pour plus d'informations à ce sujet, consultez la documentation de Kubernetes.

Vérification d'activité

La méthode recommandée pour mettre en œuvre la vérification d'activité consiste pour votre application à exposer un point de terminaison HTTP /health. Lors de la réception d'une requête sur ce point de terminaison, l'application doit envoyer une réponse "200 OK" si son état est considéré comme étant opérationnel. Dans Kubernetes, "opérationnel" signifie que le conteneur n'a pas besoin d'être détruit, ni redémarré. Les caractéristiques d'un état opérationnel varient d’une application à l’autre, mais cela signifie généralement ce qui suit :

  • L'application est en cours d'exécution.
  • Ses principales dépendances sont remplies (par exemple, l'accès à sa base de données est possible).

Vérification d'aptitude

La méthode recommandée pour mettre en œuvre la vérification d'aptitude consiste pour votre application à exposer un point de terminaison HTTP /ready. Lors de la réception d'une requête sur ce point de terminaison, l'application doit envoyer une réponse "200 OK" si elle est considérée comme étant prête à recevoir du trafic. Une application prête à recevoir du trafic implique les éléments suivants :

  • L'application est opérationnelle.
  • Toutes les étapes d'initialisation potentielles sont terminées.
  • Les requêtes valides envoyées à l'application ne génèrent pas d'erreur.

Kubernetes exploite la vérification d'aptitude pour orchestrer le déploiement de votre application. Si vous mettez à jour un déploiement, Kubernetes effectuera une mise à jour progressive des pods appartenant à ce déploiement. La stratégie de mise à jour par défaut consiste à mettre à jour un pod à la fois : Kubernetes attend que le nouveau pod soit prêt (comme indiqué par la vérification d'aptitude) avant de mettre à jour le pod suivant.

Éviter les exécutions en tant qu'utilisateur racine

Importance : MOYENNE

Les conteneurs fournissent une isolation : avec les paramètres par défaut, un processus à l'intérieur d'un conteneur Docker ne peut pas accéder aux informations provenant de la machine hôte, ni des autres conteneurs colocalisés. Toutefois, comme les conteneurs partagent le noyau de la machine hôte, l'isolation n'est pas aussi complète qu'avec les machines virtuelles, comme expliqué dans cet article de blog. Un hacker pourrait trouver des failles encore inconnues (que ce soit dans Docker ou dans le noyau Linux lui-même) qui lui permettraient d'échapper à un conteneur. Si le pirate informatique trouve une faille, et que vous exécutez votre processus en tant qu'utilisateur racine dans le conteneur, il obtiendra un accès racine à la machine hôte.

À gauche : les machines virtuelles utilisent du matériel virtualisé.
À droite : les applications dans les conteneurs utilisent le noyau hôte.
Figure 4 : à gauche, les machines virtuelles utilisent du matériel virtualisé. À droite, les applications dans les conteneurs utilisent le noyau hôte.

Pour éviter ce risque, il est recommandé de ne pas exécuter de processus en tant qu'utilisateur racine dans des conteneurs. Vous pouvez forcer ce comportement dans Kubernetes en utilisant PodSecurityPolicy. Lors de la création d'un pod dans Kubernetes, utilisez l'option runAsUser afin de spécifier l'utilisateur Linux qui exécute le processus. Cette approche remplace l’instruction USER du fichier Dockerfile.

Dans les faits, ce n'est pas toujours simple. Le processus principal de nombreux logiciels fréquemment utilisés est exécuté avec l'utilisateur racine. Si vous souhaitez éviter les exécutions en tant qu'utilisateur racine, concevez votre conteneur de sorte qu'il puisse être exécuté avec un utilisateur inconnu et non privilégié. Cette pratique implique généralement de devoir modifier les autorisations sur différents dossiers. Dans un conteneur, si vous suivez la bonne pratique d'une seule application par conteneur et que vous exécutez une seule application avec un seul utilisateur, de préférence autre qu'un utilisateur racine, vous pouvez accorder à tous les utilisateurs des autorisations en écriture sur les dossiers et les fichiers qui le nécessitent, et ne rendre les autres dossiers et fichiers accessibles en écriture qu'aux utilisateurs racine.

Un moyen simple de vérifier si votre conteneur est conforme à ces bonnes pratiques consiste à l'exécuter localement avec un utilisateur aléatoire, et à vérifier s'il fonctionne correctement. Remplacez [YOUR_CONTAINER] par le nom de votre conteneur.

docker run --user $((RANDOM+1)) [YOUR_CONTAINER]

Si votre conteneur a besoin d'un volume externe, vous pouvez configurer l'option fsGroup de Kubernetes pour attribuer la propriété de ce volume à un groupe Linux spécifique. Cette configuration résout le problème de propriété pour les fichiers externes.

Si votre processus est exécuté par un utilisateur non privilégié, il ne pourra pas s'associer aux ports inférieurs à 1024. Ce n'est généralement pas un problème, car vous pouvez configurer les services Kubernetes pour qu'ils acheminent le trafic d'un port à un autre. Par exemple, vous pouvez configurer un serveur HTTP pour qu'il s'associe au port 8080, et qu'il redirige le trafic du port 80 à l'aide d'un service Kubernetes.

Sélectionner soigneusement la version de l'image

Importance : MOYENNE

Lorsque vous utilisez une image Docker, que ce soit en tant qu'image de base dans un fichier Dockerfile ou en tant qu'image déployée dans Kubernetes, vous devez choisir le tag de l'image que vous utilisez.

La plupart des images publiques et privées appliquent un système d'ajout de tags semblable à celui décrit dans la section Bonnes pratiques pour la création de conteneurs. Si l'image utilise un système proche de la gestion sémantique des versions, vous devez prendre en compte certaines spécificités pour l'ajout de tags.

Plus important encore, le tag "latest" (plus récent) peut être déplacé fréquemment d'une image à l'autre. En conséquence, vous ne pouvez pas compter sur ce tag pour des versions prévisibles ou reproductibles. Prenons l'exemple du fichier Dockerfile suivant :

FROM debian:latest
RUN apt-get -y update && \ apt-get -y install nginx

Si vous créez une image à partir de ce document Dockerfile à deux reprises, à des moments différents, vous pouvez vous retrouver avec deux versions différentes de Debian et NGINX. À la place, utilisez cette version modifiée :

FROM debian:9.4
RUN apt-get -y update && \ apt-get -y install nginx

En utilisant un tag plus précis, vous vous assurez que l'image résultante sera toujours basée sur une version mineure spécifique de Debian. Étant donné qu'une version spécifique de Debian est toujours accompagnée d'une version spécifique de NGINX, vous avez beaucoup plus de contrôle sur l'image créée.

Ce résultat est non seulement vrai au moment de la compilation, mais aussi au moment de l'exécution. Si vous faites référence au tag "latest" (plus récent) dans un manifeste Kubernetes, vous n'avez aucune garantie quant à la version que Kubernetes utilisera. Différents nœuds de votre cluster peuvent extraire le même tag "latest" à des moments différents. Si le tag a été mis à jour durant les extractions en cours, vous pouvez vous retrouver avec différents nœuds exécutant des images différentes (qui comprennent toutes le tag "latest" à un moment donné).

Dans l'idéal, vous devriez toujours utiliser un tag immuable dans la ligne FROM. Ce tag vous permet d'obtenir des versions reproductibles. Toutefois, cette solution présente des inconvénients pour la sécurité : plus vous épinglerez la version que vous souhaitez utiliser, moins les correctifs de sécurité seront automatisés dans vos images. Si l'image que vous utilisez exploite une gestion sémantique des versions adaptée, la version du correctif (soit "Z" dans "X.Y.Z") ne devrait pas comporter de modifications susceptibles d'affecter la compatibilité ascendante. Vous pouvez utiliser le tag "X.Y" et obtenir automatiquement les corrections de bugs.

Prenons l'exemple d'un logiciel nommé "SuperSoft". Supposons que le processus de sécurité pour SuperSoft consiste à corriger les failles via une nouvelle version de correctif. Vous souhaitez personnaliser SuperSoft, et vous avez écrit le fichier Dockerfile suivant :

FROM supersoft:1.2.3
RUN a-command

Peu de temps après, le fournisseur découvre une faille et publie la version 1.2.4 de SuperSoft afin de résoudre le problème. Dans ce cas, il vous appartient de rester informé des correctifs de SuperSoft, et de mettre à jour votre fichier Dockerfile en conséquence. Si vous utilisez FROM supersoft:1.2 à la place dans votre fichier Dockerfile, la nouvelle version est automatiquement extraite.

En fin de compte, vous devez examiner soigneusement le système d'ajout de tags de chaque image externe que vous utilisez, déterminer le niveau de confiance à accorder aux personnes qui créent ces images, et choisir le tag à utiliser.

Étapes suivantes

Testez d'autres fonctionnalités de Google Cloud. Découvrez nos tutoriels.