❯ ai · Running

Déployer un agent IA dans le cluster qu'il gère Deploying an AI Agent Inside the Cluster It Manages

Comment je fais tourner Claude Code en pod sur mon homelab k3s — OAuth, SSH vers le master, workspace persistant, et l'absurdité délicieuse d'un agent qui édite son propre déploiement. How I run Claude Code as a pod on my k3s homelab — OAuth, SSH to master, persistent workspace, and the delightful absurdity of an agent that edits its own deployment.

· 9 min de lecture 9 min read ·
k3s-clusteraiclaude-codek8soauthself-host
🇬🇧 Read this article in English

🗄️ Projet archivé — juin 2026. L’app claude-webui a été décommissionnée (projet abandonné) et entièrement retirée du cluster. Cet article est conservé à titre historique.

Il y a quelque chose d’intrinsèquement comique dans le fait de demander à un agent IA d’ajouter un service Kubernetes, de le voir éditer les YAML du repo, pusher sur actual-state, et regarder ArgoCD déployer le résultat — sachant que cet agent tourne lui-même en pod sur ce même cluster. Il se déploie, au sens propre, lui-même.

C’est exactement la situation dans laquelle je me trouve depuis que j’ai mis en service claude-webui sur mon homelab k3s. Voici comment ça marche, ce que ça a fallu bricoler, et pourquoi c’est à la fois une bonne idée et légèrement inquiétant.


L’app : claude-code-webui

claude-code-webui est un wrapper web open-source autour de Claude Code, le CLI Anthropic. Il expose une interface navigateur qui lance des sessions Claude Code dans un répertoire de travail, transmet les prompts en streaming, et affiche les outils utilisés en temps réel. Pas d’API custom, pas de prompt engineering — c’est littéralement claude-code-webui --host 0.0.0.0 --port 8080 qui démarre le CLI en sous-process.

L’image tourne sur ghcr.io/yellowbeanie/claude-webui (privée, buildée via GitHub Actions sur ARM64 pour les nœuds du cluster). L’interface est exposée à claude.olig-homelab-2025.win, derrière un Cloudflare Tunnel et une politique Cloudflare Access restreinte à mon adresse Gmail. Sans ça, n’importe qui atteignant l’URL aurait un shell Claude Code sur le repo. Non négociable.

L’app ArgoCD qui orchestre tout ça est minimaliste :

# argocd/apps/claude-webui.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: claude-webui
  namespace: argocd
spec:
  source:
    repoURL: [email protected]:YellowBeanie/k3s-homelab.git
    targetRevision: actual-state
    path: claude-webui
  destination:
    server: https://kubernetes.default.svc
    namespace: claude-webui
  syncPolicy:
    automated:
      prune: false
      selfHeal: true
    syncOptions:
    - ServerSideApply=true

prune: false est délibéré — je ne veux pas qu’ArgoCD supprime des ressources si Claude pousse un manifest mal formé. La boucle de feedback est déjà assez tendue comme ça.


OAuth Pro : le token Anthropic dans un SealedSecret

Claude Code s’authentifie via un token Anthropic stocké dans ~/.claude/.credentials.json. Pour un pod éphémère, cette donnée doit être injectée de l’extérieur — et vu que le repo est public, elle doit être chiffrée.

La solution : un SealedSecret. Le fichier .credentials.json est scellé avec le certificat public du contrôleur Sealed Secrets du cluster, et le chiffré est commis dans le repo en toute sécurité. Seul le contrôleur peut le déchiffrer.

# Sceller les credentials OAuth depuis la machine locale
kubectl create secret generic claude-webui-oauth-creds \
  --namespace claude-webui \
  --from-file=.credentials.json="$HOME/.claude/.credentials.json" \
  --dry-run=client -o yaml \
  | kubeseal \
    --cert docs/sealed-secrets/sealed-secrets-pub.pem \
    --format yaml \
  > claude-webui/sealedsecret-oauth-creds.yaml

Le résultat est un blob chiffré inoffensif qui part dans le repo :

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: claude-webui-oauth-creds
  namespace: claude-webui
spec:
  encryptedData:
    .credentials.json: AgANgjWsBAA7ZC/NIkSH...  # ~3 KB de chiffrement RSA-OAEP

Piège numéro un : Claude Code rafraîchit son token automatiquement et réécrit .credentials.json à chaque refresh. Si le fichier est monté directement depuis un Secret Kubernetes (read-only), le refresh échoue et l’agent se retrouve déconnecté en plein milieu d’un changement. La solution est d’utiliser le Secret uniquement comme graine initiale, via un init container qui copie le fichier sur un PVC si et seulement s’il n’existe pas encore. Le token live réside ensuite sur le volume persistant, pas dans le Secret.

La rotation se fait en deux commandes : claude logout && claude login localement, puis rescellage du nouveau .credentials.json. C’est arrivé plus d’une fois dans l’historique du repo — une fois parce que j’avais oublié que l’ancien token était encore référencé dans un chat et s’était retrouvé exposé. Rotation préventive.


Persistance : deux PVCs Longhorn

