Published

F5 · Approach — FTE y autoescalado por cola, medido contra el cluster vivo

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

F5 · Approach — FTE y autoescalado por cola, medido contra el cluster vivo

Compañero de trino-absorcion.md §7 y de
trino-gke-coste.md §3-4.
Aquel dice qué hace F5;
éste dice qué de eso resultó ser falso al medirlo, y qué queda en pie.

Escrito el 2026-08-07 contra trino-compute-clone con F0→F4 cerradas, el código
fuente de Trino 470 y las cuotas reales del proyecto trino-k8s. Nada de aquí es
previsión: los cinco hallazgos salen de ejecutar, no de leer.


0 · Por qué importa esta fase, y por qué el titular está mal

El runbook y el doc de coste dicen lo mismo, y con razón:

«F5 no es sólo la fase de resiliencia: es la que desbloquea el 80 % del ahorro»
porque P1 (workers en Spot) exige FTE y P3 (scale-to-zero) exige el autoescalado
por cola
.

Eso sigue siendo cierto como dependencia lógica y es falso como plan ejecutable, por una razón que no es técnica y que no estaba escrita en ninguna parte: 👇

⛔ El 80 % no lo bloquea F5. Lo bloquea una cuota a cero.

PREEMPTIBLE_CPUS    usage 0    limit 0

Medido en europe-west1, proyecto trino-k8s, el 2026-08-07. Los Spot VM consumen
la cuota PREEMPTIBLE_CPUS
, y la nuestra es cero. No es que sobre poco: es que
no hay ninguna.

Y la causa es la que el §F0 del runbook ya tenía anotada como cabo suelto nº 4 sin
conectarla con esto: la cuenta está en trial. Google no concede cuota de
preemptible/Spot a cuentas de prueba
, y la ampliación se deniega automáticamente
hasta convertir la cuenta en de pago.

P1 —el 77 % de la factura de los workers, la palanca más grande del proyecto— no
depende de terminar F5. Depende de una decisión de facturación.
F5 se puede hacer
entera y perfecta, y Spot seguirá siendo imposible al terminarla.

Esto no invalida F5: FTE vale por sí misma (resiliencia ante desalojo, reintento
de tarea, queries largas que sobreviven). Lo que invalida es el orden de prioridad
con el que estaba justificada. Si se hace F5 para ahorrar, se está haciendo la
segunda mitad de una llave cuya primera mitad no se ha pedido.


1 · Los cinco hallazgos, por orden de lo que cambian

① Stackable 25.3 no sabe nada de FTE — y lo tira en silencio

kubectl explain trinoclusters.spec --recursive | grep -iE 'exchange|retry|faultToler'
#   → (nada)

El CRD no tiene ningún campo para el exchange manager. Y lo natural —meterlo por configOverrides, que es map[fichero]map[clave]valorse acepta y no hace nada:

kubectl patch trinocluster carbon -n trino --type=merge \
  -p '{"spec":{"workers":{"configOverrides":{"exchange-manager.properties":{…}}}}}'
#   → trinocluster.trino.stackable.tech/carbon patched      ✅ verde

kubectl get cm carbon-worker-default -n trino -o jsonpath='{.data}' |#   → access-control.properties · config.properties · jvm.config
#     log.properties · node.properties · security.properties
#   ⇒ exchange-manager.properties NO ESTÁ

Es exactamente la trampa de F4·3, que este repo ya tiene escrita: «Stackable copia sólo 5 ficheros fijos … y el access-control es un fichero APARTE que sólo genera un campo del CRD». Allí había campo (clusterConfig.authorization.opa). Aquí no hay ninguno.

⚠️ Y lo que lo hace peligroso no es que falle: es que no falla. kubectl apply da verde, el operador reconcilia, el pod arranca — y Trino nunca ve el fichero. Es el mismo patrón que el -Xmx del §2: el comando no falla y el resultado no está.

② La salida es un initContainer sobre rwconfigy el subPath NO vale

Cómo arranca Stackable a Trino, leído del command del StatefulSet:

