Skip to main content

Bonnes pratiques pour conteneuriser des applications Go avec Docker

Écrit par
Headshot of Jekayin-Oluwa Olabemiwo

Jekayin-Oluwa Olabemiwo

blog feature docker labels

23 mars 2022

0 minutes de lecture

Les applications Go et les conteneurs sont faits l’un pour l’autre. La faible taille des binaires des applications Go convient parfaitement aux stratégies de déploiement de microservices que Docker et Kubernetes permettent de mettre en œuvre. Cette synergie ne va toutefois pas sans difficultés. Il est donc important de comprendre les bonnes pratiques et les concepts clés liés aux conteneurs afin d’éviter les failles de sécurité qui peuvent facilement se glisser dans vos images de conteneur.

Dans cet article, vous allez coder un exemple d’application Go et apprendre à la conteneuriser et à l’exécuter de manière sécurisée.

Prérequis

Pour suivre ce tutoriel, vous devez avoir installé Docker et Go sur votre machine et connaître les bases de ces deux outils. Vous trouverez des instructions de prise en main et des liens de téléchargement de Docker sur la page orientation et configuration de la documentation Docker. Vous trouverez de bons tutoriels Go dans la documentation officielle de Go, ou pouvez accéder directement à la page Installation de Go pour Linux.

Créer un exemple d’application Go

Pour commencer, créons notre API Go. Accédez d’abord au répertoire dans lequel vous souhaitez placer votre application Go. Créez ensuite un répertoire nommé « godocker ». Dans ce répertoire godocker/, exécutez la commande suivante pour définir votre module Go :

go mod init godocker

Ensuite, créez un fichier nommé « main.go » dans le répertoire « godocker ». Ce fichier contiendra le code de votre API. Vous pouvez maintenant ajouter la logique permettant de fournir et de gérer l’heure actuelle via l’API.

Saisissez le code suivant dans godocker/main.go :

package main

import (
"encoding/json"
"fmt"
"log"
"net/http"
)

type Time struct {
CurrentTime string `json:"current_time"`
}

func main() {
// defining router
mux := http.NewServeMux()
mux.HandleFunc("/time", getTime)

// starting server
fmt.Println("Server is running at 127.0.0.1:8080")
log.Fatal(http.ListenAndServe( "localhost:8080", mux))
}

func getTime(w http.ResponseWriter, r *http.Request) {
currentTime := []Time{
    { CurrentTime: http.TimeFormat },
}

json.NewEncoder(w).Encode(currentTime)
}

Ce code fournit l’heure actuelle via l’API en utilisant la variable TimeFormat du package http. Nous définissons ensuite la bibliothèque NewServeMux comme mux afin de configurer le serveur HTTP. Enfin, nous enregistrons le point de terminaison /time pour renvoyer l’heure actuelle via l’API et encoder la réponse au point de terminaison /time au format JSON.

go mod init godocker

Faisons un test rapide. Exécutez l’application dans le terminal avec la commande suivante :

go run main.go

Vous devriez obtenir un résultat semblable à celui-ci :

Server is running at 127.0.0.1:8080

Testons maintenant l’API dans une autre fenêtre de terminal à l’aide de cURL. Saisissez la commande suivante dans le terminal :

curl http://127.0.0.1:8080/time

Le résultat devrait ressembler à ceci :

[{"current_time":"Mon, 02 Jan 2006 15:04:05 GMT"}]

Préparer le Dockerfile

Un Dockerfile contient une série d’instructions pour empaqueter et déployer une application sous forme de conteneur. Dans cette section, nous allons créer un Dockerfile et passer en revue certaines des instructions permettant d’empaqueter notre exemple d’application sous forme de conteneur.

Indiquer la version de la syntaxe Docker

Commençons par ajouter la directive de syntaxe. Dans le répertoire godocker/, créez un nouveau fichier nommé « Dockerfile » et saisissez le code suivant sur la première ligne :

# syntax=docker/dockerfile:1

La directive de syntaxe indique l’emplacement de la syntaxe Dockerfile que nous utiliserons pour créer notre Dockerfile. Cette ligne de code définit cet emplacement sur docker/dockerfile:1, qui correspond à la dernière version de la syntaxe. Docker vérifie la version de la syntaxe avant d’utiliser le moteur Buildkit pour créer le Dockerfile.

Veillez à commenter cette ligne au début du fichier. Après la directive de syntaxe, la convention consiste à laisser une ligne vide.

Hériter d’une image de base peu volumineuse

Ensuite, nous indiquons l’image Docker de base dont nous voulons hériter à l’aide de l’instruction FROM.

