Nota Bene

Bevor irgendwelche Arbeiten, die den etcd betreffen, gemacht werden, ist es unabdingbar, von diesem Backups anzufertigen. Generell ist das mehr als empfehlenswert, wenn man einen Kubernetescluster betreibt, und zwar regelmässig und automatisiert. Mit der Funktion und Administration von etcd sollte man sich ebenso vertraut machen. Nun, da wir diese Vorkehrungen getroffen haben, können wir uns an die eigentliche Arbeit machen.

Für und Wider

Diverse Regelwerke schreiben die Verschlüsselung von geheimen Daten vor. Man könnte deswegen der Meinung sein, dass man auch die Geheminisse in seinem etcd verschlüsseln sollte. Oder man könnte der Meinung sein, dass ein potenzieller Angreifer bereits irgendeine Art von Zugriff auf das Betriebssystem, auf dem der etcd-Prozess läuft, haben muss (vorausgesetzt man erlaubt nur die notwendigen Netzwerkverbindungen). Dass es deswegen wenig nützt zusätzliche Geheimniskrämerei für Dinge zu betreiben, die Anwendungen im Cluster notwendigerweise im Klartext lesen können müssen, solange man seine Datenträger selbst verschlüsselt (hier ein gut geschriebener Artikel, der das Sicherheitsthema etwas mehr ausleuchtet). Aber am Ende kann es einem trotzdem passieren, dass man einen Cluster verwalten muss, in dem die Entscheidung fiel, die erstere Herangehensweise zu wählen.

Hintergrund und Funktionsweise

Wenn in unserem Kubernetescluster Daten im etcd verschlüsselt gespeichert werden sollen, braucht der API-Server ein KMS-Plugin (Key Management Service) beim Lesen und Schreiben im etcd. Der KMS kümmert sich um die Entschlüsselung des Geheimtextes und gibt dem API-Server den Klartext zurück. Kubernetes hat in Version 1.29 die KMS-Version 1 abgekündigt, weshalb letztens eine Migration anstand. Hier gibt es nun also die Schritte, falls jemand von einer Version auf die andere wechselt, oder von einem KMS auf den anderen (in den Beispielen wird es OpenStack Barbican sein).

Wenn unser KMS die Einträge im etcd verschlüsselt, stellt es dem chiffrierten Text das Verschlüsselungsschema voran. Der API-Server nutzt diese Information, um den richtigen KMS zur Entschlüsselung zu verwenden. Hier als Beispiel einmal der Anfang eines Eintrages aus dem etcd:

k8s:enc:kms:v1:barbican_v1

Ich habe mir eine leichte Doppelung bei der Versionsangabe erlaubt (der letzte Teil dieses Präfixes ist abhängig vom Namen des jeweiligen KMS-Plugins, welcher frei gewählt werden kann), damit die Konfigurationsbeispiele nachher klarer sind. Ein anderer Eintrag nach dem Wechsel auf KMS Version 2 könnte dann so aussehen:

k8s:enc:kms:v2:barbican_v2

Die etcdctl-Kommandos, mit denen man sich diese Einträge anschauen kann sind etwas weiter unten vermerkt.

Da der Name des Plugins Teil des Präfixes ist, sollte also sichergestellt sein, dass für jeden Eintrag im etcd immer ein passend benanntes KMS-Plugin verfügbar ist. Ansonsten wird unser API-Server beim Lesen einen Fehler zurückgeben, und das gilt es zu vermeiden. Aus diesem Grund ist es auch nicht empfehlenswert, den Namen eines KMS Plugins in /etc/kubernetes/encryptionconfig.yaml zu verändern, solange Ressourcen mit diesem Präfix im etcd verschlüsselt sind, weil diese dann nicht mehr lesbar sein werden. Die Reihenfolge der Plugins ist auch relevant, dazu später mehr. Ebenso ist es eine schlechte Idee, zwei KMS-Plugins unter demselben Namen zu verwenden (wie soll der API-Server entscheiden, welches er verwenden soll?).

Hier einmal ein Beispiel, wie diese Datei aussehen kann (siehe auch die offizielle Dokumentation)

kind: EncryptionConfig
apiVersion: v1
resources:
  - resources:
      - secrets
      - applications.argoproj.io
      - ingresses.networking.k8s.io
    providers:
      - kms:
          name: barbican_v1
          endpoint: unix:///var/lib/kms/kms_v1.sock
          cachesize: 65536

