J'ai voulu gérer les certificats TLS dans mes clusters KubeVirt. Surprise

thumbernail Kubernetes

J'ai voulu gérer les certificats TLS dans mes clusters KubeVirt. Surprise

Quatrième épisode de la série "KubeVirt + k0smotron, ou comment chaque feature Kubernetes déclenche une investigation réseau". Après les clusters, l'ingress, et le monitoring, j'avais une idée géniale : automatiser les certificats TLS avec cert-manager. Je me disais que ça ne pouvait pas être si compliqué.

Je dis ça à chaque fois.


Le contexte (de plus en plus court, j'ai appris)

À ce stade de la série :

  • Des clusters Kubernetes guests tournent dans un cluster hôte via KubeVirt + k0smotron
  • Chaque cluster guest a un ingress Contour avec une IP physique (10.2.6.220/29)
  • Prometheus + Grafana sont exposés via cet ingress
  • Tout est accessible en HTTP

Ce dernier point est un problème. HTTP, c'est bien pour le développement. Pour la production, on veut HTTPS. Et pour HTTPS, il faut des certificats. Et pour les certificats, il y a cert-manager.

Plan :

  1. Déployer cert-manager dans le cluster guest
  2. Créer un ClusterIssuer Let's Encrypt
  3. Annoter les Ingress avec cert-manager.io/cluster-issuer
  4. Profiter

Spoiler : le problème arrive dès l'étape 1.


Acte 1 — cert-manager s'installe... enfin presque

On se connecte au cluster guest et on installe cert-manager dans le cluster guest — pas dans le cluster hôte :

export KUBECONFIG=.kube/guest-01.conf

helm repo add jetstack https://charts.jetstack.io && helm repo update
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true

Les pods démarrent bien :

kubectl get pods -n cert-manager
cert-manager-xxxxxxxx-xxxxx            1/1   Running
cert-manager-cainjector-xxxxx-xxxxx    1/1   Running
cert-manager-webhook-xxxxx-xxxxx       1/1   Running

Trois pods Running. Pas de CrashLoop. Pas d'erreur de probe. Cert-manager ne tourne pas en hostNetwork: true, ses probes ciblent localhost nativement — pour une fois, rien à patcher.

Mais Helm ne revient pas. Il attend. 30 secondes. 60 secondes. Et finalement :

Error: INSTALLATION FAILED: failed post-install step: ...
context deadline exceeded

Le job cert-manager-startupapicheck (un hook Helm post-install) a échoué. Ses logs :

"Not ready" err="Internal error occurred: failed calling webhook
  "webhook.cert-manager.io": failed to call webhook:
  Post "https://cert-manager-webhook.cert-manager.svc:443/mutate?timeout=30s":
  context deadline exceeded"

Le problème ne s'est pas invité à l'étape 3. Il était là dès l'étape 1.


Acte 2 — Le webhook bloque tout, dès le premier apply

Helm est planté. On essaie quand même d'avancer manuellement. On crée un ClusterIssuer self-signed — la ressource cert-manager la plus simple, zéro dépendance externe :

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
kubectl apply -f selfsigned-issuer.yaml
Error from server (InternalError): error when creating "selfsigned-issuer.yaml":
  Internal error occurred: failed calling webhook "webhook.cert-manager.io":
  failed to call webhook:
  Post "https://cert-manager-webhook.cert-manager.svc:443/validate?timeout=30s":
  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Même erreur. Le webhook bloque toutes les ressources cert-manager — pas seulement les certificats, les issuers aussi. On ne peut rien créer.

Explication : c'est le même problème architectural que metrics-server et node-exporter, sous une autre forme. L'apiserver du cluster guest (le pod kmc-cluster-guest-01-0) tourne dans le cluster hôte. Quand il doit valider une ressource cert-manager, il appelle le webhook — exposé via une ClusterIP dans le réseau du cluster guest. Le pod kmc ne peut pas joindre cette ClusterIP.

kubectl apply ClusterIssuer
    ↓
guest apiserver (kmc — cluster HÔTE)
    ↓
POST https://cert-manager-webhook.cert-manager.svc:443/validate
    ↓
résolution : ClusterIP cluster GUEST (10.96.x.x)
    ↓
FAIL — non routable depuis le réseau du cluster hôte
    ↓
context deadline exceeded

Ce n'est pas un bug cert-manager — le webhook de validation est un mécanisme de sécurité normal. C'est l'architecture nested qui rend ce webhook injoignable. C'est le troisième composant qu'on voit tomber pour la même raison : metrics-server (kube-aggregation), node-exporter (probes), cert-manager (webhook). La liste s'allonge.


Acte 3 — Les solutions

Solution 1 — Contourner le webhook (rapide, deux étapes)

--set webhook.enabled=false n'existe plus dans les versions récentes du chart — le schema le rejette :

Error: values don't meet the specifications of the schema(s):
- at '/webhook': additional properties 'enabled' not allowed

La bonne approche en deux étapes :

Étape 1 — Désactiver le startupapicheck pour que helm install termine :

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true \
  --set startupapicheck.enabled=false

Le webhook est toujours là, mais Helm ne bloque plus sur le check post-install.

Étape 2 — Passer le webhook en failurePolicy: Ignore et réduire son timeout :

for WHC in validatingwebhookconfiguration mutatingwebhookconfiguration; do
  kubectl patch ${WHC} cert-manager-webhook \
    --type=json \
    -p='[
      {"op":"replace","path":"/webhooks/0/failurePolicy","value":"Ignore"},
      {"op":"replace","path":"/webhooks/0/timeoutSeconds","value":5}
    ]'
