---
title: "GitOps del stack de inferencia con Flux: operar el asistente como código"
url: https://blog.lo0.es/posts/gitops-stack-inferencia-llm-flux/
markdown: https://blog.lo0.es/posts/gitops-stack-inferencia-llm-flux/index.md
lang: es
date: 2026-06-11
lastmod: 2026-06-12
author: "José Manuel Román"
description: "Un asistente LLM completo son seis o más servicios en varios namespaces. Desplegarlo y parchearlo a mano no escala ni es auditable. Este post explica cómo operar el stack de inferencia entero —motor, gateway, front, vector store, observabilidad— con git como única fuente de verdad y Flux reconciliando contra el cluster: los controladores, la estructura de repo, el bucle de reconciliación, image automation, secretos cifrados y rollback por git revert."
tags: ["gitops","flux","kubernetes","helm","kustomize","image-automation","on-premise","fundamentos"]
translations:
  en: https://blog.lo0.es/en/posts/gitops-inference-stack-flux/index.md
---

# GitOps del stack de inferencia con Flux: operar el asistente como código


> Este post forma parte de la serie operativa sobre cómo exprimir un cluster LLM on-premise genérico de 4×H100 SXM 80 GB. Las piezas hermanas: la [ingesta documental para RAG](https://blog.lo0.es/posts/ingesta-documental-rag-pdf-a-chunk-indexado/index.md) que llena el vector store que aquí desplegamos, el [servicio de embeddings y rerankers con TEI](https://blog.lo0.es/posts/servir-embeddings-rerankers-tei-produccion/index.md) que es uno de los servicios que GitOps gestiona, y el [hardening de secretos del stack soberano](https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/index.md), que profundiza en el problema de los secretos que aquí solo enunciamos. El asistente completo extremo a extremo (LibreChat + LiteLLM + RAG) que orquesta todo esto tendrá su propio post.

## TL;DR

Un asistente LLM en producción es un sistema distribuido, no un binario. Cuenta, como mínimo, con un **motor de inferencia** (vLLM o similar), un **gateway L7** que enruta por modelo y aplica rate-limit, un **front de chat**, un **vector store** para RAG, un servicio de **embeddings/reranker**, y una pila de **observabilidad**. Seis servicios largos, repartidos en tres o cuatro namespaces, con dependencias de arranque (el front no sirve de nada si el gateway no está, el gateway no sirve de nada si el motor no ha cargado el modelo, el RAG no responde si el vector store está vacío). Operarlo a mano —`kubectl apply` por aquí, `helm upgrade` por allá— produce un cluster cuyo estado real nadie sabe reconstruir: no hay registro de qué se aplicó, ni cuándo, ni por qué. **GitOps** resuelve esto con cuatro principios ([OpenGitOps](https://opengitops.dev/)): el estado deseado es **declarativo**, está **versionado e inmutable** en git, se **aplica automáticamente** mediante un agente (nadie hace SSH para desplegar) y se **reconcilia continuamente** comparando el estado real contra el declarado. **Flux** es ese agente: seis controladores (source, kustomize, helm, notification, image-reflector, image-automation) que clonan el repo, renderizan Kustomize/Helm, aplican al cluster, corrigen drift y, opcionalmente, escriben de vuelta a git para subir el tag de una imagen nueva. La unidad de trabajo es la `Kustomization` o el `HelmRelease`, con `interval` (cada cuánto reconcilia), `prune` (borra lo que ya no está en git), `dependsOn` (ordena el arranque) y health checks. El `interval` fija el MTTR del drift: con `interval=1m`, una edición manual al cluster se revierte en ≤1 min de media. Los secretos no caben en claro en git —problema huevo-gallina— y se resuelve con SOPS/age, sealed-secrets o External Secrets + Vault. El rollback es un `git revert`. GitOps no es gratis: la curva de aprendizaje es real y debuggear por qué una `Kustomization` no reconcilia es una habilidad nueva.

## La analogía: el plano maestro y el capataz que no negocia

Imagina una obra grande con un **plano maestro** firmado y archivado, y un **capataz** que tiene una sola orden: que la obra sea idéntica al plano, a todas horas. El capataz no improvisa. Cada cierto tiempo recorre la obra con el plano en la mano y compara: si una pared está donde dice el plano, la deja; si alguien ha movido un tabique de noche sin actualizar el plano, lo devuelve a su sitio; si el plano dice que hay una columna y no existe, la levanta; si una columna existe pero ya no aparece en el plano, la derriba. El plano es la **única fuente de verdad**: para cambiar la obra, no se toca la obra, se cambia el plano —y el capataz se encarga del resto en su siguiente ronda.

Esa es exactamente la mecánica de GitOps. **Git es el plano maestro**: declarativo (describe el estado final, no los pasos), versionado (cada cambio queda firmado en el historial, con autor y motivo), inmutable (un commit no se reescribe). **Flux es el capataz**: cada `interval` recorre el cluster, compara contra git y converge. Y aquí está la lección que separa GitOps de "tener los YAML en un repo": el capataz **deshace los cambios manuales**. Si un operador entra con `kubectl edit` y sube las réplicas del motor de inferencia de 2 a 4 a las tres de la madrugada para apagar un incendio, en la siguiente ronda Flux lo devuelve a 2 —porque el plano dice 2. Esto enfurece a quien viene del mundo imperativo y es precisamente el punto: si quieres 4 réplicas de forma permanente, actualizas el plano. El cluster deja de ser un sistema con memoria propia y opaca, y pasa a ser una **proyección reproducible de git**. Borra el cluster entero, apunta un Flux nuevo al mismo repo, y la obra se reconstruye idéntica.

La analogía también marca la frontera: el plano describe el edificio, no quién tiene las llaves del almacén de material. Los **secretos** —contraseñas, tokens, claves— no pueden ir en el plano público. Ese es el problema huevo-gallina que tratamos más abajo y que la [pieza de hardening](https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/index.md) desarrolla a fondo.

## El problema: un asistente son seis servicios, no uno

Antes de Flux, conviene fijar el tamaño del problema. Un asistente LLM soberano mínimamente serio, desplegado sobre el cluster genérico de referencia, tiene esta topología de servicios:

| Servicio | Función | Namespace típico | Estado |
|---|---|---|---|
| Motor de inferencia (vLLM) | Sirve los tokens del LLM general y el de código | `llm-serving` | Stateless (modelo desde object store) |
| Embeddings + reranker (TEI) | Vectoriza queries y reordena candidatos para RAG | `llm-serving` | Stateless |
| Vector store (Qdrant) | Almacena y busca los embeddings de documentos | `data` | **Stateful** (PVC) |
| Gateway L7 (Envoy AI Gateway / LiteLLM) | Routing por modelo, rate-limit, auth | `gateway` | Semi-stateless |
| Front de chat | UI del asistente | `apps` | Stateless |
| Observabilidad (Langfuse, OTel Collector, dashboards) | Trazas, métricas y logs LLM-aware | `observability` | Stateful (Postgres de Langfuse) |

Seis servicios, cuatro o cinco namespaces, dos componentes con estado persistente, y un grafo de dependencias real: el front llama al gateway, el gateway al motor y al servicio de embeddings, el RAG depende del vector store **lleno**, y todo emite trazas a observabilidad. Desplegar esto a mano implica recordar el orden, los valores de cada `helm install`, los `ConfigMap`, los `Secret`, los `nodeSelector` para clavar los pods GPU en los nodos correctos. Hazlo dos veces (un entorno de staging y uno de producción) y los dos divergen en cuestión de días. Eso es exactamente lo que GitOps elimina.

## Los seis controladores de Flux

Flux no es un binario monolítico, sino un conjunto de controladores que cooperan —el *GitOps Toolkit*. Una instalación por defecto trae cuatro; los dos de image automation se añaden con `--components-extra` ([instalación de Flux](https://fluxcd.io/flux/installation/)):

- **source-controller**: clona y mantiene actualizadas las fuentes —repos git (`GitRepository`), repos Helm (`HelmRepository`), buckets, artefactos OCI—. Es quien "tiene el plano en la mano": expone el contenido del repo como un artefacto interno que los demás consumen.
- **kustomize-controller**: toma un artefacto de fuente, renderiza Kustomize (bases + overlays) y aplica el resultado al cluster. Es el responsable de `prune`, `dependsOn` y los health checks de las `Kustomization`.
- **helm-controller**: reconcilia objetos `HelmRelease` —instala y actualiza charts de Helm de forma declarativa, sin que nadie ejecute `helm` desde una terminal ([helm-controller](https://github.com/fluxcd/helm-controller)).
- **notification-controller**: el puente con el exterior en ambos sentidos. Recibe webhooks (para reconciliar al instante en cada push, en vez de esperar al `interval`) y emite eventos/alertas a Slack, a un chat interno o a un sistema de incidencias.
- **image-reflector-controller**: escanea registries de contenedores y guarda los tags encontrados en una base de datos interna. Es los "ojos" de la image automation.
- **image-automation-controller**: usa lo que ven los ojos para escribir de vuelta en git —hace commit del nuevo tag de imagen en los manifiestos cuando aparece una versión que cumple la política.

Un cluster sin image automation no necesita los dos últimos. Pero para un stack de inferencia donde el servidor de modelos se actualiza con cierta frecuencia, son los que automatizan la promoción de versiones sin tocar nada a mano.

> **Diagrama:** bucle GitOps: git, source-controller, kustomize y helm controllers, cluster, drift y image automation que escribe de vuelta a git — [verlo en la versión HTML](https://blog.lo0.es/posts/gitops-stack-inferencia-llm-flux/)

El diagrama tiene tres recorridos. El **gris** es el flujo principal: git → source-controller → kustomize/helm-controller → cluster. El **rojo discontinuo** es el bucle de reconciliación que se ejecuta cada `interval`: compara estado real contra deseado, corrige el drift y poda lo que sobra. El **morado** es la image automation: el reflector ve un tag nuevo en el registry, la policy decide si cumple, y el automation-controller **escribe de vuelta a git**. Ese último recorrido es el que cierra el círculo y convierte git en un sistema que se actualiza solo.

## Estructura de repo: clusters, infrastructure, apps

La convención más extendida separa tres niveles de responsabilidad. No es la única, pero envejece bien:

```text
gitops-repo/
├── clusters/
│   └── prod/
│       ├── infrastructure.yaml   # Kustomization → ./infrastructure
│       └── apps.yaml             # Kustomization → ./apps (dependsOn infrastructure)
├── infrastructure/
│   ├── controllers/              # ingress, cert-manager, GPU operator, KEDA...
│   └── configs/                  # ClusterIssuer, RuntimeClass, StorageClass...
└── apps/
    ├── base/                     # manifiestos comunes a todos los entornos
    │   ├── llm-engine/           # HelmRelease vLLM
    │   ├── gateway/              # HelmRelease del gateway L7
    │   ├── chat-front/           # Deployment + Service + Ingress
    │   ├── vector-store/         # HelmRelease Qdrant (+ PVC)
    │   └── observability/        # HelmRelease Langfuse + OTel
    ├── staging/                  # overlay: réplicas bajas, modelo pequeño
    └── prod/                     # overlay: réplicas altas, modelo grande, MIG
```

La pieza clave es la separación **base / overlay** de Kustomize. La `base/` describe el servicio una sola vez; cada overlay (`staging/`, `prod/`) aplica un `patch` con las diferencias del entorno: número de réplicas, tamaño del modelo, perfil MIG, `nodeSelector`, `--gpu-memory-utilization`. Esto evita duplicar manifiestos completos por entorno. La carpeta `clusters/` es el punto de entrada que Flux reconcilia primero: contiene las `Kustomization` raíz que apuntan a `infrastructure/` y `apps/`, con un `dependsOn` que garantiza que la infraestructura (CRDs, operadores, storage classes) esté lista **antes** que las aplicaciones.

### Cuántos manifiestos: el cálculo que justifica los overlays

Aquí es donde los números hacen el argumento. Sin overlays, cada servicio requiere un juego completo de manifiestos por entorno. Con $N$ servicios y $M$ entornos, el coste en archivos a mantener es:

$$
\text{archivos}_{\text{naïve}} = N \times M \times k
$$

donde $k$ es el número medio de manifiestos por servicio (Deployment/HelmRelease + Service + ConfigMap + PVC + Ingress ≈ 5). Para nuestro asistente, $N=6$ servicios y $M=3$ entornos (dev, staging, prod), con $k=5$:

$$
6 \times 3 \times 5 = 90 \text{ archivos completos, cada uno mantenido por separado.}
$$

Con la estructura base/overlay, la base se escribe una vez y cada overlay solo contiene el `patch` de las diferencias (típicamente 1 fichero corto por servicio por entorno):

$$
\text{archivos}_{\text{overlay}} = \underbrace{N \times k}_{\text{base}} + \underbrace{N \times M}_{\text{patches}} = 6 \times 5 + 6 \times 3 = 30 + 18 = 48
$$

No es solo que sean menos archivos (48 frente a 90): es que **el grueso del cambio se hace en un solo sitio**. Cambiar el límite de memoria del motor para todos los entornos es una edición en `base/`, no tres ediciones sincronizadas. El factor de ahorro crece con $M$: para 5 entornos, la versión naïve son 150 archivos y la de overlays son 60. La duplicación es el enemigo de la auditabilidad, y los overlays la atacan en la raíz.

## El bucle de reconciliación: desired vs actual

El corazón de Flux es un bucle de control que no tiene fin: cada `interval`, lee el estado deseado (git), lee el estado real (cluster), calcula la diferencia y la aplica. Es el mismo principio de un **termostato**: lee la temperatura objetivo (git), lee la temperatura real (cluster) y enciende o apaga la caldera hasta que coinciden. No "despliega una vez"; converge para siempre.

Un manifiesto `Kustomization` de las aplicaciones, con las piezas que importan ([referencia Kustomization](https://fluxcd.io/flux/components/kustomize/kustomizations/)):

```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 1m              # reconcilia cada minuto: fija el MTTR del drift
  path: ./apps/prod         # overlay de producción
  prune: true               # borra del cluster lo que se quita de git
  sourceRef:
    kind: GitRepository
    name: gitops-repo
  dependsOn:
    - name: infrastructure  # no aplica apps hasta que infra esté Ready
  wait: true                # espera a que los recursos estén healthy
  timeout: 5m               # falla la reconciliación si no converge en 5 min
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: gateway
      namespace: gateway
```

Cuatro campos hacen el trabajo pesado:

- **`interval`** define cada cuánto se ejecuta la ronda del capataz. Es el determinante directo del MTTR del drift (siguiente sección).
- **`prune: true`** activa la recolección de basura: los objetos que se aplicaron antes pero ya no están en la revisión actual de git se borran del cluster automáticamente. Sin `prune`, un servicio retirado del repo sigue corriendo —el plano y la obra divergen en silencio.
- **`dependsOn`** ordena el grafo: Flux no aplica los manifiestos de una `Kustomization` hasta que todas las referenciadas tengan estado `Ready: True`. Es el mecanismo que garantiza datos → gateway → front.
- **`wait` + `healthChecks`**: con `wait: true`, Flux monitoriza todos los recursos aplicados y espera a que estén listos antes de marcar la reconciliación como exitosa; `healthChecks` permite afinar exactamente qué recursos vigilar. Esto es lo que hace que un `dependsOn` signifique algo: la dependencia no está satisfecha hasta que está **sana**, no solo aplicada.

### MTTR y detección de drift en función del interval

El `interval` es el único parámetro que el operador elige para gobernar la velocidad del bucle, y se traduce directamente en métricas operativas. Si una desviación (drift) ocurre en un instante aleatorio dentro del periodo de reconciliación $T$, el tiempo de espera hasta que Flux la detecta se distribuye uniformemente en $[0, T]$, con media:

$$
\mathbb{E}[t_{\text{detección}}] = \frac{T}{2}
$$

A esto se le suma el tiempo de corrección $t_c$ (renderizar, hacer diff, aplicar), normalmente unos segundos. El MTTR del drift queda:

$$
\text{MTTR}_{\text{drift}} = \frac{T}{2} + t_c
$$

Con `interval=1m` y $t_c \approx 10\text{s}$, el drift se corrige en $\frac{60}{2} + 10 = 40$ s de media, con un peor caso de $60 + 10 = 70$ s. Con `interval=10m`, la media sube a $5\text{min}\;10\text{s}$ y el peor caso a más de 10 min: una ventana en la que un cambio manual sigue activo. La tentación es poner `interval=10s` y olvidarse, pero hay un coste: cada reconciliación consume CPU y, sobre todo, hace peticiones a la API del cluster y al registry. Con decenas de `Kustomization` reconciliando cada 10 s, el `kube-apiserver` y el image-reflector empiezan a notar la presión. La regla práctica: `interval` corto (1m) para las aplicaciones críticas cuyo drift duele, `interval` largo (10–30m) para infraestructura estable que casi nunca cambia, y **webhooks** del notification-controller para reconciliación instantánea en cada push —así el `interval` solo gobierna el drift, no la latencia de despliegue.

## Helm como código: el HelmRelease del motor de inferencia

Para el motor, el gateway y el front, lo habitual es empaquetarlos como charts de Helm y declararlos con `HelmRelease`. El helm-controller los reconcilia sin que nadie ejecute `helm` jamás:

```yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: llm-engine
  namespace: llm-serving
spec:
  interval: 10m
  chart:
    spec:
      chart: vllm
      sourceRef:
        kind: HelmRepository
        name: vllm-charts
  values:
    image:
      tag: "0.8.4"          # gestionado por image automation (ver abajo)
    replicaCount: 2
    nodeSelector:
      nvidia.com/gpu.product: H100-SXM
    resources:
      limits:
        nvidia.com/gpu: 1
    extraArgs:
      - "--gpu-memory-utilization=0.90"
      - "--tensor-parallel-size=1"
```

Todo lo que en el mundo imperativo sería un flag de `vllm serve` —`--gpu-memory-utilization`, `--tensor-parallel-size`— vive ahora como dato versionado. Cambiar la fracción de VRAM que el motor reserva es un commit, revisable en un PR, con historial. La relación con [compartir una GPU](https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/index.md) es directa: el perfil MIG y el `--gpu-memory-utilization` son parámetros de despliegue, y aquí son código.

## Image automation: subir el tag sin tocar nada a mano

El motor de inferencia se actualiza con cierta frecuencia (parches de vLLM, fixes de seguridad). Sin automation, cada versión nueva exige que alguien edite el `tag` en el `HelmRelease`. La image automation de Flux lo hace sola, con tres objetos ([automate image updates](https://fluxcd.io/flux/guides/image-update/)):

```yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: vllm
  namespace: flux-system
spec:
  image: registry.example.local/inference/vllm
  interval: 5m              # cada 5 min escanea los tags del registry
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: vllm
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: vllm
  policy:
    semver:
      range: ">=0.8.0 <0.9.0"   # solo parches y minors dentro de 0.8.x–0.8.x
---
apiVersion: image.toolkit.fluxcd.io/v1beta1
kind: ImageUpdateAutomation
metadata:
  name: vllm
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: GitRepository
    name: gitops-repo
  git:
    commit:
      author:
        name: fluxbot
        email: fluxbot@example.local
      messageTemplate: "auto: bump vllm to {{ .NewTag }}"
```

El reparto de papeles: **`ImageRepository`** escanea todos los tags del repositorio de la imagen y los guarda en la base de datos interna del reflector. **`ImagePolicy`** lee esos tags y elige el "último" según la política —el campo `policy` es obligatorio y define cómo se selecciona ([ImagePolicy](https://fluxcd.io/flux/components/image/imagepolicies/)). **`ImageUpdateAutomation`** toma el tag elegido, edita el manifiesto en el repo (donde haya un marcador `# {"$imagepolicy": "flux-system:vllm"}`) y hace commit ([ImageUpdateAutomation](https://fluxcd.io/flux/components/image/imageupdateautomations/)).

La elección de política importa:

- **semver**: interpreta los tags como versiones semánticas y elige la más alta que cumpla el rango (`range: ">=0.8.0 <0.9.0"`). Por defecto excluye prereleases (`0.8.0-rc.1` no entra) salvo que se pidan explícitamente. Es la opción correcta para producción: te quedas dentro de un rango de versiones probado y no saltas a un major nuevo sin querer.
- **regex / alphabetical / numerical**: para esquemas de tags que no son semver puros —por ejemplo `main-<sha>-<timestamp>` de un pipeline interno—. Más flexible, pero te obliga a confiar en que el orden de tags refleja el orden de versiones, lo cual es frágil.

Una pauta de seguridad: en lugar de que `ImageUpdateAutomation` haga `push` directo a `main`, se le configura para que escriba a una **rama** y abra un **PR**. Así el bump de versión pasa por revisión humana o por un pipeline de validación antes de llegar al cluster. El automation propone; el humano (o un gate automático) dispone. Y para producción, el bump de versión no es el despliegue: es el disparador del rollout progresivo descrito en [canary, blue-green y shadow](https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/index.md).

## Secretos en GitOps: el problema huevo-gallina

GitOps exige que todo el estado deseado esté en git. Pero los secretos —la contraseña de Postgres de Langfuse, el token del registry, las API keys del gateway— no pueden ir en claro en un repo, ni siquiera privado: el historial de git es inmutable, y un secreto commiteado una vez queda ahí para siempre. Esta es la tensión fundamental: GitOps quiere todo en git; la seguridad prohíbe los secretos en git. Hay tres familias de solución, todas con la misma idea de fondo —**en git solo va el secreto cifrado; el cluster tiene la llave para descifrarlo**:

- **SOPS + age/KMS**: cifras los valores sensibles de un manifiesto con [SOPS](https://github.com/getsops/sops) (deja las claves legibles, cifra solo los valores). El YAML cifrado va a git; el kustomize-controller lleva una clave age o accede a un KMS para descifrarlo en el momento de aplicar. Simple, sin componentes extra en el cluster.
- **sealed-secrets**: un controlador en el cluster tiene una clave privada. Cifras el secreto contra su clave pública (con `kubeseal`), el `SealedSecret` cifrado va a git, y el controlador lo descifra a un `Secret` normal dentro del cluster. La clave privada nunca sale del cluster.
- **External Secrets + Vault**: en git no va ni siquiera el secreto cifrado, solo una **referencia** (`ExternalSecret`) que dice "el valor de esta clave está en Vault, en esta ruta". El operador External Secrets lo resuelve en tiempo de aplicación. Es el patrón más limpio para muchos secretos y rotación frecuente, a costa de operar un Vault.

La elección depende del volumen de secretos y de si ya tienes un gestor central. Para un stack pequeño, SOPS+age es suficiente y sin dependencias. La [pieza hermana de hardening](https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/index.md) entra en el detalle de cada opción, la rotación de claves y el modelo de amenaza.

## Promoción y rollback: git revert es el botón de pánico

La consecuencia más elegante de tener el cluster como proyección de git es que **el rollback es un `git revert`**. Si un despliegue rompe producción —el motor nuevo da peor latencia, el gateway empieza a devolver 5xx—, no hay que recordar qué versión había antes ni reconstruir el estado a mano. Se revierte el commit que introdujo el cambio, y en la siguiente reconciliación (≤ `interval`, o instantáneo con webhook) Flux devuelve el cluster al estado anterior. El historial de git es, literalmente, el historial de despliegues: cada commit es un punto de restauración con autor, fecha y motivo.

Esto encaja con las estrategias de promoción del post de [canary, blue-green y shadow](https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/index.md): Flux gestiona **qué está declarado**, y la herramienta de rollout progresivo gestiona **cómo se mueve el tráfico** entre la versión vieja y la nueva. El git revert es el rollback de último recurso (vuelve todo a un estado conocido); el canary es el mecanismo que evita necesitarlo. Para servicios donde el cambio de versión es delicado, lo correcto es que image automation haga el bump en una rama, el canary valide los gates de regresión, y solo entonces se promocione el commit a `main`.

## La costura: GitOps no es gratis

Sería deshonesto vender GitOps como magia. Tiene costes reales que cualquier equipo paga:

**Curva de aprendizaje.** El modelo declarativo es un cambio de mentalidad. El operador que lleva años arreglando incidentes con `kubectl edit` tiene que desaprender el reflejo: ahora el cluster le revierte los cambios y eso, al principio, parece que Flux "le pelea". Entender que el cambio se hace en git, no en el cluster, lleva semanas de incomodidad.

**Debuggear al reconciliador.** Cuando una `Kustomization` no aplica, el error no está en el pod —está en la cadena de reconciliación. ¿El source-controller clonó la revisión correcta? ¿El kustomize-controller renderizó bien el overlay? ¿Falló un health check del `dependsOn`? ¿El timeout saltó antes de que el modelo grande terminara de cargar? Diagnosticar esto requiere `flux get`, `flux logs`, leer los `status.conditions` de los objetos Flux y entender en qué eslabón se atascó. Es una habilidad nueva, distinta de debuggear Kubernetes "a secas".

**Timeouts y cargas lentas.** El `timeout` de una `Kustomization` con `wait: true` tiene que ser mayor que el tiempo de arranque del servicio más lento. Un motor de inferencia que tarda 4 minutos en cargar un modelo grande desde el object store hará fallar una `Kustomization` con `timeout: 2m`, aunque todo esté bien. Calibrar timeouts por servicio es trabajo fino.

**El drift que sí querías.** A veces el operador *necesita* un cambio temporal urgente y Flux se lo revierte. La respuesta correcta —suspender la reconciliación de esa `Kustomization` con `flux suspend`, hacer el cambio, y luego reflejarlo en git y reanudar— es disciplina que hay que construir. Sin esa disciplina, la gente acaba peleándose con el capataz a las 3 de la madrugada.

Ninguno de estos costes anula el beneficio. Pero un equipo que adopta GitOps esperando que "todo sea más fácil desde el día uno" se frustra. Es más fácil **a partir del mes dos**, cuando la auditabilidad, la reproducibilidad y el rollback trivial ya han pagado la curva.

## Aplicado al cluster genérico 4×H100

Sobre el cluster de referencia (4×H100 SXM 80 GB por nodo GPU, NVLink, más nodos CPU y de control), el stack del asistente se gestiona enteramente por GitOps. Las decisiones de hardware se vuelven datos en los overlays.

**nodeSelectors y perfiles MIG como código.** El overlay de `prod` clava cada servicio en el nodo correcto y declara el perfil de GPU. El motor general usa una GPU entera; los embeddings y el LLM pequeño caben en slices MIG —exactamente el reparto del post de [compartir una GPU](https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/index.md), pero ahora versionado:

```yaml
# apps/prod/llm-engine-patch.yaml
spec:
  values:
    nodeSelector:
      nvidia.com/gpu.product: H100-SXM
    extraArgs:
      - "--gpu-memory-utilization=0.92"
      - "--tensor-parallel-size=1"
---
# apps/prod/embeddings-patch.yaml — sobre un slice MIG
spec:
  values:
    nodeSelector:
      nvidia.com/mig.config: "3g.40gb"
    resources:
      limits:
        nvidia.com/mig-3g.40gb: 1
```

El perfil MIG (`3g.40gb`), la fracción de VRAM (`0.92`), el tensor-parallel: todo es texto en un PR. Cambiar el reparto de GPU entre servicios es un commit revisable, no una sesión de `nvidia-smi mig` a mano que nadie registra.

**Orden de arranque con dependsOn.** El grafo de dependencias del asistente se codifica con `dependsOn` encadenados. El orden correcto es **datos → embeddings/motor → gateway → front**:

> **Diagrama:** orden de arranque con dependsOn: datos, motor y embeddings, gateway, front — [verlo en la versión HTML](https://blog.lo0.es/posts/gitops-stack-inferencia-llm-flux/)

```yaml
# apps/prod/gateway.yaml
spec:
  dependsOn:
    - name: llm-engine        # el gateway no sirve sin motor
    - name: embeddings
  # ...
---
# apps/prod/chat-front.yaml
spec:
  dependsOn:
    - name: gateway           # el front no sirve sin gateway
```

Con `wait: true` en cada `Kustomization`, Flux no marca `llm-engine` como `Ready` hasta que el pod del motor pasa su health check —lo que en el caso de vLLM significa **modelo cargado y endpoint respondiendo**, no solo pod arrancado. Solo entonces empieza a aplicar el gateway. Esto evita la cascada de fallos clásica del despliegue manual: levantar el front primero, ver 502 porque el gateway no está, levantar el gateway, ver 503 porque el motor todavía carga el modelo. Con `dependsOn` + `wait`, el orden lo garantiza el reconciliador, no la memoria del operador.

El resultado: borrar el namespace entero del asistente y dejar que Flux lo reconstruya desde git produce exactamente el mismo stack, en el mismo orden, con la misma configuración de GPU. El cluster 4×H100 deja de tener una configuración que solo conoce quien la montó, y pasa a ser una proyección reproducible de un repo que cualquiera puede auditar.

## Cierre

GitOps no es una herramienta, es una **inversión de la dirección del control**: en vez de empujar cambios al cluster, declaras el estado en git y dejas que un agente tire del cluster hacia él. Para un asistente LLM —seis servicios, varios namespaces, dependencias de arranque, configuración de GPU delicada— esa inversión convierte un sistema frágil y opaco en uno reproducible y auditable. Flux es el capataz que mantiene la obra idéntica al plano y deshace cualquier cambio que no pase antes por el plano. El precio es una curva de aprendizaje real y una habilidad nueva de debugging. El premio es que el cluster deja de tener secretos sobre sí mismo: todo lo que es, está escrito, firmado y reproducible en git.

## Ver también

- [Hardening y secretos del stack LLM soberano](https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/index.md) — la pieza hermana: el detalle de SOPS, sealed-secrets y External Secrets, rotación de claves y modelo de amenaza del problema huevo-gallina que aquí solo enunciamos.
- [Siete fases de despliegue de una plataforma LLM on-premise](https://blog.lo0.es/posts/siete-fases-despliegue-plataforma-llm-on-premise/index.md) — GitOps es la fase F3 de ese recorrido; aquí lo desplegamos, allí se sitúa en la secuencia completa.
- [Cinco niveles de madurez de la plataforma LLM on-premise](https://blog.lo0.es/posts/cinco-niveles-madurez-plataforma-llm-on-premise/index.md) — pasar de `kubectl apply` a git como única autoridad es el salto de nivel que este post operacionaliza.
- [Autoscaling LLM en Kubernetes con KEDA](https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/index.md) — el autoscaler convive con GitOps: KEDA ajusta réplicas por métrica mientras Flux mantiene el resto del estado; cómo no se pelean.
- [Canary, blue-green y shadow para modelos LLM](https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/index.md) — la promoción progresiva que image automation dispara y que el git revert respalda como rollback de último recurso.
- [Kubelet resource managers en RKE2 y NUMA](https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/index.md) — los `nodeSelector` y la topología de los pods GPU que aquí declaramos como código tienen su contrapartida en la política del kubelet.
- [Compartir una GPU: time-slicing, MPS y MIG](https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/index.md) — los perfiles MIG y el `--gpu-memory-utilization` que en este post son datos de un overlay; allí, la mecánica de por qué.

## Referencias

- OpenGitOps — principios de GitOps — [opengitops.dev](https://opengitops.dev/)
- Flux installation — [fluxcd.io/flux/installation](https://fluxcd.io/flux/installation/)
- Flux Kustomization — [fluxcd.io/flux/components/kustomize/kustomizations](https://fluxcd.io/flux/components/kustomize/kustomizations/)
- helm-controller — [github.com/fluxcd/helm-controller](https://github.com/fluxcd/helm-controller)
- Flux Image Policies — [fluxcd.io/flux/components/image/imagepolicies](https://fluxcd.io/flux/components/image/imagepolicies/)
- Flux Image Update Automations — [fluxcd.io/flux/components/image/imageupdateautomations](https://fluxcd.io/flux/components/image/imageupdateautomations/)
- Automate image updates to Git — [fluxcd.io/flux/guides/image-update](https://fluxcd.io/flux/guides/image-update/)
- SOPS — [github.com/getsops/sops](https://github.com/getsops/sops)