Wie wir sehen, wird beschrieben, welche Ressourcen verschlüsselt abgelegt werden sollen, und welche KMS-Provider dafür genutzt werden sollen. Es folgt die Methode, die uns Scherereien mit den oben genannten Themen ersparen wird.

Überblick

Folgende Schritte werden durchlaufen werden:

  • KMS v1 und v2 werden parallel betrieben
  • Die Präferenz für Verschlüsselung wird auf v2 umgestellt
  • Alle verschlüsselten Ressourcen werden neu verschlüsselt
  • KMS v2 wird alleine betrieben

Der API-Server nutzt die KMS-Plugins in der Reihenfolge, wie sie in der /etc/kubernetes/encryptionconfig.yaml erscheinen, das gilt es zu nutzen und zu beachten, wenn man von einem KMS-Plugin auf das Andere wechseln will oder Daten neu verschlüsseln möchte. Sollte ein Plugin nicht erreichbar sein, oder einen Fehler liefern, wird das Nächste verwendet, und so weiter.

KMS v1 und v2 Parallel

Das KMS-Plugin für beide Versionen muss für den API-Server erreichbar sein, beispielsweise über einen Socket. Anschliessend tragen wir das neue KMS-Plugin in unserer /etc/kubernetes/encryptionconfig.yaml ein.

kind: EncryptionConfig
apiVersion: v1
resources:
  - resources:
      - secrets
      - applications.argoproj.io
      - ingresses.networking.k8s.io
    providers:
      - kms:
          name: barbican_v1
          endpoint: unix:///var/lib/kms/kms_v1.sock
          cachesize: 65536
      - kms:
          name: barbican_v2
          apiVersion: v2
          endpoint: unix:///var/lib/kms/kms.sock

Wie oben bereits gesagt, befindet sich das neue Plugin unterhalb dem Alten, damit unser API-Server nicht anfängt Geheimnisse mit diesem zu verschlüsseln, was anderen API-Servern, die es noch nicht kennen, Probleme bereiten würde. Im Anschluss starten wir den API-Server neu, um die neue Konfiguration zu laden.

Präferenz für Verschlüsselung umstellen

Sobald alle API-Server das neue KMS-Plugin kennen, können wir Die neue KMS-Plugin-Version wird jetzt oberhalb eingetragen

kind: EncryptionConfig
apiVersion: v1
resources:
  - resources:
      - secrets
      - applications.argoproj.io
      - ingresses.networking.k8s.io
    providers:
      - kms:
          name: barbican_v2
          apiVersion: v2
          endpoint: unix:///var/lib/kms/kms.sock
      - kms:
          name: barbican_v1
          endpoint: unix:///var/lib/kms/kms_v1.sock
          cachesize: 65536

Und abermals starten wir alle API-Server neu.

Neuverschlüsselung aller verschlüsselten Ressourcen

Um das Verschlüsselungsschema auf die neue KMS-Version umzustellen, erstellen wir alle verschlüsselten Ressourcen neu. Die Kubernetes-Doku empfiehlt folgendes, simples Kommando:

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

Wer etwas mehr Kontrolle haben möchte, kann auch z.B. folgendes Skript nutzen:

Klicken zum Ausklappen...

#!/bin/bash

# Copyright 2024 Leon Welchert
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


set -euo pipefail

INTERVAL=3
ALLNS='false'
DRYRUN='false'
unset -v NAMESPACE
unset -v KIND
unset -v CONTEXT
unset -v nsarray


Help()
{
   # Display Help
   echo "This script recreates all kubernetes resources of a given type in a given namespace or clusterwide"
   echo
   echo "Syntax Examples:"
   echo "         k8s-resource-recreate.sh [OPTIONS...] -n NAMESPACE -k KIND"
   echo "         k8s-resource-recreate.sh [OPTIONS...] -a -k KIND"
   echo "options:"
   echo "-h               Print this Help."
   echo "-a               Replace in all namespaces."
   echo "-d               Dry-run. Only show which replacements would be performed."
   echo "-k KIND          Kind of kubernetes resource to replace."
   echo "-c CONTEXT       Context from your ~/.kube/config to work in (defaults to currently active one)."
   echo "-n NAMESPACE     Namespace to replace resources in."
   echo "-i INTERVAL      Time to wait between namespaces (uses gnu sleep, defaults to 3s)."
   echo
}

