S'embarquer dans Kubernetes from scratch (ou presque).

S'embarquer dans Kubernetes from scratch (ou presque).
Photo by Alina Grubnyak / Unsplash

Dans ce retour d'expérience, je partagerai mes impressions et mes observations sur l'utilisation de Kubernetes dans différents contextes et je vous donnerai mon point de vue sur les avantages et les défis de cet outil passionnant.

Tout d'abord, il existe plusieurs manières de tester Kubernetes et pas mal de distributions avec tout un outillage déjà fourni autour. Petit florilège :

  • k3s: version minimaliste de kube
  • microk8s: dérivé Canonical de kubernetes
  • minikube: version simple upstream de kube pour maquetter et prendre en main le tout
  • k8s: Upstream vanilla
  • OKD: version upstream communautaire de la distribution Kubernetes OpenShift de Red Hat
  • MicroShift: dérivé minimaliste d'OpenShift
  • Tanzu: Distribution de VMware pour Kubernetes
  • Rancher: Distribution de SUSE pour kube

Je ne vais pas expliquer ici comment on utilise Kubernetes, abrégé en K8S, chose couverte partout, dans toutes les langues. Ce sera simplement un retour sur comment j'ai abordé le sujet à ce moment-là, des erreurs que j'ai pu faire, des conseils sur comment ne pas tomber dans certains pièges ou problèmes et quelques astuces qui m'auront bien aidé et m'aident toujours d'ailleurs.

Premiers pas

Donc, le problème fût déjà de trouver par où commencer. La première chose était d'installer une maquette pour débuter les manips depuis la documentation upstream de k8s plutôt qu'un tuto sur un blog obscure comme ce présent blog :). Et cela pour la simple et bonne raison que les post de blogs sont souvent obsolètes et datés et vu l'allure à laquelle évolue Kubernetes, il est bien plus adéquat de partir de la documentation, comme souvent, plutôt qu'un post de blog. Les informations sur les blogs m'ont plutôt servi à expliquer plus clairement ou différemment certains points de la doc quand j'avais un doute ou incompréhension.

Donc, mon choix s'est porté sur l'installation d'un Kubernetes vanilla upstream. Mon but étant d'apprendre le plus possible, il s'agit de trouver un juste milieu entre le from scratch qui pique et la distribution tout intégrée, y'a plus qu'à consommer. Un peu plus raide donc pour commencer qu'un minikube, ou k3s.

Installation d'un Kubernetes par kubeadm

Voilà, donc ici on suit la documentation upstream pour instancier un cluster kubernetes sur 1 noeud ! Un noeud dit master donc qui n'est pas voué, en temps normal, à exécuter quelques charges de travail que ce soit. Etant donné que je n'ai pas d'autre machine sous la main à ce moment là, il me faudra composer avec, alors go. Ce sera un cluster mono-noeud pour commencer qui sera master+worker.

Je vous laisse regarder les autres modes de déploiement préconisé par Kubernetes avec Kops et Kubespray. Ce dernier étant le déploiement de kube en utilisant Ansible, c'est plutôt une bonne alternative à kubeadm permettant de suivre le cycle de vie du cluster.  J'aurais sûrement choisi celle méthode avec le recul. Sinon il existe pléthore de moyen de déployer du kube que l'on peut trouver selon qu'on déploie une distribution comme Openshift ou Tanzu qui ont tous les deux des déploiements différents selon le mode voulu.

J'aurais donc un noeud master installer sur un petit NUC Intel avec un core I5 + SSD avec un flatcar comme OS immutable afin d'être le plus tranquille possible quant à son exploitation. Pratique, il dispose notamment d'un opérateur Kubernetes pour ses mises à jour OS qui prend en charge le drain de pod sur un autre noeud et le reboot. Voilà, l'OS maintenant est a oublier une fois qu'on a le Kube qui tourne.