done

Avec failurePolicy: Ignore, l'apiserver tente d'appeler le webhook, attend le timeout, puis laisse passer la requête. Sans réduire timeoutSeconds, chaque kubectl apply prend 30 secondes (le timeout par défaut) avant de réussir. En le passant à 5 secondes, c'est plus supportable — même si toujours pas instantané.

kubectl apply -f selfsigned-issuer.yaml
# clusterissuer.cert-manager.io/selfsigned created
# (5 secondes de délai — le webhook essaie, timeout, ignore)

Avantage : cert-manager reste dans un état proche de la normale. Inconvénient : chaque apply de ressource cert-manager prend timeoutSeconds avant de passer. Pour des opérations automatisées (GitOps), c'est acceptable. Pour le développement interactif, c'est pénible.

Solution 2 — Exposer le webhook via NodePort (propre)

Le pod kmc est dans le cluster hôte. Les workers du cluster guest sont des pods virt-launcher dans le cluster hôte. Ces pods ont des IPs dans le CIDR du cluster hôte (10.244.x.x), joignables depuis kmc.

On peut donc exposer le webhook en NodePort sur les workers, et reconfigurer le ValidatingWebhookConfiguration pour cibler directement l'IP d'un worker + le NodePort :

# Exposer le webhook en NodePort
kubectl patch svc cert-manager-webhook -n cert-manager \
  --type=merge -p '{"spec":{"type":"NodePort"}}'

# Récupérer le NodePort et l'IP d'un worker
NODEPORT=$(kubectl get svc cert-manager-webhook -n cert-manager \
  -o jsonpath='{.spec.ports[0].nodePort}')
WORKER_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')

# Reconfigurer les webhooks pour utiliser l'URL directe
kubectl patch validatingwebhookconfiguration cert-manager-webhook \
  --type=json -p="[
    {\"op\": \"replace\",
     \"path\": \"/webhooks/0/clientConfig\",
     \"value\": {\"url\": \"https://${WORKER_IP}:${NODEPORT}/validate-cert-manager-io-v1-certificate\",
                 \"caBundle\": \"$(kubectl get secret cert-manager-webhook-ca -n cert-manager -o jsonpath='{.data.tls\.crt}')\"}}
  ]"

C'est fonctionnel mais fragile — si le node tombe, l'URL pointe dans le vide.

Solution 3 — CA interne + cert-manager en mode self-signed (recommandée)

Pour un cluster privé non accessible depuis Internet, Let's Encrypt (HTTP-01) ne fonctionnera pas de toute façon — l'ACME challenge requiert que les serveurs de Let's Encrypt puissent joindre http://votre-domaine/.well-known/acme-challenge/xxx. Ce n'est pas le cas sur 10.2.6.220.

La bonne approche pour un cluster interne : une CA auto-gérée.

# 1. ClusterIssuer self-signed pour bootstrapper la CA
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
---
# 2. Certificat CA racine
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: guest-01-internal-ca
  secretName: internal-ca-secret
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned
    kind: ClusterIssuer
---
# 3. ClusterIssuer basé sur la CA
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-secret