HandleOptions()
{
  # only short options 
  # a colon in the first position of the opt string will suppress errors
  # upon invalid options, we don't want that
  while getopts 'hadk:c:n:i:' option; do
    case $option in
      h) # display help
        Help
      exit;;
      a) # run in all namespaces
        ALLNS='true'
        #echo "$option: All Namespaces is $ALLNS" 
      ;;
      d) # dry run
        DRYRUN='true'
        #echo "$option: Dryrun is $DRYRUN" 
      ;;
      k) # kind of resource to replace
        KIND=${OPTARG}
        #echo "$option: Kind is $KIND" 
      ;;
      c) # kubectl context to use
        CONTEXT=${OPTARG}
        #echo "$option: Context is $CONTEXT" 
      ;;
      n) # namespace to run in
        NAMESPACE=${OPTARG}
        #echo "$option: Namespace is $NAMESPACE" 
      ;;
      i) # time interval between namespaces
        INTERVAL=${OPTARG}
        #echo "$option: Interval is $INTERVAL" 
      ;;
      \?) # Invalid option recognised by getopts
        echo "Error: Invalid option"
        Help
      exit;;
    esac
  done
  # if the user passes no options, display help
  if [[ $OPTIND -eq 1 ]]; then
    Help
    exit
  fi
}

GetContext()
{
  # allow unset CONTEXT variable
  set +u
  if [[ -z "$CONTEXT" ]]; then
    set -u
    CONTEXT=$(kubectl config current-context)
    echo Context was not set, defaulting to $CONTEXT
  else
    set -u
    echo Context was set to $CONTEXT
  fi
}

GetNamespaces()
{
  if [[ "$ALLNS" == 'true' ]]; then
    echo "Working in all namespaces"
    nsarray=( $(kubectl --context $CONTEXT get namespaces -o jsonpath='{.items[*].metadata.name}') )
  else 
    echo "Working in a specified namespace"
    nsarray=($NAMESPACE)
  fi

  echo "The following namespaces will be worked on"
  for ns in ${nsarray[@]}; do
    echo -n "$ns "
  done
}

ReplaceResources()
{
  if [[ "$DRYRUN" == 'true' ]]; then
    for ns in ${nsarray[@]}; do
      echo "Working on namespace $ns"
      kubectl --context $CONTEXT -n $ns get $KIND -o json | kubectl --dry-run=server --context $CONTEXT replace -f -
    done
  else
    for ns in ${nsarray[@]}; do
      echo "Working on namespace $ns"
      kubectl --context $CONTEXT -n $ns get $KIND -o json | kubectl --context $CONTEXT replace -f -
    done
  fi
}


# main part of the script
HandleOptions $@
GetContext
GetNamespaces
echo "Waiting for 10 seconds before beginning replacement"
sleep 10
ReplaceResources
echo "Replacement Finished!"

Um sicherzustellen, dass alle mit der alten KMS-Version verschlüsselten Ressourcen ersetzt wurden, können wir im etcd nachschauen, ob dem wirklich so ist:

# Je nachdem, wie der etcd konfiguriert ist, kann sich der Authentifizierungsweg unterscheiden
# Um die neu verschlüsselten Geheimnisse zu sehen, suchen wir entsprechend nach kms:v2
etcdctl --cacert /etc/kubernetes/pki/etcd/ca.crt \
        --key /etc/kubernetes/pki/etcd/server.key \
        --cert /etc/kubernetes/pki/etcd/server.crt \
        --command-timeout=600s  \
        get --prefix /registry/secrets/ | grep --text kms:v1

Alleiniger Betrieb von KMS v2

Nachdem alle verschlüsselten Ressourcen die neue KMS-Version nutzen, können wir die Alte aus der /etc/kubernetes/encryptionconfig.yaml entfernen. Danach werden die API-Server nacheinander neugestartet und wir sind fertig.

kind: EncryptionConfig
apiVersion: v1
resources:
  - resources:
      - secrets
      - applications.argoproj.io
      - ingresses.networking.k8s.io
    providers:
      - kms:
          name: barbican_v2
          apiVersion: v2
          endpoint: unix:///var/lib/kms/kms.sock

Fehlerbehebung

Falls wir aus welchen Gründen auch immer doch in die Situation geraten, dass wir Einträge im etcd haben, die mit einem nicht (oder falsch) konfigurierten KMS-Provider verschlüsselt sind, haben wir im Grunde zwei Möglichkeiten, um die Situation zu beheben:

  • Die Einträge löschen
  • Den fehlenden/fehlerhaften KMS-Provider nachkonfigurieren und die Daten neu verschlüsseln