Pour rappel, kubernetes préconise d'avoir noeuds plusieurs ayant différents rôles et la bonne pratique voudrait qu'un cluster soit composé:

  • 3 noeuds master: Exécutant la base de données de l'état du cluster, etcd, mais peut tourner sous sqlite par exemple avec k3s. Ils feront tourner les process coeurs de Kubernetes: scheduler, controller et l'apiserver.
  • X noeuds worker, selon la charge à y mettre, et généralement X commence à 3.... Ici on y fait tourner les applications clients

Sur chaque noeud il y aura les différents composants:

  • CRI : Container Runtime Interface, docker (déprécié), containerd, cri-o, ...
  • CNI : Container Network Interface, flannel, calico, antrea, cilium, ...
  • CSI : Container Storage Interface, vSphere, Longhorn, NFS, ...

Le cas ETCD, le coeur battant du cluster

En soit, la documentation est pas mal et l'instanciation d'un cluster est plutôt simple et le résultat fût l'effondrement sous le poids de Kubernetes de mon vénérable serveur sur un AMD 2 cores un peu faiblard et vieux de plus de 10 ans. Alors qu'en temps normal, il tourne à ~80% d'idle, un peu large donc niveau CPU... Kube l'effondre littéralement et on est souvent à 0% d'idle. Pourquoi ? Très simplement à cause d'un composant très, très, très exigeant en IO d'etcd \o/ ! ETCD requiert une latence sous les 5ms en accès au stockage en permanence. C'est qu'il écrit beaucoup, et souvent, pour y mettre tout un tas de truc relatif à l'état présent du cluster, il est donc HAUTEMENT recommandé, voire obligatoire, de le mettre sous minimum SSD et mieux NVME. Dans tous les cas, mon processeur était trop peu performant pour satisfaire les demandes d'etcd en plus du reste.

Ce qui fait que j'avais un noeud qui fonctionnait mais l'idle de mon CPU tombait autour des ~30% avec des chutes à 0% régulièrement mais moins qu'avant quand même. Alors, cela était dû à etcd donc qui ne pouvait pas écrire dans les temps parfois, se retrouver à logger des erreurs qui étaient elle-mêmes écrites sur disques au bout d'un moment et cela créait régulièrement des avalanches IO et chutes de perf du CPU.

Je vous conseille de faire tourner le petit script de Red Hat, développé pour OpenShift afin de contrôler les performances des disques censés héberger la base etcd. Cela fera un test sur le filesystem utilisé par /var/lib/etcd par défaut, changer ce chemin si vous voulez tester un autre filesystem:

# Avec Podman
sudo podman run --volume /var/lib/etcd:/var/lib/etcd:Z quay.io/openshift-scale/etcd-perf
# Ou avec Docker
sudo docker run --volume /var/lib/etcd:/var/lib/etcd:Z quay.io/openshift-scale/etcd-perf
etcd performance benchmark

La page de documentation sur le Tuning d'etcd peut aider et améliorer un peu votre utilisation. Je recommande, cela m'a permis de remonte à ~50% d'idle.

Et je ne peux que vous conseillez la lecture de ce blog post détaillant le fonctionnement et tenants, aboutissants de la solution. Très instructif.

CNI, couche réseau pour ton kube

Une fois son premier noeud master déployé, il nous manque la couche réseau pour que les conteneurs puissent discuter entre eux. Sans quoi, vous verrez vos pods dans le namespace kube-system en train de CrashLoopBackOff. J'ai pris le premier que je retrouvais sur plusieurs blog posts aux alentours en faisant des recherches de déploiement Kubernetes, Flannel.

Installation simple, on touche ici au côté cool de Kube, il suffit d'appliquer un YAML. Tout est expliqué dans le README du github de flannel :

kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

Hop, c'est tout.

Alors pour voici une illustration permettant d'appréhender son implémentation dans Kubernetes, avec Flannel tant qu'à faire:

