»
Pour mettre en œuvre ce mécanisme, nous allons devoir nous appuyer sur plusieurs concepts :
Nous n’allons pas ici décrire ici le but de Kubernetes, simplement rappeler qu’il s’agit d’un orchestrateur de conteneurs (Docker en premier lieu). Son but est simplement d’exécuter des applications sur une flotte de machines. Les applications hébergées ont généralement besoin d’être configurées, et Kubernetes propose des mécanismes d’injection de paramètres, via des fichiers de configuration ou des variables d’environnement.
Pour simplement résumer l’objectif de Vault, décrivons-le comme un coffre-fort à secrets accessible au travers d’une API. Les secrets sont organisés dans des structures hiérarchiques et des mécanismes d’authentification et d’autorisation permettent d’en contrôler les accès. Des mécanismes d’audit permettent de tracer les accès (en lecture et en écriture) sur les secrets. Si vous ne connaissez pas du tout Vault, n’hésitez pas à jeter un coup d’œil à cet article qui décrit - dans sa première partie - son fonctionnement.
« Montre-moi un token Kubernetes, je te donnerai un token Vault… »
Cette brique permet de faire le lien entre les identités Kubernetes et les identités Vault. Elle valide les tokens Kubernetes et offre en échange un token Vault. Ce dernier point est relativement important, un token Kubernetes n’est pas directement utilisable dans Vault.
Le token Kubernetes est un token JWT que chaque pod (le concept logique qui enveloppe des conteneurs applicatifs dans Kubernetes) peut obtenir simplement (un fichier déposé dans le pod). Ce token est associé à un serviceAccount qui au sein d’un namespace porte l’identité du pod.
La configuration de ce module implique de créer dans Kubernetes un compte (un serviceAccount à nouveau) qui est autorisé à valider les tokens Kubernetes.
S’il est un outil de la galaxie Hashicorp qui porte mal son nom, c’est bien celui-ci. Il serait bien plus judicieux de le nommer hashicorp-template. Et pour cause, l’usage que nous allons en faire ici n’a rien à voir avec Consul. Le principe de cet outil est de générer des fichiers à partir de templates. Les templates font référence à des données qui sont issues de Consul (d’où son nom), mais aussi de Vault et c’est cette dernière caractéristique qui nous intéresse pour la suite.
Nous allons chercher à déployer une application dont un fichier de configuration est généré sur la base d’un template, dont le contenu est en partie issu de Vault (pour représenter les données sensibles), et en partie de configMap Kubernetes classiques (pour représenter la partie non sensible de la configuration de notre application). L’objectif est d’utiliser l’image d’une application sans la modifier. Pour ce faire, nous allons utiliser le mécanisme des initContainers qui permet de lancer des conteneurs en phase d’initialisation d’un pod, en vue d’en préparer le démarrage.
La cinématique (simplifiée) du démarrage se décompose comme suit :
En pratique, les choses sont un peu plus compliquées car consul-template ne sait pas directement s’authentifier avec un token Kubernetes. Il faut une première étape pour échanger auprès de Vault un token Kubernetes en token Vault. Et ce n’est qu’après cette première étape que consul-template peut faire son travail.
Si on regarde la cinématique complète, elle est composée des étapes suivantes :
À noter que deux options sont possibles pour effectuer la première étape d’échange du token Kubernetes en token Vault : Utiliser un simple cURL ou s’appuyer sur des images Docker spécialisées dans ce travail. La version cURL pourrait ressembler à cela :
$ KUBE_TOKEN=$(cat "/var/run/secrets/kubernetes.io/serviceaccount/token")
$ VAULT_K8S_LOGIN=$(curl --request POST --data \
'{"jwt": "'"$KUBE_TOKEN"'", "role": "org1-app1-reader"}' \
https://vault:8200/v1/auth/kubernetes/login | jq -r '.auth .client_token')
$ echo "$VAULT_K8s_LOGIN" > "/data/.vault-token"
Pour faire plus simple, il serait évidemment pertinent de combiner en une seule image Docker les fonctions utilisées dans les deux premiers conteneurs. Étonnement, aucune image officielle ne semble exister à ce jour…
Pour l’exemple, nous avons utilisé un simple NGINX pour jouer le rôle de l’application. Le fichier de configuration généré est en fait une page index.html qui est servie par le serveur Web. Les manifestes représentant les configMap et deployment de notre application ressemblent aux fichiers qui suivent.
Une première configMap décrit le template que nous allons utiliser pour configurer notre application :
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-templates
data:
index.html.ctmpl: >
<html>
{{ with secret "secret/org1/app1" }}
{ .Data.var }}
{{ end }}<br />
PLOUPI: {{ env "A_SPECIFIC_VAR" }}
</html>
Vient ensuite la configMap en charge de porter les paramètres de configuration non sensibles :
---
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
A_SPECIFIC_VAR: FOOBAR
Enfin, notre deployment :
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-app
spec:
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
volumes:
- name: vault-token
emptyDir:
medium: Memory
- name: generated-conf
emptyDir:
medium: Memory
- name: nginx-templates
configMap:
name: nginx-templates
- name: vault-ca
configMap:
name: vault
items:
- key: vault_ca_file
path: vault_ca_file
initContainers:
- name: vault-authenticator
image: sethvargo/vault-kubernetes-authenticator:0.1.2
volumeMounts:
- name: vault-token
mountPath: /home/vault
- name: vault-ca
mountPath: /home/vault/ca
env:
- name: VAULT_CACERT
value: /home/vault/ca/vault_ca_file
- name: TOKEN_DEST_PATH
value: /home/vault/.vault-token
- name: VAULT_ROLE
value: org1-app1-reader
- name: VAULT_ADDR
valueFrom:
configMapKeyRef:
name: vault
key: vault_addr
- name: consul-template
image: hashicorp/consul-template:0.19.5-alpine
volumeMounts:
- name: vault-token
mountPath: /home/vault
- name: vault-ca
mountPath: /home/vault/ca
- name: nginx-templates
mountPath: /tmp/nginx-templates
- name: generated-conf
mountPath: /tmp/gen-conf
env:
- name: HOME
value: /home/vault
- name: VAULT_CACERT
value: /home/vault/ca/vault_ca_file
- name: VAULT_ADDR
valueFrom:
configMapKeyRef:
name: vault
key: vault_addr
envFrom:
- configMapRef:
name: app-config
command:
- "consul-template"
- "-once"
- "-template"
- "/tmp/nginx-templates/index.html.ctmpl:/tmp/gen-conf/index.html"
containers:
- name: nginx
image: nginx:alpine
volumeMounts:
- name: generated-conf
mountPath: /usr/share/nginx/html/
Une configMap supplémentaire est utilisée : vault. Elle contient deux informations permettant de se connecter à Vault :
Consul-template est lancée avec l’option -once. L’objectif est de générer une fois le fichier et de s’arrêter immédiatement après.
Deux volumes temporaires nommés vault-token et generated-conf sont créés en vue de servir de répertoire d’échange entre les conteneurs du pod :
Ces deux volumes sont configurés pour être en mémoire uniquement. Ceci a pour objectif de minimiser les chances que ces données (sensibles) soient écrites sur disque. Ce mécanisme n’empêchera toutefois pas des administrateurs - ou tout utilisateur autorisé à exécuter les commandes kubectl cp ou kubectl exec - de récupérer le contenu des secrets.
Nous l’avons vu, ce modèle d’implémentation permet l’utilisation de secrets stockés dans Hashicorp Vault dans des applications déployées dans Kubernetes. Comme cela se fait sans modifier les applications, le couplage entre les technologies reste très faible. Du point de vue de l’application, un fichier de configuration est simplement présent au démarrage, sans avoir connaissance de la façon dont il a été généré.
Cette approche basée sur des initContainers fonctionne exclusivement dans le cas de secrets statiques, autrement dit, qui ne changent pas souvent dans le temps. Dans cette approche, si un secret est modifié, il faut provoquer le redémarrage des pods. C’est bien souvent une approche tout à fait suffisante pour la plupart des cas.
Dans le cas de secrets dynamiques et/ou à durée de vie courte, deux approches doivent être envisagées : déployer un sidecar à côté de l’application ou modifier le conteneur de l’application. Cette dernière solution semble dangereuse car elle introduit un couplage fort. L’article de blog suivant vous en dira plus sur le principe de mise en œuvre…
En phase de mise au point, même s’il est possible de découper les étapes pour les tester localement (sur un poste de développement, avec Minikube par exemple) il est nécessaire d’être dans un environnement avec Vault et Kubernetes réellement intégrés pour s’assurer que l’ensemble du mécanisme fonctionne correctement.
La complexité dans la mise en œuvre n’est pas à minimiser, en effet, la syntaxe des fichiers de description du deployment Kubernetes est très largement étoffée par rapport à une application qui utiliserait simplement des configMaps et des secrets Kubernetes.
Est-ce que pour autant il faut systématiquement rejeter cette intégration ? Certainement pas. Il existe des contextes (en particulier dans des grosses organisations) où la gestion des données sensibles dépassent largement le cadre d’un cluster Kubernetes et justifient leur externalisation. Vault peut en effet jouer un rôle pivot majeur entre plusieurs technologies (dont Kubernetes, mais pas uniquement et c'est une de ses forces), plusieurs équipes, avec une très forte rigueur dans la traçabilité.