Sans persistance, chaque redémarrage du pod repart de zéro : pas d’historique de conversations, pas de contexte projet, le token OAuth potentiellement perdu. Deux PVCs Longhorn couvrent les deux besoins distincts :

# 1 Go pour ~/.claude — credentials, projets, historique
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claude-webui-home
  namespace: claude-webui
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 1Gi
  storageClassName: standard-storage

---

# 5 Go pour /workspace — le clone du repo k3s-homelab
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claude-webui-workspace
  namespace: claude-webui
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 5Gi
  storageClassName: standard-storage

Piège numéro deux : claude-code-webui lit ~/.claude.json (l’index des projets Claude) à chaque requête /projects et plante avec ENOENT si le fichier est absent. Il faut l’initialiser avant le premier démarrage du container principal. Et ce n’est pas tout.

Piège numéro trois : l’interface web filtre les projets affichés à ceux dont il existe un dossier correspondant sous ~/.claude/projects/<encoded>/. L’encodage transforme les /, \, :, . et _ en tirets. Le chemin /workspace/k3s-homelab devient donc -workspace-k3s-homelab. Si ce dossier n’existe pas, le projet n’apparaît pas dans l’UI — même si claude.json le référence correctement (commits 9a0c2dc et 7196a52).

Ces deux problèmes sont résolus dans l’init container seed-claude-creds :

# Extrait de l'init container seed-claude-creds
if [ ! -f /home/node/.claude/claude.json ]; then
  echo '{"projects":{"/workspace/k3s-homelab":{}}}' \
    > /home/node/.claude/claude.json
fi
mkdir -p /home/node/.claude/projects/-workspace-k3s-homelab

Et dans le container principal, ~/.claude.json est un symlink vers la copie persistante sur le PVC :

ln -sf /home/node/.claude/claude.json /home/node/.claude.json
exec claude-code-webui --host 0.0.0.0 --port 8080

SSH vers le master : kubectl sans ServiceAccount

Pour qu’un agent qui édite des YAML soit vraiment utile, il doit aussi pouvoir observer l’état du cluster — vérifier si un pod est en CrashLoopBackOff, inspecter des logs, valider qu’un déploiement a réussi. Deux options :

  1. Un ServiceAccount Kubernetes avec un Role RBAC exhaustif.
  2. Une clé SSH vers [email protected] (master-1) et sudo kubectl.

J’ai choisi la seconde. La raison honnête : écrire un Role RBAC couvrant tous les verbes dont Claude pourrait avoir besoin est un travail de longue haleine, et dans un homelab perso mono-utilisateur, le gain en isolation ne compense pas le coût de maintenance. Le risque est sciemment accepté.

La clé SSH (claude-cluster-runner@claude-webui, ed25519) est scellée comme les autres :

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: claude-webui-master-ssh-key
  namespace: claude-webui
spec:
  encryptedData:
    id_ed25519: AgCztnTzUzFYl8yX...  # clé ed25519 chiffrée

La configuration SSH dans un ConfigMap mappe l’alias master-1 vers l’IP réelle et l’identité correcte :

# configmap-ssh.yaml
data:
  config: |
    Host master-1
      HostName 10.0.0.11
      User admin
      IdentityFile /home/node/.ssh/id_master_ed25519
      IdentitiesOnly yes
      StrictHostKeyChecking yes
      UserKnownHostsFile /home/node/.ssh/known_hosts

L’init container clone-repo installe les deux clés (GitHub + master) dans un emptyDir monté en ~/.ssh, avec les permissions 0600 requises. Le container principal monte ce même répertoire en lecture seule — une précaution pour éviter qu’une session Claude Code rogue n’écrase ou n’exfiltre les clés via l’interface web.

Depuis l’UI, Claude peut désormais faire :

ssh master-1 sudo kubectl -n argocd get apps
ssh master-1 sudo kubectl -n monitoring logs -l app=grafana --tail=50

Deploy key GitHub : pusher sans token humain

Pour que Claude puisse commiter et pusher les manifests modifiés sur actual-state, une deploy key avec accès en écriture est enregistrée sur le repo YellowBeanie/k3s-homelab. Elle est scellée et injectée de la même manière que la clé master.

L’init container clone-repo fait un clone (ou un git reset --hard origin/actual-state) à chaque démarrage du pod. Le reset dur est intentionnel : le workspace repart toujours d’un état propre aligné sur GitHub, même si Claude avait laissé des fichiers non commités lors du restart précédent.

Les variables d’environnement Git dans le Deployment signent les commits avec une identité reconnaissable :

env:
- name: GIT_AUTHOR_NAME
  value: "Claude (homelab)"
- name: GIT_AUTHOR_EMAIL
  value: "[email protected]"

Le Deployment complet

Pour avoir une vue d’ensemble, voici le Deployment avec ses volumes (légèrement abrégé) :

apiVersion: apps/v1
kind: Deployment
metadata:
  name: claude-webui
  namespace: claude-webui