Ce qu'il faut noter ici, ce sont les différents sous-réseaux disponible:

  • Cluster CIDR: Sous-réseau global au cluster dans lequel sera découper des sous-réseaux pour les Pods.
  • Pod CIDR: Le sous-réseau du noeud permettant au Pods tournant dans un cluster de communiquer entre eux et sous-réseau pris depuis le cluster CIDR. Ce réseau est accessible depuis l'extérieur du cluster mais ne l'utiliser pas pour ça.
  • Service CIDR: Ce réseau interne au cluster permet d'exposer une application en gérant son nom d'hôte et IP de manière automatique. Le principe étant d'allouer dynamiquement une IP et d'avoir toujours le même port, ce qui permet en se basant sur la résolution DNS de ne jamais se préoccuper des IPs attribuées aux services. Bref, DHCP+DNS dynamique géré de façon totalement automatique. Plus de détails dessous !

Petit point . sur les services et le réseau

Un pod n'a pas d'adresse au départ. Créer un pod à partir d'un manifest YAML ne fait jamais que démarrer un conteneur. Pour lui coller un adresse, prise depuis le Pod CIDR, il faut lui déclarer un service en lui spécifiant un selector afin de faire le lien avec le pod. En règle général, on attribue un label au pod, exemple app: myapp et on spécifie un selector reprenant ce label.

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app.kubernetes.io/name: proxy
spec:
  containers:
  - name: nginx
    image: nginx:stable
    ports:
      - containerPort: 80
        name: http-web-svc

---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app.kubernetes.io/name: proxy
  ports:
  - name: name-of-service-port
    protocol: TCP
    port: 80
    targetPort: http-web-svc

Une fois le service déclaré, vous aurez une IP prise depuis le Service CIDR, non routable sur l'extérieur, et un endpoint correspondant qui sera l'IP du Pod, qui elle est routable sur l'extérieur. Mais vous ne voudrez surtout pas l'utiliser pour atteindre le service derrière car cette IP change à chaque fois que le pod s'instancie.

$ kubectl get services
NAME             TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)                        AGE
coredns          ClusterIP   10.3.0.10     <none>        53/UDP,53/TCP                  160d
kubelet          ClusterIP   None          <none>        10250/TCP,10255/TCP,4194/TCP   135d
metrics-server   ClusterIP   10.3.204.83   <none>        443/TCP                        160d
$ kubectl get endpoints
NAME             ENDPOINTS                                                           AGE
coredns          10.2.0.148:53,10.2.0.42:53,10.2.0.148:53 + 1 more...                160d
kubelet          192.168.1.3:10250,192.168.1.2:10250,192.168.1.3:10255 + 3 more...   135d
metrics-server   10.2.0.146:4443                                                     160d

C'est le boulot d'un ingress désormais, qui vous permettra d'atteindre le service derrière de manière consistante peu importe l'IP que prendra l'endpoint ou le service. Un ingress permet de router une requête HTTP sur un service ou bien une ressource de stockage par exemple. L'ingress est géré par un ingress controller qui LUI aura une adresse fixe et fera office de reverse proxy pour tout vos services gérés par votre Kube.

OU ALORS ! On utilise un Load Balancer, tel que MetalLB ou un Traefik par exemple, et on crée des services de type LoadBalancer plutôt que ClusterIP, par défaut. Dans ce cas, le Load Balancer externe se chargera d'attribuer une IP selon sa configuration au service. Cette IP sera directement routable depuis l'extérieur et ne proviendra pas du Kubernetes mais du Load Balancer qui peut d'ailleurs tout aussi bien tourner dans le Kube, cela ne gêne pas. Vous pourrez voir son IP attribuée par le champ External-IP du service :

$ kubectl get service -n traefik
NAME           TYPE           CLUSTER-IP    EXTERNAL-IP              PORT(S)                                     AGE
traefik        LoadBalancer   10.3.154.90   192.168.1.11             9000:31130/TCP,80:32676/TCP,443:32601/TCP   111d

Tout est affaire de configuration et de choix. Kubernetes est un puit sans fond de possibilités et de configurations pour s'adapter à toute situation.

