Caso práctico: Helm, observabilidad y almacenamiento de objetos#
Seguimos sobre el mismo clúster del caso anterior, sin borrar ni reinstalar nada: los nodos que ya tenemos, el NFS de compute-0-0 y la aplicación web-tunombre desplegada con su imagen propia. Lo que cambia es la forma de trabajar. Hasta ahora hemos escrito los YAML a mano y hemos mirado el clúster con kubectl y k9s, que enseña lo que pasa ahora mismo. Un administrador de verdad hace otras dos cosas: instala software empaquetado en lugar de escribirlo, y guarda el histórico de lo que ha pasado para poder mirarlo después.
Vamos a montar tres piezas encima de lo que ya hay:
Helm, el gestor de paquetes de Kubernetes: lo que
aptes a Ubuntu.Prometheus y Grafana, para ver el clúster con gráficas y con histórico.
MinIO, un almacén de objetos compatible con S3, y una copia de seguridad automática del NFS contra él.
Helm, el gestor de paquetes de Kubernetes#
Instalar una aplicación en Kubernetes escribiendo los YAML a mano funciona con tres objetos, como los nuestros. Con treinta, no. Helm empaqueta todos los objetos de una aplicación en un chart, con los valores configurables sacados a un fichero aparte, y lo instala de una vez. Tres palabras que hay que tener claras:
chart: el paquete, con las plantillas de todos los objetos.
values: los valores que rellenan las plantillas (el
values.yaml).release: una instalación concreta de un chart en el clúster, con su nombre y su historial.
Es la misma idea que un paquete .deb, sus ficheros de configuración y el paquete ya instalado en la máquina.
root@compute-0-0:~# curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
Downloading https://get.helm.sh/helm-v3.22.0-linux-amd64.tar.gz
Verifying checksum... Done.
Preparing to install helm into /usr/local/bin
helm installed into /usr/local/bin/helm
root@compute-0-0:~# helm version
version.BuildInfo{Version:"v3.22.0", GitCommit:"144ca65f8501...", GoVersion:"go1.26.8"}
helm version contesta sin tocar el clúster, así que parece que ya está todo. En cuanto le pides algo de verdad, no:
root@compute-0-0:~# helm list
Error: Kubernetes cluster unreachable: Get "http://localhost:8080/version": dial tcp 127.0.0.1:8080: connect: connection refused
Helm, igual que k9s, no es el binario de k3s y no sabe dónde están las credenciales: al no encontrar un kubeconfig prueba la dirección por defecto de Kubernetes, que en k3s no escucha nadie. Lo arregla la misma variable que pusimos para k9s, así que si la dejaste en el ~/.bashrc no hay nada que hacer; si no, es una línea:
root@compute-0-0:~# echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
root@compute-0-0:~# source ~/.bashrc
root@compute-0-0:~# helm list
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
La lista sale vacía, pero ya contesta. Aunque hay más de lo que parece, y se ve pidiendo todos los namespaces:
root@compute-0-0:~# helm list -A
NAME NAMESPACE REVISION STATUS CHART APP VERSION
traefik kube-system 1 deployed traefik-40.1.4+up40.1.0 v3.7.1
traefik-crd kube-system 1 deployed traefik-crd-40.1.4+up40.1.0 v3.7.1
k3s ya estaba usando Helm sin decírnoslo: aquellos dos Pods helm-install-traefik que salían como Completed en kube-system desde el primer día eran exactamente esto, la instalación del Ingress con un chart.
Nuestra aplicación como chart propio#
Antes de instalar paquetes de otros, empaquetamos el nuestro: así se ve qué hay dentro de un chart. helm create deja un esqueleto de ejemplo del que nos quedamos con la estructura:
root@compute-0-0:~/09# helm create web-tunombre-chart
Creating web-tunombre-chart
root@compute-0-0:~/09# tree web-tunombre-chart/
web-tunombre-chart/
├── charts
├── Chart.yaml
├── templates
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ ├── hpa.yaml
│ ├── httproute.yaml
│ ├── ingress.yaml
│ ├── NOTES.txt
│ ├── serviceaccount.yaml
│ ├── service.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
Lo que nos interesa son tres sitios: Chart.yaml (el nombre y la versión del paquete), values.yaml (los valores) y templates/ (los YAML, con huecos). Las plantillas de ejemplo que trae son mucho más complicadas de lo que necesitamos, así que las borramos y ponemos las nuestras, que son exactamente los ficheros del caso anterior con los valores sustituidos por huecos:
root@compute-0-0:~/09# rm -rf web-tunombre-chart/templates/* web-tunombre-chart/charts
web-tunombre-chart/Chart.yaml:
apiVersion: v2
name: web-tunombre
description: La aplicacion web del caso practico anterior, empaquetada como chart
type: application
version: 0.1.0
appVersion: "1.0"
web-tunombre-chart/values.yaml:
replicaCount: 4
image:
repository: web-tunombre
tag: "1.0"
pullPolicy: IfNotPresent
titulo: "Hola, soy tunombre"
usuario: "tunombre"
clave: "MiClaveSecreta"
service:
nodePort: 30080
ingress:
host: web-tunombre.local
nfs:
claimName: pvc-tunombre
En templates/ van cinco ficheros —configmap.yaml, secret.yaml, deployment.yaml, service.yaml e ingress.yaml—, los mismos objetos del caso anterior. Los dos pequeños enseñan la idea entera:
web-tunombre-chart/templates/configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
titulo: {{ .Values.titulo | quote }}
usuario: {{ .Values.usuario | quote }}
web-tunombre-chart/templates/secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-secret
type: Opaque
stringData:
clave: {{ .Values.clave | quote }}
Y el Deployment, que es el de siempre con los valores sacados fuera:
web-tunombre-chart/templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: nginx
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 80
env:
- name: TITULO
valueFrom:
configMapKeyRef:
name: {{ .Release.Name }}-config
key: titulo
- name: USUARIO
valueFrom:
configMapKeyRef:
name: {{ .Release.Name }}-config
key: usuario
- name: CLAVE
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secret
key: clave
volumeMounts:
- name: datos
mountPath: /datos
volumes:
- name: datos
persistentVolumeClaim:
claimName: {{ .Values.nfs.claimName }}
El Service y el Ingress, igual:
web-tunombre-chart/templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
spec:
type: NodePort
selector:
app: {{ .Release.Name }}
ports:
- port: 80
targetPort: 80
nodePort: {{ .Values.service.nodePort }}
web-tunombre-chart/templates/ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}
port:
number: 80
{{ .Values.algo }} es un valor del values.yaml y {{ .Release.Name }} el nombre que le damos al instalar. El | quote pone las comillas por nosotros. Eso es lo que convierte un YAML escrito para un nombre concreto en algo reutilizable.
Dos comprobaciones antes de tocar el clúster. helm lint revisa el paquete y helm template enseña el YAML ya montado, que es como se depura un chart:
root@compute-0-0:~/09# helm lint web-tunombre-chart
==> Linting web-tunombre-chart
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
root@compute-0-0:~/09# helm template prueba web-tunombre-chart | head -20
---
# Source: web-tunombre/templates/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: prueba-secret
type: Opaque
stringData:
clave: "MiClaveSecreta"
---
# Source: web-tunombre/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: prueba-config
data:
titulo: "Hola, soy tunombre"
usuario: "tunombre"
Fíjate en que al llamar a la release prueba, todos los objetos han pasado a llamarse prueba-.... Eso es lo que permite instalar el mismo chart dos veces en el mismo clúster sin que choquen.
Como la aplicación del caso anterior sigue desplegada, la quitamos primero para no chocar con el mismo NodePort y el mismo Ingress. El PersistentVolume y su PVC no se tocan: los queremos:
root@compute-0-0:~/09# kubectl delete -f web-tunombre.yml -f ingress-tunombre.yml -f config-tunombre.yml
deployment.apps "web-tunombre" deleted from default namespace
service "web-tunombre" deleted from default namespace
ingress.networking.k8s.io "web-tunombre" deleted from default namespace
configmap "web-tunombre-config" deleted from default namespace
secret "web-tunombre-secret" deleted from default namespace
secret "web-tunombre-secret" deleted from default namespace
root@compute-0-0:~/09# helm install web-tunombre ./web-tunombre-chart
NAME: web-tunombre
LAST DEPLOYED: Mon Sep 21 15:42:30 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
root@compute-0-0:~/09# helm list
NAME NAMESPACE REVISION STATUS CHART APP VERSION
web-tunombre default 1 deployed web-tunombre-0.1.0 1.0
root@compute-0-0:~/09# kubectl get pods -l app=web-tunombre \
-o custom-columns=ESTADO:.status.phase,NODO:.spec.nodeName --no-headers | sort | uniq -c
1 Running compute-0-0
1 Running compute-0-1
1 Running compute-0-2
1 Running compute-0-3
root@compute-0-0:~/09# curl -s http://web-tunombre.local | head -6
<!DOCTYPE html>
<html>
<head><title>Hola, soy tunombre</title></head>
<body>
<h1>Hola, soy tunombre</h1>
<p>Imagen propia construida con Dockerfile</p>
Seis objetos, un comando, y la misma página de antes. Y ahora lo importante: todo lo que hicimos a mano en el caso anterior, Helm lo hace sobre el paquete entero, no objeto a objeto.
root@compute-0-0:~/09# helm upgrade web-tunombre ./web-tunombre-chart --set replicaCount=8
Release "web-tunombre" has been upgraded. Happy Helming!
NAME: web-tunombre
REVISION: 2
STATUS: deployed
root@compute-0-0:~/09# kubectl get pods --no-headers | wc -l
8
root@compute-0-0:~/09# helm rollback web-tunombre 1
Rollback was a success! Happy Helming!
root@compute-0-0:~/09# kubectl get pods --no-headers | wc -l
4
root@compute-0-0:~/09# helm history web-tunombre
REVISION UPDATED STATUS CHART DESCRIPTION
1 Mon Sep 21 15:42:30 2026 superseded web-tunombre-0.1.0 Install complete
2 Mon Sep 21 15:42:53 2026 superseded web-tunombre-0.1.0 Upgrade complete
3 Mon Sep 21 15:43:12 2026 deployed web-tunombre-0.1.0 Rollback to 1
A mano (caso anterior) |
Con Helm |
|---|---|
|
|
|
|
|
|
|
|
editar el YAML |
cambiar un valor del |
La diferencia no es escribir menos: es que el rollback de Helm devuelve la aplicación entera —Deployment, ConfigMap, Secret, Service e Ingress— a como estaba, mientras que kubectl rollout undo solo deshace el Deployment. Fíjate además en que el rollback es la revisión 3, no vuelve a la 1: el historial nunca se reescribe, solo crece.
Un chart ajeno: MinIO#
MinIO es un almacén de objetos compatible con la API S3 de Amazon: en vez de ficheros y directorios como el NFS, guarda objetos dentro de buckets, y se habla con él por HTTP. Es lo que hay por dentro de muchas copias de seguridad y de casi cualquier aplicación que guarde imágenes o documentos, y es la pieza que más se parece a lo que luego encontraréis en la nube.
Lo instalamos sin escribir un solo YAML, que es de lo que se trata:
root@compute-0-0:~/09# helm repo add minio https://charts.min.io/
"minio" has been added to your repositories
root@compute-0-0:~/09# helm repo update
Update Complete. ⎈Happy Helming!⎈
root@compute-0-0:~/09# helm search repo minio
NAME CHART VERSION APP VERSION DESCRIPTION
minio/minio 5.4.0 RELEASE.2024-12-18T13-15-44Z High Performance Object Storage
Antes de instalar nada conviene mirar qué se puede configurar, que es el equivalente a leer el fichero de configuración de un paquete de Debian:
root@compute-0-0:~/09# helm show values minio/minio | head -40
Ahí se ve, entre otras cosas, que el chart está pensado para un servidor de verdad:
mode: distributed ## other supported values are "standalone"
replicas: 16
resources:
requests:
memory: 16Gi
Advertencia
16 Gi de memoria y 16 réplicas por defecto. Si instalas el chart tal cual, el Pod se queda en Pending para siempre y kubectl describe dirá que ningún nodo tiene memoria suficiente. Es el error más habitual al usar un chart ajeno por primera vez: los valores por defecto son los del fabricante, no los tuyos.
Nuestros valores van en un fichero propio, minio-values.yaml, en lugar de encadenar diez --set:
mode: standalone
replicas: 1
rootUser: "tunombre"
rootPassword: "MiClaveSecreta"
persistence:
enabled: true
size: 5Gi
resources:
requests:
memory: 512Mi
consoleIngress:
enabled: true
hosts:
- minio-tunombre.local
root@compute-0-0:~/09# kubectl create namespace minio
namespace/minio created
root@compute-0-0:~/09# helm install minio minio/minio -n minio -f minio-values.yaml
NAME: minio
NAMESPACE: minio
STATUS: deployed
REVISION: 1
root@compute-0-0:~/09# kubectl get all,pvc -n minio
NAME READY STATUS RESTARTS AGE
pod/minio-5bb5cf69fd-b7cjc 1/1 Running 0 59s
NAME TYPE CLUSTER-IP PORT(S) AGE
service/minio ClusterIP 10.43.209.10 9000/TCP 59s
service/minio-console ClusterIP 10.43.141.196 9001/TCP 59s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/minio 1/1 1 1 59s
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
persistentvolumeclaim/minio Bound pvc-c391c89e-9701-4a87... 5Gi RWO local-path
Fíjate en lo que ha aparecido sin haberlo escrito: un Deployment, dos Services (uno para la API en el 9000 y otro para la consola web en el 9001), un Ingress, un Secret con las credenciales y un PersistentVolumeClaim. El PVC lo ha resuelto local-path, el almacenamiento por defecto de k3s del que hablamos al final del caso anterior, así que los datos viven en el disco del nodo donde haya caído el Pod.
El nombre nuevo hay que añadirlo al /etc/hosts de todos los nodos. Ojo con el bucle: compute-0-0 no puede entrar por ssh en sí mismo, así que el primero se hace en local:
root@compute-0-0:~/09# echo "172.16.0.10 minio-tunombre.local grafana-tunombre.local" >> /etc/hosts
root@compute-0-0:~/09# for n in 1 2 3 4; do
ssh -n 172.16.0.1$n "echo '172.16.0.10 minio-tunombre.local grafana-tunombre.local' >> /etc/hosts"
done
root@compute-0-0:~/09# kubectl get ingress -A
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS
default web-tunombre traefik web-tunombre.local 172.16.0.10,172.16.0.11,172.16.0.12,172.16.0.13 80
minio minio-console traefik minio-tunombre.local 172.16.0.10,172.16.0.11,172.16.0.12,172.16.0.13 80
root@compute-0-0:~/09# curl -s -o /dev/null -w "%{http_code}\n" http://minio-tunombre.local
200
El mismo Traefik reparte ahora dos aplicaciones distintas por el puerto 80 según el nombre que se pida, que es justo lo que decíamos al montar el Ingress.
Desde la consola web (http://minio-tunombre.local, con el usuario y la clave del values.yaml) se crea un bucket con el ratón, pero también se puede desde la línea de órdenes con mc, el cliente de MinIO, que viene dentro de la propia imagen. Como el Pod tiene un nombre que cambia en cada despliegue, lo cómodo es entrar por el Deployment:
root@compute-0-0:~/09# kubectl -n minio exec deploy/minio -- \
mc alias set local http://localhost:9000 tunombre MiClaveSecreta
Added `local` successfully.
root@compute-0-0:~/09# kubectl -n minio exec deploy/minio -- mc mb local/backup-tunombre
Bucket created successfully `local/backup-tunombre`.
root@compute-0-0:~/09# kubectl -n minio exec deploy/minio -- mc ls local
[2026-09-21 15:45:21 UTC] 0B backup-tunombre/
Observabilidad: Prometheus y Grafana#
kubectl top y k9s enseñan lo que pasa en este instante. Cuando un nodo se cayó durante la noche, o cuando hay que explicar por qué a las once se quedó todo lento, hace falta histórico, y eso son dos programas: Prometheus, que va preguntando cada poco a todo el clúster y guarda los números, y Grafana, que los pinta.
Montarlos a mano son decenas de objetos. Aquí es donde Helm deja de ser una comodidad:
root@compute-0-0:~/09# helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
root@compute-0-0:~/09# helm repo update
monitor-values.yaml:
# k3s ejecuta el plano de control dentro de un unico proceso: estos componentes
# no existen como servicios sueltos y el chart los daria por caidos
kubeControllerManager:
enabled: false
kubeScheduler:
enabled: false
kubeEtcd:
enabled: false
kubeProxy:
enabled: false
alertmanager:
enabled: false
prometheus:
prometheusSpec:
retention: 2d
# para que recoja tambien los ServiceMonitor de otros charts, como el de MinIO
serviceMonitorSelectorNilUsesHelmValues: false
resources:
requests:
memory: 400Mi
grafana:
adminPassword: "MiClaveSecreta"
ingress:
enabled: true
hosts:
- grafana-tunombre.local
root@compute-0-0:~/09# kubectl create namespace monitoring
namespace/monitoring created
root@compute-0-0:~/09# helm install monitor prometheus-community/kube-prometheus-stack \
-n monitoring -f monitor-values.yaml
NAME: monitor
NAMESPACE: monitoring
STATUS: deployed
REVISION: 1
Tarda varios minutos, porque son bastantes imágenes. Al acabar:
root@compute-0-0:~/09# kubectl get pods -n monitoring -o wide
NAME READY STATUS NODE
monitor-grafana-946bd9ff6-dv86j 3/3 Running compute-0-1
monitor-kube-prometheus-st-operator-585c879bb6-tm58w 1/1 Running compute-0-1
monitor-kube-state-metrics-657db597d-s4cf8 1/1 Running compute-0-2
monitor-prometheus-node-exporter-626mt 1/1 Running compute-0-1
monitor-prometheus-node-exporter-7kw2p 1/1 Running compute-0-3
monitor-prometheus-node-exporter-bgp6d 1/1 Running compute-0-2
monitor-prometheus-node-exporter-nn7cb 1/1 Running compute-0-0
prometheus-monitor-kube-prometheus-st-prometheus-0 2/2 Running compute-0-2
Un solo comando ha dejado funcionando cuatro cosas distintas:
Prometheus, que guarda los números (y es un
StatefulSet: por eso acaba en-0).Grafana, que los pinta.
node-exporter, un
DaemonSet: una copia en cada nodo, igual que elsvclbde Traefik, porque los datos de una máquina solo se pueden recoger desde dentro de esa máquina.kube-state-metrics, que traduce a números el estado de los objetos de Kubernetes: cuántos Pods hay, cuántos esperados, cuántos reiniciados.
Y un quinto que no es un servicio sino un operador: el prometheus-operator es quien ha creado a Prometheus a partir de un objeto de configuración. Los operadores son la forma en que Kubernetes se amplía a sí mismo.
Advertencia
Los cuatro enabled: false del principio del values.yaml no son un capricho. El chart está pensado para una instalación con kubeadm, donde kube-scheduler, kube-controller-manager, etcd y kube-proxy son procesos separados con su propio puerto de métricas. k3s los ejecuta todos dentro de un único binario, así que esos puertos no existen y Prometheus los marcaría como DOWN para siempre, con el panel lleno de rojo sin que nada esté roto.
Quién está contestando y quién no se mira en la propia página de Prometheus (Status → Target health), o desde la línea de órdenes:
root@compute-0-0:~/09# IP=$(kubectl get svc -n monitoring monitor-kube-prometheus-st-prometheus \
-o jsonpath='{.spec.clusterIP}')
root@compute-0-0:~/09# curl -s "http://$IP:9090/api/v1/targets?state=any" | \
python3 -c 'import sys,json; d=json.load(sys.stdin)["data"]["activeTargets"]; [print(t["health"], t["labels"].get("job"), t["scrapeUrl"]) for t in d]'
up apiserver https://172.16.0.10:6443/metrics
up coredns http://10.42.0.55:9153/metrics
up kubelet https://172.16.0.10:10250/metrics
up kubelet https://172.16.0.10:10250/metrics/cadvisor
up kubelet https://172.16.0.10:10250/metrics/probes
up kubelet https://172.16.0.11:10250/metrics
...
up kubelet https://172.16.0.13:10250/metrics/probes
up kube-state-metrics http://10.42.2.58:8080/metrics
up monitor-grafana http://10.42.1.53:3000/metrics
up node-exporter http://172.16.0.10:9100/metrics
up node-exporter http://172.16.0.11:9100/metrics
up node-exporter http://172.16.0.12:9100/metrics
up node-exporter http://172.16.0.13:9100/metrics
Son veinticuatro destinos y ninguno caído. Aquí se ve algo que no se aprecia en ningún otro sitio: Prometheus va a buscar los datos, uno por uno, a direcciones que ha descubierto solo. Nadie le ha dado una lista. Y fíjate en que hay un node-exporter por máquina y tres destinos de kubelet por máquina: si añades un nodo, aparecen los suyos sin tocar nada.
Nota
En máquinas justas de memoria puede aparecer el apiserver en down con un context deadline exceeded: no es un fallo de configuración, es que el servidor de la API ha tardado más de lo que Prometheus espera. Suele recuperarse solo en la siguiente pasada.
Grafana se entra por su nombre, con el usuario admin y la clave del values.yaml:
root@compute-0-0:~/09# curl -s -o /dev/null -w "%{http_code}\n" http://grafana-tunombre.local
302
El 302 es la redirección a la pantalla de entrada: desde el navegador del anfitrión hay que ir a http://grafana-tunombre.local. Y no hay que dibujar ningún panel, porque el chart trae veinticuatro cuadros de mando ya hechos, cada uno en un ConfigMap:
root@compute-0-0:~/09# kubectl get configmaps -n monitoring -l grafana_dashboard=1 --no-headers | wc -l
24
Busca Kubernetes / Compute Resources / Cluster y Node Exporter / Nodes, y repite mirándolos los dos experimentos del caso anterior: escalar a ocho copias y apagar un nodo entero.
root@compute-0-0:~/09# helm upgrade web-tunombre ./web-tunombre-chart --set replicaCount=8
... PENDIENTE: pantallazo de Grafana con el salto de Pods y de memoria ...
... PENDIENTE: pantallazo del nodo cayendo y volviendo ...
La diferencia con k9s es que esto queda grabado: dentro de una hora seguirá ahí el hueco, y esa es justo la información con la que se explica una caída que nadie vio.
Las métricas de MinIO#
Un sistema de monitorización sirve de poco si solo mira al propio Kubernetes. MinIO publica sus números él solo, sin ningún programa intermedio; solo hay que decirle a Prometheus que vaya a buscarlos, y eso se hace con un objeto ServiceMonitor, que es lo que el chart de MinIO sabe crear:
# se añade al minio-values.yaml
metrics:
serviceMonitor:
enabled: true
includeNode: true
Advertencia
Hacen falta las dos líneas. La plantilla del chart empieza con {{- if and .Values.metrics.serviceMonitor.enabled .Values.metrics.serviceMonitor.includeNode }}, así que con solo enabled: true no se crea nada, sin ningún error y sin ninguna pista. Cuando un chart ajeno «no hace caso» a un valor, la respuesta está en su plantilla: helm pull minio/minio --untar la descarga para poder leerla.
root@compute-0-0:~/09# helm upgrade minio minio/minio -n minio -f minio-values.yaml
REVISION: 4
STATUS: deployed
root@compute-0-0:~/09# kubectl get servicemonitors -A | grep minio
minio minio 5s
Un minuto después, Prometheus ya lo está recogiendo, y tiene datos:
root@compute-0-0:~/09# curl -s "http://$IP:9090/api/v1/targets?state=any" | \
python3 -c 'import sys,json; d=json.load(sys.stdin)["data"]["activeTargets"]; [print(t["health"], t["labels"].get("job"), t["scrapeUrl"]) for t in d if t["labels"].get("job")=="minio"]'
up minio http://10.42.2.53:9000/minio/v2/metrics/node
root@compute-0-0:~/09# curl -s --data-urlencode 'query=count({job="minio"})' "http://$IP:9090/api/v1/query"
{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[...,"168"]}]}}
168 series distintas sobre MinIO, sin tocar la configuración de Prometheus. Así es como crece un sistema de monitorización de verdad: cada cosa nueva que se instala se apunta sola.
Copia de seguridad del NFS contra MinIO#
Cerramos el círculo. En la tarea de cron y systemd montamos copias de seguridad que se ejecutaban en una máquina: si esa máquina estaba apagada a su hora, esa noche no había copia. Aquí la copia la mantiene el clúster: un CronJob es un Job que se lanza según un horario, y lo arranca cualquier nodo que esté vivo.
Lo que copiamos es el NFS /srv/tunombre del caso anterior, que ya tenemos en el pvc-tunombre, contra el bucket de MinIO. La herramienta es mc mirror, y las credenciales no van escritas en el YAML: van en un Secret, como aprendimos con la clave de la página.
backup-tunombre.yml:
apiVersion: v1
kind: Secret
metadata:
name: minio-credenciales
type: Opaque
stringData:
usuario: "tunombre"
clave: "MiClaveSecreta"
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: backup-tunombre
spec:
schedule: "*/5 * * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: mc
image: quay.io/minio/mc
env:
- name: USUARIO
valueFrom:
secretKeyRef:
name: minio-credenciales
key: usuario
- name: CLAVE
valueFrom:
secretKeyRef:
name: minio-credenciales
key: clave
command:
- sh
- -c
- |
echo "copia desde $HOSTNAME el $(date)" > /datos/ultima-copia.txt
mc alias set destino http://minio.minio.svc.cluster.local:9000 "$USUARIO" "$CLAVE"
mc mirror --overwrite /datos destino/backup-tunombre
volumeMounts:
- name: datos
mountPath: /datos
volumes:
- name: datos
persistentVolumeClaim:
claimName: pvc-tunombre
Tres detalles que dan más guerra de la que parece:
La imagen es
quay.io/minio/mc, nominio/mc. La de Docker Hub contestapull access denied ... insufficient_scopey el Pod se queda enImagePullBackOff;quay.ioes el registro que usa el propio chart de MinIO.Dentro de esa imagen no existe el mandato ``hostname`` (es una imagen mínima, sin las herramientas de siempre), así que se usa la variable
$HOSTNAME, que Kubernetes ya deja puesta con el nombre del Pod.La dirección de MinIO es
minio.minio.svc.cluster.local, es decir servicio.namespace*``.svc.cluster.local``, el nombre que le da **coredns*, el DNS interno que vimos enkube-systemal montar el clúster. Dentro del clúster nadie usa direcciones IP.
El schedule es el mismo formato de cinco campos de crontab que ya conoces. Lo que cambia es quién lo ejecuta.
root@compute-0-0:~/09# kubectl apply -f backup-tunombre.yml
secret/minio-credenciales created
cronjob.batch/backup-tunombre created
root@compute-0-0:~/09# kubectl get cronjob
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE
backup-tunombre */5 * * * * False 0 <none> 1s
Esperar cinco minutos a ver si funciona es incómodo. Se puede lanzar una ejecución a mano, a partir del mismo CronJob, sin tocar el horario:
root@compute-0-0:~/09# kubectl create job prueba-backup --from=cronjob/backup-tunombre
job.batch/prueba-backup created
root@compute-0-0:~/09# kubectl logs job/prueba-backup
Added `destino` successfully.
`/datos/ultima-copia.txt` -> `destino/backup-tunombre/ultima-copia.txt`
┌───────┬─────────────┬──────────┬────────────┐
│ Total │ Transferred │ Duration │ Speed │
│ 119 B │ 119 B │ 00m00s │ 3.65 KiB/s │
└───────┴─────────────┴──────────┴────────────┘
Dejándolo un rato, las ejecuciones aparecen solas cada cinco minutos, cada una con su Job y su Pod:
root@compute-0-0:~/09# kubectl get jobs
NAME STATUS COMPLETIONS DURATION AGE
backup-tunombre-29833430 Complete 1/1 43s 10m
backup-tunombre-29833435 Complete 1/1 7s 5m40s
backup-tunombre-29833440 Complete 1/1 6s 40s
root@compute-0-0:~/09# cat /srv/tunombre/ultima-copia.txt
copia desde backup-tunombre-29833440-5rg86 el Mon Sep 21 16:00:02 UTC 2026
root@compute-0-0:~/09# kubectl -n minio exec deploy/minio -- mc ls local/backup-tunombre
[2026-09-21 15:47:49 UTC] 74B STANDARD prueba.txt
[2026-09-21 16:00:03 UTC] 75B STANDARD ultima-copia.txt
El fichero está en los dos sitios: en el NFS, que es un directorio de compute-0-0, y dentro del bucket, que es un objeto guardado en el disco de otro nodo. Fíjate también en que la primera ejecución tardó 43 segundos y las siguientes 6: la primera se tuvo que descargar la imagen.
Y la prueba que de verdad importa, la que en la tarea de cron no se podía hacer: apaga el nodo donde se ejecutó la última copia y espera a la siguiente. Sale igual, en otra máquina, sin que nadie toque nada.
... PENDIENTE: salida con el nodo apagado ...
Un nodo nuevo en un clúster que ya tiene cosas#
Merece la pena añadir un nodo ahora, con el clúster lleno, y ver qué se apaña solo y qué no. Creamos una sexta máquina, compute-0-5, clon enlazado de MV Ubuntu Server con 4 GB de memoria y la tarjeta en "Red interna" : 172.16.0.15/16. Para poder llamarla por su nombre desde compute-0-0, como a las demás, la añadimos a su /etc/hosts (si no la tienes ya), y la unimos con el mismo comando de siempre, lanzado desde compute-0-0:
root@compute-0-0:~# echo "172.16.0.15 compute-0-5" >> /etc/hosts
root@compute-0-0:~# ssh -n compute-0-5 "curl -fL https://get.k3s.io | \
K3S_URL=https://172.16.0.10:6443 \
K3S_TOKEN='$(cat /var/lib/rancher/k3s/server/node-token)' sh -"
[INFO] systemd: Enabling k3s-agent unit
[INFO] systemd: Starting k3s-agent
root@compute-0-0:~# kubectl get nodes
NAME STATUS ROLES AGE VERSION
compute-0-0 Ready control-plane 6d22h v1.36.4+k3s1
compute-0-1 Ready <none> 6d22h v1.36.4+k3s1
compute-0-2 Ready <none> 6d22h v1.36.4+k3s1
compute-0-3 Ready <none> 6d22h v1.36.4+k3s1
compute-0-4 Ready <none> 6d21h v1.36.4+k3s1
compute-0-5 Ready <none> 54s v1.36.4+k3s1
Lo que se arregla solo, sin que nadie lo pida:
el DaemonSet de node-exporter le pone su copia y Prometheus empieza a recogerlo;
el svclb de Traefik hace lo mismo, y los tres Ingress pasan a anunciar seis direcciones.
Se ve pidiendo los Pods de ese nodo:
root@compute-0-0:~# kubectl get pods -A -o wide --field-selector spec.nodeName=compute-0-5
Lo que no se arregla solo es nuestra aplicación: en compute-0-5 no hay ningún Pod de web-tunombre. El planificador solo decide dónde va un Pod cuando se crea, y los que ya estaban funcionando en los otros cinco nodos se quedan donde están. Kubernetes no los mueve para repartir la carga.
Para que la aplicación use el nodo nuevo hay que recrear los Pods, pero antes hay que preparar el nodo, porque le faltan justo las dos cosas que ya sabemos del caso anterior:
La imagen propia no está en su containerd, porque cada nodo tiene su propio almacén: hay que llevársela con
scpe importarla conk3s ctr images import, como hicimos con los demás.El cliente de NFS tampoco está: sin
nfs-commonel Pod se quedaría enContainerCreatingcon elexit status 32de la sección de almacenamiento persistente.
root@compute-0-0:~# scp web-tunombre-1.0.tar compute-0-5:/root/
root@compute-0-0:~# ssh compute-0-5 "k3s ctr images import /root/web-tunombre-1.0.tar"
root@compute-0-0:~# ssh compute-0-5 "k3s ctr images ls | grep web-tunombre"
root@compute-0-0:~# ssh compute-0-5 "apt install -y nfs-common"
Ahora sí, recreamos los Pods. rollout restart crea Pods nuevos uno a uno y va borrando los viejos, sin cortar el servicio, y como son nuevos el planificador ya cuenta con compute-0-5:
root@compute-0-0:~# kubectl rollout restart deployment/web-tunombre
deployment.apps/web-tunombre restarted
root@compute-0-0:~# kubectl rollout status deployment/web-tunombre
deployment "web-tunombre" successfully rolled out
root@compute-0-0:~# kubectl get pods -o wide
Por defecto el planificador tiende a repartir las réplicas entre los nodos, pero no lo garantiza: a compute-0-5 le puede tocar una, dos o, alguna vez, ninguna. Si hay que asegurar el reparto, el Deployment admite topologySpreadConstraints, que impiden que un nodo tenga más réplicas que otro.
Esa es la diferencia entre lo que Kubernetes mantiene —los Pods, los servicios, la monitorización— y lo que sigue siendo administración de sistemas de toda la vida: los paquetes y las imágenes de cada máquina. Un nodo recién añadido está en el clúster desde el primer minuto, pero no sirve para todo hasta que alguien lo prepara. En un sistema de verdad eso se resuelve con un registro de imágenes y con el Ansible de la tarea 08.
Deshacer lo instalado#
Todo lo que ha entrado por Helm sale por Helm, que es otra de las razones de usarlo: no hay que acordarse de qué objetos creó cada cosa.
root@compute-0-0:~/09# helm list -A
NAME NAMESPACE REVISION STATUS CHART APP VERSION
minio minio 4 deployed minio-5.4.0 RELEASE.2024-12-18T13-15-44Z
monitor monitoring 1 deployed kube-prometheus-stack-91.4.1 v0.94.0
traefik kube-system 1 deployed traefik-40.1.4+up40.1.0 v3.7.1
traefik-crd kube-system 1 deployed traefik-crd-40.1.4+up40.1.0 v3.7.1
web-tunombre default 3 deployed web-tunombre-0.1.0 1.0
root@compute-0-0:~/09# helm uninstall monitor -n monitoring
root@compute-0-0:~/09# helm uninstall minio -n minio
root@compute-0-0:~/09# helm uninstall web-tunombre
Los dos traefik de la lista no se tocan: son de k3s, y si los quitas te quedas sin Ingress.
Nota
helm uninstall no borra los PersistentVolumeClaim: los datos de MinIO y de Prometheus siguen ocupando disco a propósito, para que una reinstalación los recupere. Si quieres el espacio de vuelta hay que borrarlos a mano con kubectl delete pvc, y los namespaces con kubectl delete namespace.