Día 66 - Deploy de MySQL en Kubernetes (PV + PVC + Secrets + Deployment + Service)
Problema / Desafío
El equipo de Nautilus va a desplegar un servidor MySQL en el cluster con todos los componentes que requiere una DB "production-like" en K8s: storage persistente, credenciales en Secrets, env vars desde secretKeyRef, y un Service NodePort para acceso externo. Es la primera vez en el journal que se ensamblan todas las primitivas a la vez en un solo stack.
Requirements:
- PV
mysql-pv, capacity250Mi, parámetros libres - PVC
mysql-pv-claim, request250Mi - Deployment
mysql-deployment, imagen MySQL libre, PV montado en/var/lib/mysql - Service
mysql, tipoNodePort,nodePort: 30007 - Tres Secrets:
mysql-root-pass→password=YUIidhb667mysql-user-pass→username=kodekloud_joy,password=ksH85UJjhbmysql-db-url→database=kodekloud_db10- Env vars en el container, todas vía
secretKeyRef: MYSQL_ROOT_PASSWORD←mysql-root-pass.passwordMYSQL_DATABASE←mysql-db-url.databaseMYSQL_USER←mysql-user-pass.usernameMYSQL_PASSWORD←mysql-user-pass.password
El lab cierra el círculo de los conceptos vistos entre los Días 60 y 65: storage persistente (60), Secrets como fuente de env vars (62), stack multi-componente en un namespace (63), troubleshooting de manifests rotos (64–65).
Conceptos clave
Anatomía del stack — qué recurso depende de qué
┌─────────────────────────────────────────────────────────────────┐
│ Cluster │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ PV mysql-pv │◄───────┤ PVC mysql-pv-claim │ │
│ │ 250Mi, hostPath │ bind │ 250Mi, RWO, manual SC │ │
│ └──────────────────┘ └────────────┬───────────┘ │
│ │ claimName │
│ ▼ │
│ ┌────────────┐ ┌────────────────────────┐ │
│ │ Secret │ │ Deployment │ │
│ │ mysql-root │◄─── secretKeyRef ┤ mysql-deployment │ │
│ │ mysql-user │◄─── secretKeyRef │ ┌────────────────────┐ │ │
│ │ mysql-db │◄─── secretKeyRef │ │ Pod (replica 1) │ │ │
│ └────────────┘ │ │ container mysql │ │ │
│ │ │ volumeMount /var/ │ │ │
│ │ │ lib/mysql │ │ │
│ │ └────────────────────┘ │ │
│ └───────────┬────────────┘ │
│ │ selector │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Service mysql │ │
│ │ type: NodePort │ │
│ │ port 3306 → targetPort 3306, nodePort 30007 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Cinco tipos de recursos (PV, PVC, Secret×3, Deployment, Service) con relaciones por nombre:
| Relación | Cómo se establece | Si se rompe |
|---|---|---|
| PVC → PV | storageClassName + accessModes + storage matchean |
PVC queda en Pending, Pod no schedulea |
| Pod → PVC | volumes[].persistentVolumeClaim.claimName |
Pod queda en Pending por unbound PVC |
| Pod → Secret (vía env) | valueFrom.secretKeyRef.name + .key |
Pod queda en CreateContainerError con secret not found |
| Service → Pod | service.spec.selector matchea pod.metadata.labels |
Endpoints: <none>, sin tráfico al Pod |
| Deployment → Pod | selector.matchLabels matchea template.labels |
Deployment falla al crear el ReplicaSet |
data: vs stringData: en Secrets (anclar bien)
| Campo | Qué espera | Quién codifica | Cuándo usar |
|---|---|---|---|
data: |
Valores ya en base64 | El que escribe el YAML | Cuando los valores ya están codificados (export de otro cluster) |
stringData: |
Valores planos | K8s al hacer apply | Al escribir el YAML a mano (este lab) |
El error de este lab: usar
Fix: usardata:con valores planos (password: YUIidhb667) hace que K8s intente decodificarYUIidhb667como base64. Como tiene caracteres válidos pero no respeta la longitud múltiplo de 4 y el padding, falla con:stringData:para los tres Secrets, o codificar manualmente conecho -n 'YUIidhb667' | base64.
PV hostPath — qué es y por qué es solo para labs
El lab termina usando hostPath como volume type del PV. Es la forma más simple de tener un PV: K8s monta un directorio del nodo donde se schedulea el Pod.
| Tipo de PV | Cuándo usar | Problemas |
|---|---|---|
hostPath |
Labs, single-node clusters, dev local | No funciona en multi-node (si el Pod schedulea a otro nodo, no encuentra los datos) |
nfs |
Compartir entre Pods de distintos nodos | Depende de un servidor NFS externo |
awsElasticBlockStore, gcePersistentDisk, azureDisk |
Producción cloud (legacy in-tree drivers) | Deprecated en favor de CSI drivers |
| CSI driver | Producción real moderna (EBS, GCE PD, Azure Disk, Ceph, Rook, Longhorn) | Requiere instalar el driver en el cluster |
local |
Mejor que hostPath en multi-node — pin del Pod a un nodo específico |
Igual no hay HA si el nodo cae |
La razón por la que
hostPath"funciona" en este lab es porque KodeKloud arma un cluster single-node. En cualquier cluster real con más de un nodo, perdés datos al primer reschedule.
accessModes del PV — significado real
Los modos definen cómo se monta el volumen, no cuántos clientes pueden escribir simultáneamente:
| Modo | Sigla | Significado real |
|---|---|---|
ReadWriteOnce |
RWO | Montable como rw en un solo nodo a la vez (varios Pods del mismo nodo pueden compartirlo) |
ReadOnlyMany |
ROX | Montable como ro en múltiples nodos |
ReadWriteMany |
RWX | Montable como rw en múltiples nodos (requiere backend que lo soporte: NFS, CephFS) |
ReadWriteOncePod (1.22+) |
RWOP | Montable por un solo Pod del cluster (no del nodo) — más estricto que RWO |
Para una DB single-replica, ReadWriteOnce es lo correcto. Si después se quiere escalar a réplicas, hace falta otra arquitectura (StatefulSet + storage replicado).
El bug del lab: typo
ReadWriteOne(sin lac) → K8s rechaza con regex de validación. Los nombres son enums, no admiten variantes.
kubectl apply -f con múltiples recursos — comportamiento al error
Cuando un archivo YAML contiene varios documentos separados por ---, kubectl apply -f los procesa uno por uno:
- Cada recurso válido se crea/actualiza
- Cada recurso inválido es rechazado individualmente
- El comando sigue procesando los siguientes
- Al final reporta todos los errores juntos
Esto significa que un apply parcial puede dejar el cluster en un estado inconsistente: el Deployment y el PVC quedan creados, pero los Secrets que el Deployment necesita no se crearon. El Pod queda esperando los Secrets que nunca llegan.
Patrón operacional: después de un apply con errores, fixear y re-aplicar sin borrar nada. Los recursos ya creados se ven como
unchangedy solo se aplican los nuevos / corregidos.
Pasos
- Escribir el manifest con los 7 recursos separados por
---(PV, PVC, Deployment, 3 Secrets, Service) kubectl apply -f mysql.yaml— observar los errores- Fixear los Secrets: cambiar
data:porstringData: - Fixear el PV: corregir indentación de
accessModes(al nivel despec, no decapacity) y typo (ReadWriteOnce) - Fixear el PV: agregar el
volume type(hostPath) - Re-aplicar hasta que
kubectl get podsmuestreRunning - Validar con
kubectl execque las env vars se cargaron desde los Secrets
Comandos / Código
Manifest final (con los 4 bugs corregidos)
apiVersion: v1
kind: PersistentVolume
metadata:
name: mysql-pv
spec:
storageClassName: manual
capacity:
storage: 250Mi
accessModes: # ← fix 2: al nivel de spec, NO bajo capacity
- ReadWriteOnce # ← fix 3: con la "c" final
hostPath: # ← fix 4: agregar el volume type
path: "/mnt/data"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-pv-claim
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 250Mi
---
apiVersion: v1
kind: Secret
metadata:
name: mysql-root-pass
type: Opaque
stringData: # ← fix 1: stringData en lugar de data
password: YUIidhb667
---
apiVersion: v1
kind: Secret
metadata:
name: mysql-user-pass
type: Opaque
stringData:
username: kodekloud_joy
password: ksH85UJjhb
---
apiVersion: v1
kind: Secret
metadata:
name: mysql-db-url
type: Opaque
stringData:
database: kodekloud_db10
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql-deployment
labels:
app: mysql-deployment
spec:
replicas: 1
selector:
matchLabels:
app: mysql-deployment
template:
metadata:
labels:
app: mysql-deployment
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: mysql-pv-claim
containers:
- name: mysql-deployment
image: mysql:latest
ports:
- containerPort: 3306
volumeMounts:
- name: data
mountPath: "/var/lib/mysql"
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-root-pass
key: password
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: mysql-db-url
key: database
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: mysql-user-pass
key: username
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-user-pass
key: password
---
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app: mysql-svc
spec:
type: NodePort
selector:
app: mysql-deployment
ports:
- port: 3306
targetPort: 3306
nodePort: 30007
Autopsia de los 4 bugs encontrados en cadena
Primer apply
persistentvolumeclaim/mysql-pv-claim created
deployment.apps/mysql-deployment created
service/mysql created
Error: PersistentVolume ... quantities must match the regular expression ...
Error: Secret ... illegal base64 data at input byte 8
Error: Secret ... illegal base64 data at input byte 8
Error: Secret ... illegal base64 data at input byte 9
Notar que el PVC, Deployment y Service se crearon igual. Los errores son del PV y de los 3 Secrets. El Pod queda en
Pendingporque el PVC está unbound (el PV no existe).
Bug 1 — Secrets con data: en lugar de stringData:
# ❌ Mal
data:
password: YUIidhb667 # K8s intenta decodificar "YUIidhb667" como base64 → falla
# ✅ Bien
stringData:
password: YUIidhb667 # K8s lo codifica al apply
Por qué falla con
data:: base64 espera un string que sea múltiplo de 4 caracteres y que use solo el alfabetoA-Z a-z 0-9 + /con=de padding.YUIidhb667tiene 10 caracteres (no múltiplo de 4) y7final no es válido como último byte sin padding.
Bug 2 — accessModes mal indentado en el PV
# ❌ Mal — accessModes bajo capacity
spec:
capacity:
storage: 250Mi
accessModes: # K8s lee esto como parte de capacity
- ReadWriteOnce
# ✅ Bien — accessModes al nivel de spec
spec:
capacity:
storage: 250Mi
accessModes:
- ReadWriteOnce
El error
quantities must match the regexes engañoso — el real problema es quecapacityesperaba solostorage, y la indentación incorrecta pusoaccessModesahí, donde se intentó parsear como unaquantity.
Bug 3 — Typo ReadWriteOne (debería ser ReadWriteOnce)
Los accessModes son enums — ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod. Cualquier variante es rechazada por el validador.
Bug 4 — PV sin volume type
Un PV necesita declarar de dónde sale el storage real. No basta con declarar capacity y accessModes; hay que decir si es hostPath, nfs, awsElasticBlockStore, un CSI driver, etc.
spec:
storageClassName: manual
capacity:
storage: 250Mi
accessModes:
- ReadWriteOnce
hostPath: # ← agregado
path: "/mnt/data"
Re-apply final
persistentvolume/mysql-pv created
persistentvolumeclaim/mysql-pv-claim unchanged
deployment.apps/mysql-deployment unchanged
secret/mysql-root-pass configured
secret/mysql-user-pass configured
secret/mysql-db-url configured
service/mysql unchanged
Notar
unchangedyconfigured: los recursos ya existían y la diff del apply los detecta como cambios menores o sin cambios.
El warning que se ve pero no rompe — storageclass "manual" not found
11m Warning ProvisioningFailed PersistentVolumeClaim/mysql-pv-claim storageclass.storage.k8s.io "manual" not found
Este warning aparece porque K8s intenta hacer dynamic provisioning con la StorageClass manual, no la encuentra como recurso del cluster, y eventualmente el PVC encuentra el PV estático que coincide (mismo storageClassName: manual + mismos accessModes + suficiente capacity). El binding funciona aunque la StorageClass no exista como objeto, porque el campo storageClassName también funciona como selector para hacer match con PVs estáticos.
| Tipo de provisioning | Cuándo ocurre |
|---|---|
| Estático | PV creado a mano (este lab). El PVC busca uno que matchee y bindea |
| Dinámico | Solo PVC creado, con StorageClass real. K8s crea el PV automáticamente |
Verificación
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
mysql-pv-claim Bound mysql-pv 250Mi RWO manual 11m
Notar
DATA: 2enmysql-user-pass— confirma que las dos claves (usernameypassword) se almacenaron.
Validación funcional — las env vars vienen de los Secrets
Dentro del container:
MYSQL_ROOT_PASSWORD=YUIidhb667 ← desde mysql-root-pass.password
MYSQL_PASSWORD=ksH85UJjhb ← desde mysql-user-pass.password
MYSQL_USER=kodekloud_joy ← desde mysql-user-pass.username
MYSQL_DATABASE=kodekloud_db10 ← desde mysql-db-url.database
# Auto-inyectadas por K8s (Service discovery legacy):
MYSQL_PORT=tcp://10.43.210.54:3306
MYSQL_PORT_3306_TCP_ADDR=10.43.210.54
MYSQL_PORT_3306_TCP_PORT=3306
MYSQL_PORT_3306_TCP=tcp://10.43.210.54:3306
MYSQL_PORT_3306_TCP_PROTO=tcp
MYSQL_SERVICE_PORT=3306
MYSQL_SERVICE_HOST=10.43.210.54
# Inyectadas por la imagen oficial de MySQL:
MYSQL_MAJOR=9.7
MYSQL_VERSION=9.7.0-1.el9
MYSQL_SHELL_VERSION=9.7.0-1.el9
Tres grupos distintos de variables conviviendo en el environment:
| Origen | Cómo aparece |
|---|---|
secretKeyRef que declaraste explícitamente |
MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD, MYSQL_USER, MYSQL_DATABASE |
| Service discovery legacy de K8s | MYSQL_PORT, MYSQL_SERVICE_HOST, etc. — auto-inyectadas para cada Service del namespace |
ENV de la Dockerfile de la imagen mysql:latest |
MYSQL_MAJOR, MYSQL_VERSION, MYSQL_SHELL_VERSION |
Para apagar el grupo legacy (que puede generar conflictos de naming con tus propias vars), agregar al Pod spec:
El patrón de "errores en cadena" del lab
Cuatro bugs distintos, cada uno enmascarando al siguiente:
1. Apply inicial → Secrets fail (base64) + PV fail (regex)
│
Fix Secrets ──► stringData │
▼
2. Apply → PV fail (regex sigue)
│
Fix indentación accessModes ──► accessModes al nivel correcto
│
▼
3. Apply → PV fail (regex sigue!)
│
Fix typo ReadWriteOne ──► ReadWriteOnce
│
▼
4. Apply → PV fail ("must specify a volume type")
│
Fix agregando hostPath ──► hostPath: /mnt/data
│
▼
5. Apply → todos los recursos created ✓
La lección operacional, repetida desde Día 59: leer cada mensaje de error específicamente antes de cambiar nada. El error quantities must match the regex apareció en los puntos 2 y 3 — eran dos causas distintas con el mismo síntoma superficial. Si en el punto 2 solo se hubiera revertido el fix de la indentación al ver que "el error es el mismo", se habría perdido el verdadero fix.
Conexión con días anteriores
- Día 60 (PV + PVC + Pod + Service): idéntico patrón del stack de hoy, pero sin Secrets. Hoy se agrega la capa de Secrets como fuente de env vars. Misma estructura, más componentes.
- Día 62 (Secrets): introdujo
data:vsstringData:. Hoy se vio en la práctica por qué el detalle importa — escribir el secret a mano en plano dentro dedata:rompe el apply. - Día 63 (stack multi-componente): misma idea de "ensamblar varios recursos en un solo manifest". El de Iron Gallery era app + DB + 2 Services. El de hoy es DB + PV + PVC + Secrets + Service.
- Día 64 (troubleshooting de dos bugs): la regla "si fixeaste un bug y el síntoma persiste, asumir que hay otro bug" se vuelve a aplicar — pero acá fueron cuatro bugs en cadena, no dos.
- Día 65 (Redis con ConfigMap,
kubectl events): se mantiene el reflejo de usarkubectl eventscomo primer comando de debug. Acá fue la clave para ver el warningProvisioningFailedy validar que el binding del PVC al final funcionó.
Reflexión: ensamblar el stack completo y leer los errores con calma
Algo que me queda como aprendizaje importante es el orden de creación de los recursos en K8s. Pensaba que se creaban en base a la relación que tenían entre sí (Secrets antes que el Deployment que los referencia, PV antes que el PVC que lo reclama), pero no necesariamente — K8s procesa el archivo en el orden en que están definidos, y los recursos dependientes simplemente reintentan hasta que sus dependencias aparecen. Por eso es importante cómo se definen en el manifest: aunque "funciona" en cualquier orden, ordenar de menos a más dependiente evita estados intermedios feos.
Al crear 7 recursos en un solo apply, hay que ser mucho más detallista con los typos. Es muy fácil equivocarse en un campo (accessModes mal indentado, ReadWriteOne sin la c, una clave de Secret mal escrita) y los bugs resultantes no siempre son sencillos de identificar — el mensaje de error puede apuntar a un síntoma superficial que esconde la causa real (como el quantities must match the regex que en realidad era un problema de indentación, no de cantidades).
No sabía sobre data y stringData ni cuál era la diferencia. Lo entendí en este lab: data espera el valor ya en base64, stringData lo acepta plano y K8s lo codifica al apply. Para escribir Secrets a mano, stringData es siempre la opción correcta; data solo sirve cuando los valores ya están codificados (típicamente al copiar/exportar desde otro cluster).
El bug más complicado fue el must specify a volume type. No conocía ese key del PV ni las diferencias entre los tipos (hostPath, nfs, csi, etc.) ni los casos de uso de cada uno. Saber que hostPath solo sirve para single-node clusters y que en producción real se usan CSI drivers fue uno de los aprendizajes más concretos del día.
Había manejado MySQL fuera de K8s, tanto en instalaciones locales como en Docker. Me pareció bastante similar — varios conceptos se unen (env vars de configuración, mount del data dir, exposición del puerto 3306). La diferencia principal está en que K8s separa la responsabilidad del storage (PV/PVC) de la del cómputo (Deployment), mientras que en Docker -v /host:/var/lib/mysql es una decisión única.
Troubleshooting
| Problema | Causa | Solución |
|---|---|---|
Secret in version "v1" cannot be handled: illegal base64 data |
Usás data: con valores planos. K8s intenta decodificar como base64 y falla |
Cambiar a stringData: o codificar manualmente: echo -n 'valor' \| base64 |
PersistentVolume invalid: quantities must match the regex |
Indentación mal: un campo (típicamente accessModes) está donde se esperaba una quantity |
Validar la indentación del PV spec — accessModes va al nivel de spec, no de capacity |
PersistentVolume invalid: unsupported value "ReadWriteOne" |
Typo en el access mode | Los modos válidos son: ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod |
PersistentVolume invalid: must specify a volume type |
El PV declara capacity y accessModes pero no de dónde sale el storage |
Agregar uno de: hostPath, nfs, csi, awsElasticBlockStore, etc. |
kubectl apply parcial: algunos recursos created, otros con error |
Comportamiento esperado de apply con múltiples docs | Fixear los recursos rechazados y re-aplicar; los ya creados se ven como unchanged |
Pod en Pending: pod has unbound immediate PersistentVolumeClaims |
El PVC no se bindeó a ningún PV (PV no existe o specs no matchean) | kubectl describe pvc <name> para ver razón; típicamente storageClassName o accessModes no matchean |
Warning ProvisioningFailed: storageclass "X" not found |
K8s intenta dynamic provisioning pero la StorageClass no existe como recurso | Si hay un PV estático con el mismo storageClassName, el warning es ruido. Si no, crear la StorageClass |
Failed to create endpoint for service ... "X" already exists |
El Service existe en estado raro después de un apply parcial | kubectl delete svc <name> y re-aplicar, o kubectl apply --force para reemplazar |
| Env var del container vacía aunque el Secret existe | La key del secretKeyRef no matchea ninguna clave del Secret |
kubectl get secret <name> -o yaml para ver las claves reales del Secret |
MYSQL_* variables auto-inyectadas que generan conflicto |
K8s inyecta env vars de Service discovery legacy por cada Service del namespace | spec.enableServiceLinks: false en el Pod spec para apagarlas |
| MySQL pierde los datos al recrear el Pod | El PV usa hostPath y el cluster es multi-node — el Pod schedulea a otro nodo sin los datos |
Usar un CSI driver real o local PV con node-affinity al nodo correcto |
Apply funciona pero el Pod queda en CreateContainerError |
El Pod se schedulea pero falla al crear el container — típicamente Secret/ConfigMap referenciado falta | kubectl describe pod <name> para ver el error exacto |