Typiquement si vous ne rediriger que des requêtes HTTP, ce qui est généralement le cas, les ingress controller suffisent et route le trafic au niveau L7. Si vous préférez gérer le trafic à un niveau plus bas, alors un Load Balancer externe vous permettra d'agir sur le L4. Traefik par exemple permet les deux qui a été mon choix et reste très pratique pour à la fois gérer du reverse proxy http et tcp. Il est possible de rediriger son trafic très facilement depuis Traefik pour le diriger sur un service interne ou un externe au cluster. Cela permet de glisser doucement les services depuis un apache vers kubernetes petit à petit.

CSI et stockage

Bon, on est content, on a un Kube qui tourne des pods qu'on peut adresser depuis l'extérieur. Maintenant, il s'agit de mettre des trucs sympa dessus comme un blog, un stockage cloud, peu importe. Il faudra bien donc stocker des données particulières à un moment et Kube sert surtout à faire du stateless à la base. Pas mal de mèmes circulent sur le sujet d'ailleurs :)

Quand il s'agit de configuration d'un composant prévu pour fonctionner en container, vous devriez pouvoir effectuer la configuration par variable d'environnements et stockage de vos mots de passe dans des secrets ou plus évolué et secure dans un Vault. Lorsqu'il s'agit de provisionner un simple, ou plusieurs, fichiers de configuration une simple ConfigMap fait l'affaire sinon.

Mais lorsqu'il faut stocker de la donnée de manière persistante, le cas le plus classique étant la base de données, il faut bien avoir un moyen de monter un volume dans le container. Sachant qu'un pod est généralement déployé en plusieurs exemplaires afin de garantir une disponibilité, il faut pouvoir garantir l'accès au stockage et de savoir gérer la concurrence. D'où les PV, Persistent Volume, et PVC, Persistent Volume Claim. Le PV déclare un volume avec une taille et des modes d'accès, le PVC est une demande d'utilisation d'un PV qui est utilisé dans un Pod, par exemple, pour faire la liaison entre le PV et l'objet. Un PV peut avoir plusieurs modes d'accès par exemple et le PVC n'en n'utilisera qu'un parmi ceux déclarés.

A cela s'ajoute les CSI, Container Storage Interface, nombreux ils permettront de vous donner un accès à du stockage de manière optimisée et dynamique selon le constructeur de la solution de stockage que vous utiliser. A vous de choisir celui qui vous convient le mieux.

De mon côté, je vais commencer avec le CSI NFS gérant déjà l'accès et écriture concurrentes. Les modes d'accès possible au stockage sont les suivants :

  • RWO - ReadWriteOnce : Lecture possible en concurrence par plusieurs Node et 1 seul en écriture.
  • RWX - ReadWriteMany : Pareil avec plusieurs Node en écriture concurrente.
  • ROX - ReadOnlyMany :  Lecture possible en concurrence par plusieurs Node, aucun en écriture.
  • RWOP - ReadWriteOncePod : Lecture et écriture possible 1 seul Pod. A partir de Kubernetes version 1.22

Les CSI vont s'occuper de la création automatique et manuelle, de volumes persistents, pv, et pvc associés qui font le lien entre un pod et un pv.

Son déploiement est décrit dans le dépôt Git du projet. Plutôt simple et efficace, vous aurez des exemples très parlant dans le dépôt pour vous permettre de faire vos essais.

Par la suite, à cause de soucis de permissions sur les fichiers par le drivers, j'ai ajouté une CSI Longhorn. Il arrive que vos pods monte un pv et change les propriétaires des fichiers en première action lorsque vous spécifiez un contexte de sécurité et notamment avec les attributs fsGroup et fsGroupPolicy définissant le comportement au montage selon le mode. En résulte qu'avec NFS cela pose soucis, car vous ne pouvez pas modifier les droits de fichiers sans en être propriétaire depuis le montage. Par dessus cela, vous avez du mapping d'uid et gid. Le changement des propriétaires en utilisant l'anonid de NFS permet au moins de faire en sorte que ça marche mais c'est manuel et difficilement maintenable dans le temps et à plus grande échelle. Vous avez une discussion sur une issue de leur dépôt Git expliquant le problème.