Une fois la CA en place, les Certificate sont créés localement par cert-manager, sans aucun appel sortant. Pas de webhook externe, pas de ACME, pas de problème réseau.

# Certificat Grafana
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: grafana-tls
  namespace: monitoring
spec:
  secretName: grafana-tls
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer
  dnsNames:
    - grafana.guest-01.mon-domaine.fr

Et l'ingress Contour :

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: grafana
  namespace: monitoring
  annotations:
    cert-manager.io/cluster-issuer: internal-ca
spec:
  tls:
    - hosts:
        - grafana.guest-01.mon-domaine.fr
      secretName: grafana-tls
  rules:
    - host: grafana.guest-01.mon-domaine.fr
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: grafana
                port:
                  number: 3000

Résultat : Grafana accessible en HTTPS, certificat signé par la CA interne. Les navigateurs afficheront un avertissement (CA non reconnue publiquement) — on peut distribuer le certificat racine aux machines de l'équipe pour supprimer cet avertissement.


Acte 4 — Et Let's Encrypt alors ?

Si le domaine est public et que le cluster guest est accessible depuis Internet (via le VIP 10.2.6.220 routé publiquement), HTTP-01 fonctionne. Sinon, DNS-01 est la seule option — on délègue la preuve de propriété du domaine à un enregistrement DNS TXT, sans que l'ACME server ait besoin de joindre le cluster.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@mon-domaine.fr
    privateKeySecretRef:
      name: letsencrypt-account-key
    solvers:
      - dns01:
          cloudflare:              # adapter selon le provider DNS
            email: admin@mon-domaine.fr
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

DNS-01 est compatible avec les clusters privés. La preuve de propriété se fait uniquement via DNS — cert-manager crée un enregistrement TXT temporaire, Let's Encrypt le vérifie, le certificat est émis.

L'inconvénient : il faut que le provider DNS supporte l'API (Cloudflare, Route53, OVH, Scaleway, Gandi...). Si le DNS est géré manuellement ou sur un provider non supporté, c'est compliqué.


Ce que j'aurais voulu savoir avant (épisode 4)

1. Le webhook cert-manager a le même problème que metrics-server et les autres. L'apiserver du cluster nested ne peut pas joindre les webhooks enregistrés dans le cluster guest. Soit on désactive le webhook, soit on l'expose via NodePort sur les workers (IPs joignables depuis kmc), soit on contourne en utilisant des issuers qui n'en ont pas besoin (selfSigned, CA).

2. HTTP-01 ne fonctionne pas sur un cluster privé. Let's Encrypt doit pouvoir joindre le cluster. Si l'IP n'est pas publique, il faut DNS-01 ou une CA interne.

3. Une CA interne est souvent la meilleure solution pour un cluster privé. Pas de dépendance externe, pas de quota ACME, certificats émis en quelques secondes, zéro appel sortant. Le seul coût : distribuer la CA aux clients qui doivent faire confiance aux certificats.

4. cert-manager peut tourner sans webhook. --set webhook.enabled=false. Moins de validation, mais ça fonctionne si les ressources sont correctes. Acceptable pour un cluster de développement.


Et maintenant ?

Le cluster guest a désormais :

  • Un control plane k0s en pod sur le cluster hôte : OK
  • Des workers en VMs KubeVirt avec Cilium : OK
  • Un ingress Contour accessible via IP physique : OK
  • Prometheus + Grafana exposés via cet ingress : OK
  • Certificats TLS via CA interne (cert-manager) : OK
  • kubectl top — et on s'en accommode toujours très bien : KO

Pour le prochain cluster, tout ça se déploie via Helm + le chart kubevirt-cluster. La plateforme devient un produit.


La prochaine étape : gérer le multi-tenant — plusieurs équipes, plusieurs clusters, isolation réseau et RBAC. Je me dis que ça ne peut pas être si compliqué.

Je dis ça à chaque fois. Et pourtant, j'continue.


Ce sujet vous intéresse ? Clusters multi-tenant, KubeVirt, plateformes Kubernetes as a Service, automatisation de bootstrap ? N'hésitez pas à me contacter directement.

Hervé Leclerc — Alterway


#Kubernetes #KubeVirt #CertManager #TLS #HTTPS #CloudNative #Infrastructure #GitOps #CAPI #RetourDExperience

Découvrez les technologies d'alter way