spec:
  replicas: 1
  strategy:
    type: Recreate          # pas de rolling update : ReadWriteOnce sur les PVCs
  template:
    spec:
      imagePullSecrets:
      - name: claude-webui-ghcr   # GHCR privé
      securityContext:
        fsGroup: 1000
        runAsUser: 1000
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault

      initContainers:
      - name: seed-claude-creds   # graine OAuth + claude.json + dossier encodé
        ...
      - name: clone-repo          # installe les clés SSH, clone/reset le repo
        ...

      containers:
      - name: claude-webui
        image: ghcr.io/yellowbeanie/claude-webui:latest
        command: ["/usr/bin/tini", "--"]
        args:
        - /bin/sh
        - -c
        - |
          ln -sf /home/node/.claude/claude.json /home/node/.claude.json
          exec claude-code-webui --host 0.0.0.0 --port 8080
        resources:
          requests: {cpu: 250m, memory: 512Mi}
          limits: {cpu: "2", memory: 2Gi}
        volumeMounts:
        - name: workspace
          mountPath: /workspace
        - name: claude-home
          mountPath: /home/node/.claude
        - name: ssh-dir
          mountPath: /home/node/.ssh
          readOnly: true     # clés SSH montées en lecture seule au runtime

      volumes:
      - name: workspace
        persistentVolumeClaim:
          claimName: claude-webui-workspace    # 5 Gi, repo k3s-homelab
      - name: claude-home
        persistentVolumeClaim:
          claimName: claude-webui-home         # 1 Gi, ~/.claude
      - name: ssh-dir
        emptyDir: {}                           # rempli par l'init container
      - name: claude-creds-secret
        secret:
          secretName: claude-webui-oauth-creds
      - name: deploy-key-secret
        secret:
          secretName: claude-webui-deploy-key
      - name: master-key-secret
        secret:
          secretName: claude-webui-master-ssh-key

GHCR privé

L’image ghcr.io/yellowbeanie/claude-webui est privée (elle embarque un Dockerfile custom pour ajuster les chemins et dépendances sur ARM64). Le pattern d’accès est le même que pour les autres namespaces du cluster : un SealedSecret claude-webui-ghcr de type kubernetes.io/dockerconfigjson, référencé dans imagePullSecrets. L’image est épinglée par digest SHA256 via le workflow GitHub Actions qui commite automatiquement le digest après chaque build.


Risques et garde-fous

Faire tourner un agent IA avec un accès en écriture au repo d’infra et un sudo sur le master, c’est une décision qui mérite d’être documentée honnêtement.

Les risques concrets que j’ai rencontrés :

  • Boucles de retry. Claude n’a pas de notion de “backoff”. Si un kubectl apply échoue sur un manifest mal formé, il peut enchaîner les tentatives sans s’arrêter. La solution : branch protections sur main (les merges requièrent une review humaine), et je surveille les notifications GitHub pour les pushes trop fréquents sur actual-state.

  • Token OAuth qui expire. Le refresh automatique fonctionne tant que le processus tourne. Après un redémarrage du pod, si le token sur le PVC est périmé, l’agent démarre mais échoue à la première requête à l’API Anthropic. Symptôme : l’UI affiche une erreur 401 en streaming. Remède : reseal.

  • L’agent qui touche à ses propres manifests. C’est possible — et ça s’est produit. Claude a modifié deployment.yaml sur instruction pour ajouter une variable d’environnement. ArgoCD a déployé la modification. Le pod a redémarré avec la nouvelle config. Tout s’est bien passé. Mais ça veut dire qu’une instruction mal formulée peut provoquer un redémarrage du pod en cours de conversation. Prévoir de reformuler.

Les garde-fous en place :

  • Cloudflare Access sur l’ingress : seul mon Gmail peut ouvrir l’UI.
  • Branch protections sur main : pas de push direct, les PRs critiques passent par une review humaine.
  • prune: false dans la politique ArgoCD : ArgoCD ne supprime pas de ressources si le manifest disparaît.
  • Clés SSH montées en lecture seule dans le container runtime.
  • automountServiceAccountToken: false : le pod n’a pas de token ServiceAccount en mémoire.

L’effet en pratique

Depuis la mise en service, environ 80% des changements d’infrastructure du repo passent par l’interface Claude. Les cas d’usage les plus fréquents : ajouter une annotation, ajuster une ressource requests/limits, investiguer un pod en erreur, rédiger un SealedSecret pour un nouveau namespace, écrire un article de blog sur le déploiement d’agents IA dans des clusters Kubernetes.

Ce dernier point n’est pas une blague. Une partie de cet article a été rédigée depuis l’interface claude.olig-homelab-2025.win, dans le workspace /workspace/k3s-homelab, par un pod qui tourne sur le cluster décrit dans l’article.

La mise en abyme s’arrête là. Pour l’instant.


Voir aussi

Auditer la sécurité de mon homelab avec un agent IA (Claude Fable 5) — même logique de garde-fous (preuves, barrières, retour arrière) appliquée cette fois à un audit de sécurité complet du cluster.

k3s-clusteraiclaude-codek8soauthself-host