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-clonecon F0→F4 cerradas, el código
fuente de Trino 470 y las cuotas reales del proyectotrino-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 0Medido en
europe-west1, proyectotrino-k8s, el 2026-08-07. Los Spot VM consumen
la cuotaPREEMPTIBLE_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]valor— se 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 rwconfig — y 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:
config | ConfigMap → /stackable/config |
rwconfig | ⭐ emptyDir → /stackable/rwconfig |
catalog | ConfigMap → /stackable/config/catalog (anidado — la pista) |
La vía obvia —montar el fichero con subPath dentro de /stackable/config, imitando lo
que hace catalog— se 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 elcp -RLno falle por el fichero preexistente y que
elinitContainerno se salte elfsGroup/permisos destackable: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:
| Camino | Quién lo usa | Credencial |
|---|---|---|
Filesystem GCS nativo (fs.native-gcs.enabled, gcs.*) | el conector Iceberg | ⭐ ADC ⇒ Workload Identity SÍ |
Exchange manager de FTE (exchange.*) | 👉 F5 | ❌ S3 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,
S3FileSystemExchangeStorageconstruye DOS clientes:
Cliente Para qué Credencial gcsClient(nativo)sólo los deleteStorageOptions.getDefaultInstance()⇒ ADC ✅s3AsyncClientleer y escribir el shuffle HMAC 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 usado nodos que caben hoy (100 GB/nodo) 202 / 250 2 con 50 GB/nodo 102 / 250 5 ⇒ 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 contraDISKS_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 != limitsesBurstabley
puede ser desalojado bajo presión de memoria del nodo. Para Trino,requests=limits
y QoSGuaranteed.»
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-fase | Variable | Estado | |
|---|---|---|---|
| F5·0 | Sitio — liberar cuota SSD (P5) o pool pd-standard; QoS Guaranteed (⑤) | dónde caben los pods | ⬜ bloqueante de todo lo demás |
| F5·a | FTE — exchange manager por initContainer (②) + retry-policy=TASK | tolerancia a fallo | ⬜ requiere decidir el almacén (③) |
| F5·b | Elasticidad — ⛔ el autoescalado por cola NO es posible (ni /scale ni escalar el STS); 🏁 se hace suspensión por clusterOperation.stopped | elasticidad | 🏁 §4 |
| F5·c | Spot (P1) | precio | ⛔ BLOQUEADA 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ón | Precio | |
|---|---|---|
| A | GCS gs:// + HMAC en europe-west1 | ⭐ misma región (la latencia que pide el §7) · ❌ una credencial durable nueva |
| B | R2 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 |
| C | No 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
| Pool | main-pool-3 · n2-standard-2 · discos de 50 GB · autoescalado 1→4 · SA mínima gke-node-carbon@ · GKE_METADATA |
| Cuota SSD | 202/250 → 102/250 ⇒ el techo pasa de 2 nodos a 5 |
| Exchange | gs://carbon-trino-exchange · europe-west1 · lifecycle 1 día · SA trino-exchange@ con objectAdmin sólo sobre ese bucket |
| FTE | retry-policy=TASK + fault-tolerant-execution-task-memory=1GB en los dos roles |
| El fichero | exchange-manager.properties en un Secret, colocado por un initContainer sobre el emptyDir de rwconfig |
| Workers | 2 — sin dos, el gate no se puede ejecutar |
| Artefactos | infra/gke/f5-exchange.sh · el bloque F5·a de infra/gke/f1-trino.yaml |
El gate, con su control negativo
retry-policy | Workers activos en el motor (tareas RUNNING c/u) | Golpe | Resultado |
|---|---|---|---|
TASK | 2 (2 y 2) | matar worker en seco | ✅ 9.999.832 en 242,2 s |
NONE | 2 (4 y 5) | el mismo golpe | 🛑 REMOTE_TASK_ERROR a los 55,6 s |
TASK | 2 | ninguno | 9.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:
| Prueba | Resultado |
|---|---|
¿tiene TrinoCluster subrecurso /scale? | 🔴 No — subresources={"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 hayqueuedqueriesque 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 0 | inmediato |
| 4 nodos → 1 nodo | ~14 min (el autoescalador acordona, consolida y borra) |
| Despertar desde 1 nodo frío hasta query verde | 330 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 Trabajando 3 (y llegó a rozar el techo de 4) ≈ 297 (4 nodos: ≈ 370) Suspendido 2 — 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:
- ⭐ El suelo no es cero, y oscila entre 1 y 2 nodos.
kube-dnstiene 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.- Los 63 €/mes de gestión (cluster regional) no se tocan. Sólo los quita borrar
el cluster.- El NAT y la IP
trino-lbreservada 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:
- El
opa-bridge— es un Deployment nuestro (f4-opa-bridge.yaml), no lo gestiona el operador de Trino.stoppedno lo toca y se queda ocupando hueco de nodo para no autorizar a nadie. El script lo escala aparte. - El nodo de sistema y el cargo de gestión.
kube-systemnecesita 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, concarbon-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.