Longhorn à contrario permet de provisionner du stockage block par iSCSI simplement pour faire du RWO. Le support pour le RWX est en cours en ajoutant par dessus une couche de NFS, à l'état expérimental pour l'instant.

Ptits trucs sympa pour aider

Le shell

Première chose, se faire un dépôt git dans lequel mettre tout ses manifests YAML. Pour chaque namespace, vous pouvez créer un dossier correspondant afin de tout bien ranger. Et surtout, vous pourrez utiliser direnv, utilitaire très sympa qui vous permet de sourcer un fichier shell en entrant dans un répertoire. Vous pourrez donc en changeant de répertoire vous mettre dans un contexte voulu. De mon côté, je l'utilise pour effectuer un changement de contexte kubernetes et me mettre dans le namespace du dossier pour que mes get pod ou autre commandes s'appliquent sur ce namespace sans avoir besoin de changer à la main le namespace avec la commande un peu longue par kubectl.

Ce qui m'amène aux alias ou fonctions shell permettant d'exploiter un peu plus facilement votre cluster. Voici les miennes côté bash :

function kn() {
  NS=$1
  for i in $(kubectl get ns -oname | cut -d'/' -f2)
  do
    if test "$i" = "$NS"
    then
      kubectl config set-context --namespace="$NS" --current
      return
    fi
  done
  echo "$NS does not exists"
  return 1
}

function kctx() {
  kubectl config use-context $1
}

function kn_clean_replicasets() {
  for ns in $(kubectl get ns --no-headers| awk '{print $1}')
  do
    rs=$(kubectl get -n $ns replicasets.apps --no-headers 2> /dev/null| awk '{print  $1" "$2}'|grep -E '0$' | awk '{print $1}')
    if test "$rs"
    then
      kubectl delete -n $ns replicasets.apps $rs
    fi
  done
}

function k8s_clean_all_node_pods() {
	NS=$(kubectl config get-contexts --no-headers | awk '{print $5}')
	[ "$1" ] && NS=$1
	kubectl get pods -A -owide | grep "$NODE" | awk '{print $1" "$2}' | xargs -n 2 kubectl delete pods -n
}

function k8s_watch_pods() {
	NS=$(kubectl config get-contexts --no-headers | awk '{print $5}')
	[ "$1" ] && NS=$1
	watch kubectl get pods -n $NS -owide
}

function k8s_node_cidr() {
	kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.podCIDR}{"\n"}'
}

function k8s_get_resources() {
	NS=$(kubectl config get-contexts --no-headers | awk '{print $5}')
	[ "$1" ] && NS=$1
	kubectl get po --all-namespaces -o=jsonpath="{range .items[*]}{range .spec.containers[*]}  {.name}{' requested cpu'}: {.resources.requests.cpu}{'\n  '}{.name}{' limits cpu'}:{.resources.limits.cpu}{'\n'}{end}{'\n'}{end}"
}

function k8s_get_operators_versions() {
	kubectl get subscriptions.operators.coreos.com -ojsonpath='{range .items[*]}{"current: "}{.status.currentCSV}{" installed: "}{.status.installedCSV}{"\n"}{end}'
}

function toggle_k8s_prompt() {
  ls -l $HOME/.config/starship.toml | grep nokube && \
     ln -sf $HOME/.config/starship-kube.toml $HOME/.config/starship.toml || \
     ln -sf $HOME/.config/starship-nokube.toml $HOME/.config/starship.toml
}

export -f toggle_k8s_prompt
export -f k8s_node_cidr
export -f k8s_watch_pods
export -f k8s_get_resources
export -f kn_clean_replicasets
export -f kn

alias kc='kubectl'
complete -F __start_kubectl kc