Ajoutez le code suivant à godocker/Dockerfil` :

FROM golang:1.17-alpine

Cette instruction nous évite d’avoir à créer notre propre image Docker de base. Nous héritons plutôt de l’image officielle Docker destinée aux applications Go, dans sa variante Alpine Linux. La version de Go de l’image de base est la 1.17. L’image alpine est très légère par rapport à une variante comme l’image ubuntu.

Complétons maintenant le reste du Dockerfile, en ajoutant des commentaires au code pour expliquer brièvement le rôle de chaque ligne.

Mettez à jour godocker/Dockerfile pour y inclure le code suivant :

# syntax=docker/dockerfile:1

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

# tells Docker that the container listens on specified network ports at runtime
EXPOSE 8080

# command to be used to execute when the image is used to start a container
CMD [ "/godocker" ]

Créer l’image

Notre Dockerfile est prêt. Créons maintenant notre image Docker à partir du Dockerfile avec la commande docker build. Docker utilise le daemon Docker pour créer les images. Avec la commande docker build, nous utilisons l’option --tag — que nous pouvons abréger en -t — pour attribuer un nom personnalisé à notre image Docker.

Saisissez la commande suivante dans le terminal :

docker build --tag godocker .

Nous ajoutons . à la commande docker build pour créer l’image dans le répertoire actuel, qui sert de contexte de build. Évitez d’utiliser le chemin / comme contexte de build, car cela peut transférer tout le code source au daemon Docker.

La sortie du build doit contenir la ligne FINISHED et ressembler à ceci :

[+] Building 6.8s (17/17) FINISHED                                                            
...
 => => writing image sha256:539bdb3e661f66d489467ef217e1b46786de9cf3c29dc9a2dd6b4e9fa763  0.0s
 => => naming to docker.io/library/godocker

Cela signifie que l’image Docker a été entièrement créée avec le tag godocker.

Pour afficher la liste des images locales, saisissez la commande suivante dans le terminal :

docker image ls

Votre résultat devrait ressembler à ceci :

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                latest     539bdb3e661f    2 minutes ago   319MB
docker/getting-started  latest     720f449e5af2    1 hour ago      27.2MB

Dans notre résultat, la nouvelle image godocker créée fait 319 Mo, ce qui est volumineux pour une simple application API. Nous devons donc optimiser le build pour créer une image plus légère. Dans la section suivante, nous allons mettre en œuvre le concept de builds multi-étapes pour obtenir une image plus légère.

Utiliser des builds multi-étapes

Les builds multi-étapes permettent de créer des images bien plus petites que celles obtenues avec l’approche à une seule étape présentée dans la section précédente. Un build multi-étapes utilise une image pour créer des éléments, qui sont ensuite empaquetés dans une image plus légère contenant uniquement les éléments indispensables à leur exécution. En réduisant nos images au strict nécessaire pour exécuter l’application, nous pouvons limiter le risque de vulnérabilités. Pour ce faire, nous devons utiliser plusieurs instructions FROM dans notre Dockerfile.

Utiliser l’image officielle scratch

Nous pouvons commencer notre build avec une image vide en héritant de l’image officielle Docker scratch. Dans cette section, nous allons montrer comment utiliser l’image scratch pour les builds multi-étapes.

Accédez d’abord au répertoire racine de votre application. Créez ensuite un fichier nommé « Dockerfile.multistage » et saisissez le code suivant :

# syntax=docker/dockerfile:1

##
## STEP 1 - BUILD
##

# specify the base image to  be used for the application, alpine or ubuntu
FROM golang:1.17-alpine AS build

# create a working directory inside the image
WORKDIR /app

# copy Go modules and dependencies to image
COPY go.mod ./

# download Go modules and dependencies
RUN go mod download

# copy directory files i.e all files ending with .go
COPY *.go ./

# compile application
RUN go build -o /godocker

##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

EXPOSE 8080

ENTRYPOINT ["/godocker"]

Ce code indique que l’image de base à utiliser est l’image officielle golang:1.17-alpine, à laquelle on attribue le nom d’étape build. Nous utilisons ensuite une autre instruction FROM pour mettre en œuvre le principe du build multi-étapes : le binaire créé à la première étape est copié dans l’image vide de la deuxième étape.

Nous devons ensuite créer une nouvelle image à l’aide du nouveau fichier Dockerfile.multistage. Nous devons également attribuer à cette nouvelle image le tag « multistage », afin de la distinguer de celle créée précédemment.

Saisissez la commande suivante dans le terminal :

docker build -t godocker:multistage -f Dockerfile.multistage .

Une fois le build terminé, vérifiez la liste des images en saisissant la commande suivante dans le terminal :

docker image ls

Votre résultat devrait ressembler à ceci :

REPOSITORY              TAG        IMAGE ID        CREATED         SIZE
godocker                multistage 192cc137f88b    9 seconds ago   6.18MB
godocker                latest     539bdb3e661f    1 hour ago      319MB

Ce résultat montre la grande différence de taille entre les images godocker:multistage et godocker:latest. La réduction est considérable : l’image à une seule étape passe de 319 Mo à seulement 6,1 Mo pour l’image multi-étapes. Comme nous comptons sur les conteneurs pour démarrer rapidement, ce type d’optimisation est essentiel lors de la conteneurisation d’applications Go.

Déployer le conteneur

Outre l’optimisation des performances et de l’efficacité, nous devons également réfléchir à la meilleure façon de déployer nos conteneurs pour les exécuter en toute sécurité. Au cours de cette étape du tutoriel, nous allons appliquer quelques bonnes pratiques.

Exécuter en tant qu’utilisateur non root

Le principe du moindre privilège exige de limiter l’accès aux ressources système. Nos conteneurs Docker Go sont des conteneurs d’application et n’ont pas besoin de s’exécuter avec les privilèges root. Pour renforcer la sécurité, nous devrions donc créer dans notre Dockerfile un nouvel utilisateur et un nouveau groupe disposant d’un accès limité.

Pour créer un utilisateur non root, ajoutez les lignes suivantes immédiatement après la première instruction FROM dans godocker/Dockerfile.multistage :

RUN useradd -u 1001 -m iamuser

Cette instruction définit les arguments USERNAME et PASSWORD à l’aide du mot-clé ARG, puis crée un utilisateur avec l’instruction RUN adduser.

Ajoutons maintenant les instructions permettant de copier les informations de l’utilisateur depuis la première étape et de les appliquer à la deuxième.

Ajoutez le code en gras de l’exemple suivant à la deuxième étape de godocker/Dockerfile.multistage. Le fichier devrait alors se terminer par le code suivant :

...
##
## STEP 2 - DEPLOY
##
FROM scratch

WORKDIR /

COPY --from=build /godocker /godocker

COPY --from=build /etc/passwd /etc/passwd

USER 1001

EXPOSE 8080

ENTRYPOINT ["/godocker"]

Dans Kubernetes, vous pouvez spécifier runAsuser: UID dans le champ securityContext. Consultez la documentation Kubernetes pour découvrir comment définir le contexte de sécurité d’un Pod.

Exécuter avec un système de fichiers racine en lecture seule

Une autre façon de renforcer la sécurité de notre application consiste à exécuter le conteneur avec un système de fichiers en lecture seule. Pour imposer l’accès en lecture seule au système de fichiers de notre conteneur, nous transmettons l’option correspondante à la commande docker run.

Saisissez la commande suivante dans le terminal :

docker run -read-only godocker

Supprimer ou refuser les capacités Linux

Les capacités Linux sont des privilèges que nous pouvons activer ou désactiver sous Linux. Comme notre conteneur repose sur une variante de Linux, nous pouvons également utiliser ces capacités pour renforcer la sécurité de notre application. La suppression de certaines capacités réduit les risques pour notre conteneur.

Pour ce tutoriel, supprimons toutes les capacités à l’exception de setuid.

Saisissez la commande suivante dans le terminal :

docker run --cap-drop=all --cap-add=setuid

Limiter l’utilisation du processeur et de la mémoire

La commande docker run permet de définir des limites d’utilisation des ressources de la machine hôte pour notre conteneur Docker. Docker utilise l’option --cpus.

Par exemple, pour empêcher le conteneur d’utiliser plus de 50 % d’un seul processeur, saisissez la commande suivante dans l’interface de ligne de commande Docker :

docker run -it --cpus=".5" alpine /bin/bash

Si vous devez utiliser 2 processeurs, vous pouvez en limiter l’utilisation avec la commande suivante dans l’interface de ligne de commande Docker :

docker run -it --cpus=2 alpine /bin/bash

Pour limiter à 1 024 Mo l’utilisation de la mémoire d’un conteneur Docker, vous pouvez utiliser la commande docker run, comme ceci :

docker run -m 1024m --memory-reservation=256m alpine /bin/bash

Cette commande définit également une limite de mémoire de 256 Mo, appliquée lorsque Docker détecte que la mémoire de l’hôte est insuffisante.

Conclusion

Dans cet article, nous avons parcouru les étapes de configuration d’une application Go et de sa conteneurisation avec Docker. Nous avons également mis en œuvre le principe des builds multi-étapes pour optimiser les performances, créé une image à partir de l’image officielle Docker vide, et présenté quelques bonnes pratiques utiles lors de la création et du déploiement de conteneurs. Ces recommandations constituent un point de départ pour mettre en place une approche efficace et robuste de la sécurité et de la gestion de la mémoire lors de la conteneurisation d’applications web Go avec Docker.

Pour en savoir plus sur les bonnes pratiques de sécurité, consultez le centre de ressources Snyk Learn.