cp -RL /stackable/config/* /stackable/rwconfig
bin/launcher run --etc-dir=/stackable/rwconfig --data-dir=/stackable/data

Y los volúmenes:

configConfigMap/stackable/config
rwconfigemptyDir/stackable/rwconfig
catalogConfigMap → /stackable/config/catalog (anidado — la pista)

La vía obvia —montar el fichero con subPath dentro de /stackable/config, imitando lo que hace catalogse probó y revienta el contenedor:

Error: failed to create containerd task … error mounting
  "…/volume-subpaths/sonda/trino/10" to rootfs at "/stackable/config/sonda.properties":
  flags=MS_RDONLY|MS_BIND|MS_REC: not a directory

Anidar un DIRECTORIO dentro de un volumen de ConfigMap funciona (por eso catalog va bien); anidar un FICHERO no, porque el bind-mount necesita que el destino ya exista y el volumen de ConfigMap es de sólo lectura. El worker quedó en CrashLoopBackOff hasta revertir.

La vía que sí funciona, y sale de que rwconfig sea un emptyDir:

podOverrides:
  spec:
    initContainers:
      - name: exchange-manager-config
        image: <la misma imagen>
        command: ["sh","-c","cp /cfg/exchange-manager.properties /stackable/rwconfig/"]
        volumeMounts:
          - { name: rwconfig, mountPath: /stackable/rwconfig }
          - { name: exchange-cfg, mountPath: /cfg }
    volumes:
      - name: exchange-cfg
        configMap: { name: trino-exchange-manager }

El initContainer escribe en el emptyDir antes; el cp -RL del arranque añade los otros seis ficheros encima sin borrar el nuestro (nombres distintos). Trino arranca y encuentra etc/exchange-manager.properties donde lo busca.

⚠️ [confirmar] al ejecutar: que el cp -RL no falle por el fichero preexistente y que
el initContainer no se salte el fsGroup/permisos de stackable:1000.

③ Workload Identity: la premisa del runbook es media verdad, y la mitad falsa es la que importa

El §7 del runbook dice:

«Y esto es lo que hace NECESARIA la Workload Identity (F0): sin ella, autenticar contra
GCS obliga a montar un fichero de clave de service account en los pods — justo el tipo de
credencial durable que este proyecto lleva meses quitando de todas partes.»

Hay que separarlo en dos, porque Trino tiene dos caminos distintos a GCS:

CaminoQuién lo usaCredencial
Filesystem GCS nativo (fs.native-gcs.enabled, gcs.*)el conector IcebergADC ⇒ Workload Identity SÍ
Exchange manager de FTE (exchange.*)👉 F5S3 en modo GCP ⇒ claves HMAC estáticas

El primero, verificado en el código de la 470 (GcsStorageFactory), no en la doc — que no lo menciona y por eso se dudaba:

credentials = jsonGoogleCredential.orElseGet(() -> {
    return GoogleCredentials.getApplicationDefault();   // ← sin gcs.json-key* ⇒ ADC
});

El segundo es el que rompe el plan (FileSystemExchangeModule, Trino 470 y 483 — subir de versión no lo arregla):

else if (ImmutableSet.of("s3", "gs").contains(scheme)) {
    binder.bind(FileSystemExchangeStorage.class).to(S3FileSystemExchangeStorage.class)    CompatibilityMode compatibilityMode = scheme.equals("gs") ? GCP : AWS;   // ⭐
}

gs:// NO usa el filesystem GCS nativo. Usa el cliente S3 contra la API de interoperabilidad XML de GCS. Y sus credenciales salen de:

if (accessKey != null) { return StaticCredentialsProvider.create(
        AwsBasicCredentials.create(accessKey, secretKey)); }

o sea exchange.s3.aws-access-key / -secret-key = claves HMAC de GCS. Estáticas. Durables. En un Secret.

El detalle que hace esto especialmente fácil de diagnosticar mal

En modo GCP, S3FileSystemExchangeStorage construye DOS clientes:

ClientePara quéCredencial
gcsClient (nativo)sólo los deleteStorageOptions.getDefaultInstance()ADC ✅
s3AsyncClientleer y escribir el shuffleHMAC estáticas ❌

⇒ Con Workload Identity y sin HMAC, los borrados funcionarían y las escrituras no.
Un fallo parcial que parece un problema de permisos de bucket y no lo es. Es el mismo
género que «listar es catálogo, leer es storage» de F2, y el mismo que
fs.native-s3.enabled: el gate barato pasa y el caro no.

⇒ La consecuencia honesta: F5 introduce UNA credencial durable nueva, sí o sí. No hay configuración que lo evite en la 470 ni en la 483. Lo que sí se puede es acotarla: una SA dedicada, con HMAC propias, y permiso únicamente sobre el bucket del exchange — que guarda shuffle efímero, no tablas de nadie. El radio de explosión es pequeño; la afirmación «cero secretos durables donde haya federación» deja de ser cierta y hay que corregirla en el §5 del runbook, no esconderla.

④ No hay sitio para un segundo worker — y por tanto el gate de F5 no se puede ejecutar hoy

El gate de F5 es, literalmente: «matar un worker a mitad de una query larga y que la query termine». Eso exige dos workers: con uno, matarlo deja el cluster sin nadie a quien reasignar la tarea, y lo que se mediría no es FTE.

Al intentarlo, el autoescalador lo dijo:

Warning  FailedScaleUp  cluster-autoscaler
  Node scale up in zones europe-west1-b … failed: GCE quota exceeded

Y la cuota es ésta:

SSD_TOTAL_GB     usage 202    limit 250

pd-balanced cuenta contra la cuota de SSD. Dos nodos × 100 GB = 200. Un tercero pide 302 > 250. El cluster está a un nodo de su techo, y el techo no es dinero: es cuota.

Y esto le da a P5 un segundo motivo, mucho mayor que su precio

El doc de coste tiene P5 (disco 100 → 50 GB) valorado en ~9 €/mes y agendado
«en la misma recreación que P2» — o sea, casi como una propina. Con este número
delante, P5 es otra cosa:

SSD usadonodos que caben
hoy (100 GB/nodo)202 / 2502
con 50 GB/nodo102 / 2505

P5 no ahorra 9 €: desbloquea la fase. Y no hace falta pedirle nada a Google.

La alternativa que no toca nada de lo que ya corre: un pool de workers nuevo con
pd-standard, que cuenta contra DISKS_TOTAL_GB (0 / 2048, vacía) y no toca
la cuota de SSD
. Cuesta arranques más lentos —justo lo que penaliza a un pool que
escala— pero es aditivo y reversible, y no exige recrear el pool de sistema.

⑤ Los pods de Trino son Burstable, y el propio doc de coste dice que no deben serlo

Medido en el pod vivo: QoS Class: Burstable, porque cpu: {min: 300m, max: 1} genera requests != limits. Y el §P4 del doc de coste ya lo tiene escrito:

«⚠️ Y esto NO se le aplica a Trino. Un motor con requests != limits es Burstable y
puede ser desalojado bajo presión de memoria del nodo. Para Trino, requests = limits
y QoS Guaranteed

La regla está escrita y no está aplicada. Hoy es un riesgo latente; con FTE y Spot encima pasa a ser un multiplicador: un desalojo por presión de memoria del nodo se sumaría a los desalojos de Spot, y los dos se diagnostican igual. Va en F5, no después.


2 · Lo que queda en pie, y en qué orden

Una variable por fase, como manda el runbook. Con lo medido, F5 se parte en cuatro, y las dos primeras no son las que estaban escritas:

Sub-faseVariableEstado
F5·0Sitio — liberar cuota SSD (P5) o pool pd-standard; QoS Guaranteed (⑤)dónde caben los podsbloqueante de todo lo demás
F5·aFTE — exchange manager por initContainer (②) + retry-policy=TASKtolerancia a fallo⬜ requiere decidir el almacén (③)
F5·bElasticidad — ⛔ el autoescalado por cola NO es posible (ni /scale ni escalar el STS); 🏁 se hace suspensión por clusterOperation.stoppedelasticidad🏁 §4
F5·cSpot (P1)precioBLOQUEADA por PREEMPTIBLE_CPUS = 0 — no es técnica

⚠️ El orden FTE → Spot del doc de coste sigue mandando («Spot antes de FTE = cada
desalojo mata la query»
). Lo que cambia es que F5·c ya no es el paso siguiente de
F5·a
: está detrás de una decisión de facturación que puede no tomarse nunca.

La decisión que este documento NO toma

Dónde vive el exchange, porque las tres salidas tienen precios distintos y ninguno es técnico:

OpciónPrecio
AGCS gs:// + HMAC en europe-west1⭐ misma región (la latencia que pide el §7) · ❌ una credencial durable nueva
BR2 s3://, reutilizando lo que ya hay✅ cero credenciales nuevas · ❌ WEUR ≠ europe-west1: cruzar de nube en cada etapa de cada shuffle, que es justo lo que el §7 rechazó por escrito
CNo hacer F5·a todavía✅ cero coste y cero credenciales · ❌ sin FTE no hay Spot — pero Spot ya está bloqueado por cuota, así que hoy no se pierde nada que no estuviera perdido

3 · 🏁 EJECUTADO el 2026-08-07 — F5·0 y F5·a CERRADAS

Lo que quedó montado

Poolmain-pool-3 · n2-standard-2 · discos de 50 GB · autoescalado 1→4 · SA mínima gke-node-carbon@ · GKE_METADATA
Cuota SSD202/250 → 102/250 ⇒ el techo pasa de 2 nodos a 5
Exchangegs://carbon-trino-exchange · europe-west1 · lifecycle 1 día · SA trino-exchange@ con objectAdmin sólo sobre ese bucket
FTEretry-policy=TASK + fault-tolerant-execution-task-memory=1GB en los dos roles
El ficheroexchange-manager.properties en un Secret, colocado por un initContainer sobre el emptyDir de rwconfig
Workers2 — sin dos, el gate no se puede ejecutar
Artefactosinfra/gke/f5-exchange.sh · el bloque F5·a de infra/gke/f1-trino.yaml

El gate, con su control negativo

retry-policyWorkers activos en el motor (tareas RUNNING c/u)GolpeResultado
TASK2 (2 y 2)matar worker en seco9.999.832 en 242,2 s
NONE2 (4 y 5)el mismo golpe🛑 REMOTE_TASK_ERROR a los 55,6 s
TASK2ninguno9.999.832 en 86,2 s (control de corrección)

Y la prueba de que el shuffle sale de la máquina, listada durante la query:

gs://carbon-trino-exchange/1a5d86.20260807_113215_00004_j3ded.external-exchange-0.0/0/

⇒ El camino HMAC escribe de verdad, y no por deducción: por ls.


🔴 ⑥ EL HALLAZGO GRANDE: FTE salva la QUERY, pero el WORKER vuelve roto

Matar un worker con --force --grace-period=0 —que es lo que hace un desalojo de Spot— lo dejó en CrashLoopBackOff permanente, con este error:

ERROR: already running as 12

Causa: /stackable/data es un volumeClaimTemplate, o sea un PVC que sobrevive al pod. El launcher de Trino deja ahí var/run/launcher.pid y una muerte súbita no lo limpia. El pod nuevo lo lee y comprueba si ese PID vive — dentro de su propio namespace, donde los PIDs vuelven a empezar en 1, así que casi siempre hay algo con ese número. Conclusión: «ya estoy corriendo». Y no arranca. Nunca. No se cura solo.

Por qué esto es más importante que el propio FTE: con Spot, esto no es un
incidente — es cada desalojo. El cluster se iría vaciando de workers uno a uno
mientras las queries siguen dando verde, porque FTE las reparte entre los que quedan.
Es decir: el síntoma aparece semanas después, como «el cluster está lento», y la
causa está a siete reinicios de distancia.

Y es invisible para el gate de FTE: la query pasó mientras el worker se moría del
todo. Un gate que mide la query no puede ver un daño que está en el worker.

Arreglo, en el initContainer que ya existía para el exchange manager:

rm -f /stackable/data/var/run/launcher.pid

Verificado del modo que vale: el worker que llevaba 7 reinicios y estaba atascado para siempre arrancó limpio en cuanto el initContainer llevaba el rm.

🔴 ⑦ El gate dio un FALSO VERDE la primera vez — y el control negativo también

La primera pasada del gate salió «verde» y no probaba nada. El segundo worker existía como pod y estaba Ready, pero aún no se había anunciado al coordinador cuando arrancó la query. La query corrió con un worker, matar al otro no le quitó nada, y el resultado fue correcto y el tiempo también.

Peor: el control negativo (retry-policy=NONE) cayó en la misma trampa y también «sobrevivió» — 219,0 s contra los 221,9 s del run con FTE. Dos veredictos idénticos que parecían confirmar FTE y en realidad decían «esta query tarda 220 s con un worker».

La regla: antes de un gate de tolerancia a fallo, comprobar system.runtime.nodes y system.runtime.tasks, nunca kubectl get pods. Un pod Ready no es un worker que esté ejecutando. Sólo cuando se verificó dentro de la ventana que los dos nodos tenían tareas RUNNING el control negativo empezó a fallar como debía.

🔴 ⑧ kubectl rollout status dice «complete» sin haber reiniciado nada

Cambiar retry-policy por el CR actualiza el ConfigMap, no el PodTemplate ⇒ el StatefulSet no rueda, y rollout status responde partitioned roll out complete. El pod seguía con la config vieja 15 minutos después.

Es el C5 de F2 otra vez («cambiar el TrinoCatalog actualizó el ConfigMap pero no el proceso»), con una vuelta de tuerca: allí no había nada que dijera lo contrario; aquí hay un comando que afirma explícitamente que sí se ha desplegado. ⇒ Tras cualquier cambio de configOverrides: kubectl rollout restart, y verificar leyendo /stackable/rwconfig/config.properties dentro del pod.

⚠️ ⑨ Y una que queda ABIERTA: FTE sube el suelo de memoria del coordinador

Durante las pruebas el coordinador se murió una vez con OOMKilled con FTE puesto y el heap ya al 65 %. Tiene sentido: con retry-policy=TASK el coordinador guarda los descriptores de tarea para poder reintentarlas (fault-tolerant-execution-task-descriptor-storage-max-memory = heap × 0,15) además de todo lo demás.

Es la tercera vez que la conclusión es la misma máquina: el hallazgo ⑤ (QoS Guaranteed imposible, sólo 434m libres), el aviso del Helm oficial («fewer pods each having more resources») y ahora esto. n2-standard-2 se ha quedado pequeña, y no por el dato: por el impuesto de sistema, que es fijo por nodo (~1,5 cores y ~1,85 GiB de requests) y en una máquina de 2 vCPU se come el 77 %.

[confirmar] en F6: con n2-standard-4 el mismo impuesto pasa a ser ~38 %, y entonces sí caben un heap holgado, el QoS Guaranteed y el margen que FTE pide.


4 · 🏁 F5·b — EJECUTADO 2026-08-07, y no es lo que el runbook pedía

El runbook pide «autoescalado por COLA, no por CPU (KEDA sobre queries encoladas)». Con Stackable 25.3 no se puede, y las tres medidas que lo cierran:

PruebaResultado
¿tiene TrinoCluster subrecurso /scale?🔴 Nosubresources={"status":{}} ⇒ KEDA no puede apuntar al CR
¿y escalar el StatefulSet por debajo?🔴 El operador lo revierte en 25 s (3 → 2)
¿existe la métrica de cola?✅ Sí — trino_execution_querymanager_queuedqueries en el JMX exporter (:8081/metrics)

La métrica está y el motor no se deja escalar. La única forma de que KEDA funcione es clusterOperation.reconciliationPaused: true — verificado: con la pausa puesta, el escalado externo aguanta. Pero eso apaga la reconciliación entera: a partir de ahí kubectl apply del CR da verde y no cambia nada.

No se hace. En una sola sesión este repo se ha comido tres veces ese fallo exacto —el add del -Xmx, el configOverrides del exchange manager y el rollout status que dice complete sin rodar— y armarlo de forma permanente a cambio de mover workers de 1 a 2 no compensa.

⭐ Y hay una razón ESTRUCTURAL, que sobrevive al operador

No se puede autoescalar por profundidad de cola un sistema cuya cola sólo existe
mientras está encendido.
Con el coordinador a cero no hay queuedqueries que mirar.

«Autoescalado por cola» y «scale-to-zero» suenan a lo mismo y son dos mecanismos con
dos disparadores distintos
: el primero mueve workers de 1 a N con el motor vivo y su
disparador es interno; el segundo apaga el motor y su disparador tiene que venir de
fuera
. El runbook los tenía en la misma línea de la tabla, y no lo están.

⇒ Lo que ahorra los ~160 €/mes de P3 es el segundo, no el primero. Y su disparador
hoy es humano o un cron; el día que exista el Gateway (F6), el disparador natural
es la primera petición que llegue.

Lo que sí se hizo: F5·b1 · suspensión, por la vía nativa del operador

clusterOperation.stopped: true — un solo campo del CRD, sin pausar nada. infra/gke/trino-suspend.sh {on|off|status}.

Medido de punta a punta:

stopped: true → pods de Trino a 0inmediato
4 nodos → 1 nodo~14 min (el autoescalador acordona, consolida y borra)
Despertar desde 1 nodo frío hasta query verde330 s
Tras el ciclo: retry-policy=TASK, count(*)→25, 2 workers activos✅ intacto

💰 Lo que ahorra DE VERDAD — medido, y es bastante menos de lo que promete P3

P3 del doc de coste dice «la parte de cómputo baja un 76 %». Con la suspensión sola,
no. Medido en dos ciclos completos:

nodos€/mes de tasa
Trabajando3 (y llegó a rozar el techo de 4)≈ 297 (4 nodos: ≈ 370)
Suspendido2 — y en otro ciclo bajó a 1≈ 224 (1 nodo: ≈ 151)

La suspensión ahorra 73 €/mes (25 %), o 146 (49 %) si el suelo cae a un nodo. Y
con el ritmo real —suspendido noches y fines de semana, ~76 % de las horas— el ahorro
mensual queda en ~55-111 €, no en el 76 % del cómputo.

Las tres razones de la diferencia, y ninguna es corregible desde el CR:

  1. El suelo no es cero, y oscila entre 1 y 2 nodos. kube-dns tiene 2 réplicas
    con anti-afinidad
    , así que no pueden compartir nodo: mientras su autoescalador
    mantenga 2, hay un nodo entero sosteniendo un solo pod de DNS. En un ciclo bajó a
    1 y en el siguiente se quedó en 2 — es un lazo con histéresis, no un valor fijo.
  2. Los 63 €/mes de gestión (cluster regional) no se tocan. Sólo los quita borrar
    el cluster.
  3. El NAT y la IP trino-lb reservada siguen corriendo.

El 76 % de P3 es de su OTRA forma: borrar el cluster (carbon-compute.sh), no
suspenderlo. La suspensión compra 330 s de despertar; el borrado compra el 76 % y
cuesta rehacer operadores y CRs. Son dos palancas distintas con dos precios distintos,
y el doc de coste las tenía bajo un mismo epígrafe.

⚠️ Dos cosas que el stopped NO apaga, y las dos cuestan dinero:

  1. El opa-bridge — es un Deployment nuestro (f4-opa-bridge.yaml), no lo gestiona el operador de Trino. stopped no lo toca y se queda ocupando hueco de nodo para no autorizar a nadie. El script lo escala aparte.
  2. El nodo de sistema y el cargo de gestión. kube-system necesita dónde vivir ⇒ el pool no baja de 1, y los 63 €/mes de cluster regional se pagan igual. Eso sólo lo quita borrar el cluster (la otra forma de P3, con carbon-compute.sh).

5 · Lo que este approach NO dice

  • No dimensiona el exchange. El bucket cobra almacenamiento y operaciones; sin lifecycle crece sin techo. Se mide con el bucket delante, como avisa el §6 del doc de coste.
  • No decide si merece la pena FTE a esta escala. Con 1-2 workers, FTE añade latencia (todo el shuffle pasa por almacenamiento remoto) a cambio de una resiliencia que sólo se cobra cuando hay desalojos. Con Spot bloqueado, no hay desalojos que absorber.
  • No toca el Gateway (F6) ni el nested-namespace. Son otras fases.