Pour zsh, j'utilise l'autoload de fonction depuis un répertoire. Zsh n'ayant pas la possibilité d'export de fonction à la bash, c'est la façon la plus simple et élégante. Voici à quoi ressemble une partie de mon .zshrc:

# Use startship
eval "$(starship init zsh)"

#direnv
eval "$(direnv hook zsh)"

#Autojump
eval "$(jump shell)"

alias kc='kubectl'
compdef kc=kubectl

fpath=( ~/.zsh "${fpath[@]}" )
autoload -Uz $fpath[1]/*(.:t)

Puis dans ~/.zsh, vous trouverez un fichier par fonction bash décrite plus haut.

Pour direnv, voici un exemple de fichier .envrc dans chacun de mes dossiers/namespace de mon dépôt git:

kn $(basename $PWD)

Simple et générique, à la racine de mon dépôt j'ai mis une commande me permettant de savoir si j'ai des pods en erreurs:

kubectl get pod -A -owide | grep -vEi "(Completed|Running)"

direnv est normalement plutôt fait pour sourcer des variables d'environnements plutôt que d'exécuter du code car à la sortie d'un répertoire l'utilitaire se charge d'unset les variables du répertoire ou de les remettre à la valeur précédant l'entrée dans le répertoire.

En pour finir, avoir le contexte kubernetes dans le prompt de votre shell aide pas mal. J'utilise le prompt starship qui est compatible avec les principaux shell du moment zsh, fish, bash... Et j'active l'affiche du contexte kubernetes dans la configuration :

[kubernetes]
format = 'on [⛵ $user@$cluster \($namespace\)](cyan bold)'
disabled = false

Le format est propre à mon terminal, je vous conseil de le modifier à votre sauce. Comme vous avez pu le voir dans les fonctions bash, vous avez une fonction permettant éventuellement d'activer ou désactiver le prompt kubernetes à la volée en switchant de fichier.

Le réseau

Un autre utilitaire sympa permettant de visualiser les flux réseaux, un peu comme un wireshark, kubeshark, anciennement nommé Mizu. Pratique pour savoir ce qu'il se passe là-dedans parce que c'est un peu le bordel quand même pour s'y retrouver surtout au début !

YAML qui aident

Quelques YAML qui m'aide parfois pour debugger quelques situations :

apiVersion: v1
kind: Pod
metadata:
  name: ubi8
  #  namespace: kube-system
spec:
  containers:
  - name: ubi8
    image: registry.access.redhat.com/ubi8/ubi
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
    volumeMounts:
      - name: volv
        mountPath: /data
  restartPolicy: Always
  nodeName: enlil.uruk.home
  volumes:
  - name: volv
    persistentVolumeClaim:
      claimName: test-remote

Ubi8 est une image avec un shell et quelques utilitaires comme curl etc.

apiVersion: v1
kind: Pod
metadata:
  name: dnsutils
  #  namespace: kube-system
spec:
  containers:
  - name: dnsutils
    image: k8s.gcr.io/e2e-test-images/jessie-dnsutils:1.3
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
  restartPolicy: Always
  dnsConfig:
    searches:
      - operators.svc.cluster.local
      - svc.cluster.local
      - cluster.local
  nodeName: gilgamesh.uruk.home
  hostNetwork: true
  dnsPolicy: ClusterFirstWithHostNet

Un autre pour debugger les problèmes de résolution de nom selon certaines configurations. Ici j'avais un problème quand j'avais un pod utilisant hostNetwork et la dnsPolicy à ClusterFirstWithHostNet.

apiVersion: v1
kind: Pod
metadata:
  name: maildebug
  #  namespace: kube-system
spec:
  containers:
  - name: maildebug
    image: crazymax/msmtpd
    command:
      - sleep
      - "3600"
    imagePullPolicy: IfNotPresent
  restartPolicy: Always
  nodeName: enlil.uruk.home

Un autre pour test des envois de mails.