Deep-dive : anatomie de mon agent IA Kubernetes
Dans les articles précédents, j’ai présenté l’idée de l’agent IA et ses 45 tools. Mais je n’avais pas vraiment expliqué comment ça marche sous le capot.
Cet article est le deep-dive technique. Le vrai. Celui où on ouvre le moteur et on regarde chaque pièce.
Le pattern Agent + Tools
Le concept est simple : au lieu de coder des workflows rigides (“si l’utilisateur dit X, fais Y”), on donne à Claude une boîte à outils et on le laisse décider quoi utiliser.
Utilisateur: "Déploie un Grafana avec persistance"
↓
Claude analyse le goal
↓
Claude choisit: search_docker_image("grafana")
↓
Résultat → Claude analyse
↓
Claude choisit: create_namespace("grafana-a7b2")
↓
Claude choisit: deploy_custom_app({image: "grafana/grafana:latest", ...})
↓
Claude choisit: create_pvc({size: "10Gi", ...})
↓
Claude choisit: configure_npm_proxy({subdomain: "grafana", ...})
↓
Claude vérifie: kubectl_get({resource: "pods", namespace: "grafana-a7b2"})
↓
Pod running → "Terminé ! Grafana accessible sur https://grafana.sortium.fr"
C’est une boucle Réflexion → Action → Observation qui tourne jusqu’à ce que le goal soit atteint. Le modèle décide tout seul de l’ordre des opérations, gère les erreurs, et s’adapte.
L’interface Tool
Chaque outil implémente une interface simple :
type AgentTool interface {
Name() string
Description() string
InputSchema() ToolInputSchema
Execute(ctx context.Context, params map[string]interface{}) (string, error)
}
4 méthodes. C’est tout. Name et Description sont envoyés à Claude pour qu’il sache quoi utiliser. InputSchema définit les paramètres en JSON Schema. Execute fait le travail.
Exemple concret — un tool pour créer un namespace :
type CreateNamespaceTool struct {
dynamicClient dynamic.Interface
}
func (t *CreateNamespaceTool) Name() string {
return "create_namespace"
}
func (t *CreateNamespaceTool) Description() string {
return "Create a new Kubernetes namespace with AI agent labels"
}
func (t *CreateNamespaceTool) InputSchema() ToolInputSchema {
return ToolInputSchema{
Type: "object",
Properties: map[string]ToolPropertySchema{
"name": {
Type: "string",
Description: "Name of the namespace to create",
},
},
Required: []string{"name"},
}
}
func (t *CreateNamespaceTool) Execute(ctx context.Context, params map[string]interface{}) (string, error) {
name := params["name"].(string)
ns := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": map[string]interface{}{
"name": name,
"labels": map[string]interface{}{
"managed-by": "ai-agent",
},
},
},
}
gvr := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
_, err := t.dynamicClient.Resource(gvr).Create(ctx, ns, metav1.CreateOptions{})
if err != nil {
return "", fmt.Errorf("failed to create namespace: %w", err)
}
return fmt.Sprintf("Namespace '%s' created successfully", name), nil
}
Le label managed-by: ai-agent est important. Il permet de savoir quelles ressources ont été créées par l’agent, utile pour le cleanup.
L’enregistrement des tools
Tous les tools sont instanciés au démarrage et passés à l’agent :
func createAllAITools(
k8sClient *kubernetes.Clientset,
dynamicClient dynamic.Interface,
npmClient *NPMClient,
logger logger.Logger,
) []ai.AgentTool {
return []ai.AgentTool{
// Recherche & Discovery
tools.NewWebSearchTool(logger),
tools.NewSearchDockerImageTool(logger),
tools.NewInspectDockerImageTool(logger),
// Déploiement
tools.NewDeployHelmChartTool(k8sClient, logger),
tools.NewDeployCustomAppTool(k8sClient, logger),
// Kubernetes natif
tools.NewKubectlGetTool(k8sClient, logger),
tools.NewKubectlExecTool(k8sClient, logger),
tools.NewKubectlDeleteTool(k8sClient, logger),
// Configuration
tools.NewCreateNamespaceTool(dynamicClient, logger),
tools.NewCreatePVCTool(k8sClient, logger),
tools.NewCreateSecretTool(k8sClient, logger),
tools.NewConfigureNPMProxyTool(npmClient, logger),
// Troubleshooting
tools.NewAnalyzePodFailureTool(k8sClient, logger),
tools.NewApplyFixTool(k8sClient, logger),
tools.NewGetEventsTool(k8sClient, logger),
// ... 25+ autres
}
}
L’avantage de ce pattern : ajouter un nouveau tool prend 5 minutes. Tu crées un struct, tu implémentes les 4 méthodes, tu l’ajoutes à la liste. L’agent le découvre automatiquement au prochain démarrage.
L’intégration Claude API
La requête
Chaque appel à Claude envoie le system prompt, l’historique de la conversation, et la liste des tools disponibles :
func (a *AutonomousAgent) askClaude(ctx context.Context, systemPrompt string, messages []ClaudeMessage) (*ClaudeResponse, error) {
// Convertir les tools en format Claude
claudeTools := make([]ClaudeTool, len(a.tools))
for i, tool := range a.tools {
claudeTools[i] = ClaudeTool{
Name: tool.Name(),
Description: tool.Description(),
InputSchema: tool.InputSchema(),
}
}
req := ClaudeRequest{
Model: "claude-sonnet-4-5",
MaxTokens: 1024,
System: systemPrompt,
Messages: messages,
Tools: claudeTools,
}
return a.callClaudeAPI(ctx, req)
}
La réponse de Claude contient soit du texte (réflexion), soit un tool_use (action à exécuter), soit les deux. Le stop_reason indique si Claude a fini (end_turn) ou s’il attend le résultat d’un tool (tool_use).
Le traitement des tool calls
Quand Claude retourne un tool_use, on exécute le tool et on renvoie le résultat :
for _, block := range response.Content {
if block.Type == "tool_use" {
// Exécuter le tool
result, err := a.executeTool(block.Name, block.Input)
// Tronquer le résultat pour économiser des tokens
if len(result) > 500 {
half := 250
result = result[:half] + "\n...[truncated]...\n" + result[len(result)-half:]
}
// Renvoyer à Claude
toolResults = append(toolResults, ClaudeContentBlock{
Type: "tool_result",
ToolUseID: block.ID,
Content: result,
IsError: err != nil,
})
}
}
// Ajouter les résultats à la conversation
messages = append(messages, ClaudeMessage{
Role: "user",
Content: toolResults,
})
La troncature à 500 caractères est cruciale. Un kubectl get pods -A peut retourner des kilooctets de données. Envoyer tout ça à Claude coûterait une fortune en tokens et noierait l’information utile.
La boucle autonome
Le coeur du système, c’est une boucle qui tourne jusqu’à ce que le goal soit atteint :
func (a *AutonomousAgent) executeWithClaude(ctx context.Context, execution *AgentExecution, goal string) (*AgentExecution, error) {
systemPrompt := a.buildSystemPrompt()
messages := []ClaudeMessage{
{Role: "user", Content: []ClaudeContentBlock{
{Type: "text", Text: fmt.Sprintf("Goal: %s", goal)},
}},
}
maxIterations := 15
consecutiveThinking := 0
for attempt := 0; attempt < maxIterations; attempt++ {
// Appel Claude
response, err := a.askClaude(ctx, systemPrompt, messages)
if err != nil {
return execution, err
}
// Traiter la réponse (exécuter les tools, ajouter les résultats)
finished, _, hasToolUse, err := a.processClaudeResponse(ctx, execution, response, &messages)
if finished {
execution.Status = "completed"
return execution, nil
}
// Détection de boucle : si Claude réfléchit sans agir
if !hasToolUse {
consecutiveThinking++
if consecutiveThinking > 5 {
execution.Status = "completed"
return execution, nil
}
} else {
consecutiveThinking = 0
}
}
return execution, nil
}
15 itérations max : une itération = un appel Claude + exécution des tools. En pratique, un déploiement simple prend 4-6 itérations. Un troubleshooting complexe peut en prendre 10-12.
Détection de boucle : si Claude fait 5 appels consécutifs sans utiliser de tool (juste du texte), on considère qu’il est bloqué et on arrête.
Le Prompt Engineering
Le system prompt fait 305 lignes. C’est le cerveau de l’agent. Voici les parties clés :
L’identité
Tu es un Agent IA autonome EXPERT et UNIVERSEL pour Kubernetes et KubeVirt.
🎯 MISSION:
Gérer TOUT le cycle de vie des applications, VMs et ressources sur Kubernetes.
Tu es comme un ingénieur DevOps/SRE senior qui peut TOUT faire de façon autonome.
Pourquoi “expert et universel” ? Parce que sans ça, Claude est trop conservateur. Il demande confirmation pour tout. Avec ce framing, il prend des initiatives.
L’intelligence
🧠 INTELLIGENCE & EXPLORATION:
- Si tu ne connais pas une application → CHERCHE avec web_search
- Si tu ne connais pas les ports/config → CHERCHE la documentation
- N'utilise JAMAIS de valeurs hardcodées sans vérifier
- Explore, apprends, construis des solutions DYNAMIQUEMENT
Sans cette section, Claude devinerait les ports et les configs. Il mettrait le port 8080 pour tout. Avec cette instruction, il cherche d’abord la documentation de l’app, trouve le bon port, les bonnes variables d’environnement, et déploie correctement.
Les workflows
Le prompt décrit des workflows types, mais pas comme des règles rigides — plutôt comme des exemples :
📦 WORKFLOW: APPLICATION INCONNUE
───────────────────────────────
1. web_search("how to deploy {app} docker")
2. search_docker_image("{app}")
3. inspect_docker_image("{image}") → ports, env vars
4. deploy_custom_app({image, ports, env...})
5. Vérifier pods → troubleshoot si nécessaire
Claude s’en inspire mais adapte. Si l’app a un Helm chart populaire, il le trouvera avec web_search et utilisera deploy_helm_chart à la place. C’est ça l’avantage d’un agent vs un script.
Les règles de sécurité
8️⃣ ACTIONS DESTRUCTIVES:
- delete_namespace, delete_vm → TOUJOURS demander confirmation
- Format: "⚠️ Ceci va supprimer X. Confirmer ?"
- Si user confirme → appel avec confirm: true
Certains tools ont un paramètre confirm :
{
Name: "delete_namespace",
Description: "Supprime un namespace (ATTENTION: supprime TOUTES les ressources! Nécessite confirmation)",
InputSchema: ToolInputSchema{
Properties: map[string]ToolPropertySchema{
"name": {Type: "string", Description: "Nom du namespace"},
"confirm": {Type: "boolean", Description: "Confirmation (true pour confirmer)"},
},
},
}
Double sécurité : le prompt dit de demander confirmation, ET le tool refuse d’exécuter sans confirm: true. Ceinture et bretelles.
Les garde-fous
Détection de complétion
Comment savoir quand l’agent a fini ? C’est plus subtil qu’il n’y paraît.
// L'agent vérifie plusieurs conditions
if response.StopReason == "end_turn" {
// 1. Si des pods crashent → continuer le troubleshooting
if hasFailingPods {
return false, summaries, hasToolUse, nil
}
// 2. Si c'est une opération read-only → c'est fini
if hasReadOnlyAction && !hasWriteAction {
execution.Status = "completed"
return true, summaries, hasToolUse, nil
}
// 3. Pour un déploiement : vérifier que tout est créé
if hasNamespace && hasDeployment && hasService && hasPodCheck {
execution.Status = "completed"
return true, summaries, hasToolUse, nil
}
}
L’astuce avec hasFailingPods : même si Claude dit “terminé”, on vérifie que les pods tournent vraiment. Si un pod est en CrashLoopBackOff, l’agent continue automatiquement le troubleshooting. Claude ne peut pas tricher en déclarant victoire.
Cleanup automatique
Si l’exécution échoue, on nettoie les namespaces créés :
defer func() {
if execution.Status == "error" || execution.Status == "failed" {
for namespace := range createdNamespaces {
a.server.k8sClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{})
}
}
}()
Sans ça, chaque tentative ratée laisserait des namespaces orphelins dans le cluster.
Auto-healing
L’agent a un cycle de troubleshooting intégré :
1. analyze_pod_failure → identifie le problème
2. apply_fix → applique un correctif
3. Vérifier → si encore cassé, retry avec un autre fix
4. Max 3 tentatives → puis diagnostic à l'utilisateur
En pratique, ça marche étonnamment bien. La plupart des erreurs de déploiement sont des classiques (mauvaise image, port incorrect, ressources insuffisantes) que Claude connaît par coeur.
Le streaming temps réel
L’agent publie ses actions en temps réel via Server-Sent Events (SSE) :
func (s *Server) aiExecutionStreamHandler(w http.ResponseWriter, r *http.Request) {
// Headers SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // Important pour Nginx
// S'abonner aux updates via Redis pubsub
pubsub := s.redisClient.Subscribe(ctx, fmt.Sprintf("execution:%s:updates", executionID))
defer pubsub.Close()
ch := pubsub.Channel()
for {
select {
case <-r.Context().Done():
return
case msg := <-ch:
fmt.Fprintf(w, "data: %s\n\n", msg.Payload)
flusher.Flush()
}
}
}
Le flow complet :
Agent exécute un tool
↓
Publie sur Redis pubsub: "execution:xxx:updates"
↓
Le handler SSE reçoit le message
↓
Envoie au frontend via SSE
↓
React affiche l'action en temps réel
Redis sert de bus de messages entre la goroutine de l’agent (qui tourne en background) et le handler HTTP (qui stream vers le client).
J’ai aussi implémenté la reconnexion : chaque event a un ID incrémental, et les events sont sauvés dans une liste Redis. Si le client se déconnecte et revient, il envoie le header Last-Event-ID et le serveur rejoue les events manqués.
Optimisation des coûts : Prompt Caching
Avec 40+ tools dans le system prompt, chaque appel Claude consommait beaucoup de tokens en input. La solution : Prompt Caching.
cachedSystemPrompt := []SystemBlock{
{
Type: "text",
Text: systemPrompt,
CacheControl: &CacheControl{
Type: "ephemeral", // Cache TTL: 5 minutes
},
},
}
Le principe :
- Premier appel : Claude lit et cache le system prompt (coût normal)
- Appels suivants (dans les 5 min) : Claude lit depuis le cache (90% moins cher)
Concrètement :
- Sans cache : ~$3/MTok pour le system prompt à chaque appel
- Avec cache : ~$0.30/MTok pour les appels suivants
Sur une exécution de 10 itérations, ça divise le coût par 5-8x.
J’ai aussi un rate limiter maison qui estime les tokens avant chaque appel et attend si on approche de la limite :
claudeRateLimiter.WaitIfNeeded(estimatedTokens)
// Après la réponse
claudeRateLimiter.RecordUsage(response.Usage.InputTokens)
Ce que j’aurais fait différemment
1. Commencer avec moins de tools. J’ai fait 40+ tools d’un coup. En réalité, 10-15 tools bien choisis suffisent pour 90% des cas. Les tools de niche (Docker Compose, VM cloning…) sont rarement utilisés.
2. Mieux structurer les résultats des tools. Retourner du texte brut, c’est simple mais ça force Claude à parser du texte libre. Du JSON structuré serait plus fiable.
3. Ajouter du streaming dès le début. J’ai d’abord fait du request/response synchrone, puis migré vers SSE. Autant commencer directement avec le streaming.
4. Logger les coûts par exécution. Je n’ai pas de dashboard des coûts Claude. Je sais que ça coûte pas grand chose (quelques centimes par requête), mais j’aimerais avoir les chiffres précis.
Le résultat
Aujourd’hui, l’agent peut :
- Déployer n’importe quelle app en 30 secondes
- Diagnostiquer et réparer un pod crashé
- Créer des VMs KubeVirt
- Configurer le HTTPS automatiquement
- Et tout ça en langage naturel, en français
Le pattern agent + tools est puissant parce qu’il est extensible. Ajouter une capacité = ajouter un tool. Claude s’adapte automatiquement.
Prochain article : Chaos Engineering dans mon homelab — je casse tout exprès pour voir ce qui se passe.