
🌐 También en: English · Deutsch · Français
Parte 3 de la visita a Ansible. La parte 1 trató el marco, la parte 2 la plataforma que hay debajo – sistema base y Kubernetes. Esta parte pone el blog encima. Como siempre, describe la versión v3.2.0.
Este blog es WordPress. Cuatro roles lo llevan de un clúster Kubernetes vacío a un sitio en marcha, configurado y monitorizado, y un playbook de actualización lo mantiene al día:
| Rol / playbook | Qué hace |
|---|---|
blog_k3s_deploy | Cada objeto de Kubernetes del blog: base de datos, caché, WordPress, servidor web, enrutamiento, tareas programadas, reglas de red, reglas de imágenes |
blog_wordpress | Configura el WordPress en marcha desde fuera: instalación, plugins, ajustes, pequeños plugins must-use, actualizaciones |
blog_visitor_stats | Cuenta los visitantes cada noche a partir de los logs, de forma anónima, en el servidor |
blog_verify + common_verify | Pruebas de humo al final de cada ejecución |
blog-update.yml | La actualización rutinaria: plugins, núcleo, imágenes reconstruidas – con un camino definido para cuando algo falla |
Antes de cualquier código, sin embargo, ayuda saber qué se construye y por qué está cada pieza. Por eso esta parte empieza por la arquitectura.
🏗️ Qué se ejecuta, y por qué
Sigamos una petición. Un lector abre un artículo; la petición llega al puerto 443 del servidor.
- El controlador Ingress (de la parte 2) termina TLS – guarda el certificado de Let’s Encrypt, descifra la petición y comprueba unas cuantas reglas (límite de tasa en la página de inicio de sesión, rutas bloqueadas, cabeceras de seguridad). Después pasa la petición HTTP en claro, dentro del clúster, al Service de WordPress, un nombre interno estable para «el pod que ejecuta WordPress ahora mismo».
- nginx, dentro del pod de WordPress, la recibe. Si ya existe en la caché de páginas una copia HTML lista de la página, nginx devuelve ese archivo y listo – sin PHP. Las imágenes, el CSS y el JavaScript también los sirve nginx directamente, con cabeceras de caché largas.
- Solo si la página tiene que generarse, nginx pasa la petición a PHP-FPM en el mismo pod. FPM («FastCGI Process Manager») mantiene preparada una reserva de procesos PHP; uno de ellos ejecuta el código PHP de WordPress para esta petición.
- WordPress necesita datos: el artículo, el menú, docenas de ajustes. Primero pregunta a Redis, un almacén en memoria. Lo que no está allí lo lee de MariaDB, la base de datos, y lo guarda en Redis para la próxima vez.
Cada una de estas piezas responde a una pregunta concreta:
| Componente | Por qué está ahí |
|---|---|
| nginx + PHP-FPM en un pod | La imagen oficial wordpress:fpm contiene PHP, pero ningún servidor web. nginx sirve archivos estáticos y páginas en caché mucho más barato de lo que podría PHP; FPM solo se ejecuta cuando de verdad hace falta PHP. Al estar en el mismo pod, comparten los archivos de WordPress y hablan por 127.0.0.1:9000 – un sidecar: un contenedor auxiliar junto al principal. |
| Caché de páginas (WP Super Cache) | La mayoría de los visitantes – y casi todos los bots – son anónimos y ven la misma página. El plugin guarda el HTML terminado; nginx lo sirve sin arrancar PHP. Durante una tormenta de escáneres, esa es la diferencia entre un servidor tranquilo y un pool de FPM lleno. |
| Redis (caché de objetos) | WordPress consulta la base de datos muchas veces por página – ajustes, menús, metadatos de artículos, traducciones. Una caché de objetos guarda esos resultados en memoria entre peticiones, de modo que una página que sí hay que generar necesita muchas menos consultas. Redis es aquí una caché pura: no se guarda nada en disco, y perderla solo supone unas cuantas cargas de página más lentas. |
| MariaDB | WordPress guarda todo en una base de datos compatible con MySQL: artículos, usuarios, ajustes, datos de plugins. En el blog se ejecuta como pod en el clúster, como todo lo demás – la parte 4 muestra por qué Nextcloud lo hace de otra manera. |
| Directorios del host (hostPath) | Los archivos de WordPress y los de la base de datos están en dos directorios del servidor, montados en los pods. Los pods pueden recrearse en cualquier momento; los datos se quedan. La copia de seguridad nocturna copia esos directorios. |
| Un CronJob en lugar de WP-Cron | WordPress tiene su propio planificador que se ejecuta «en la próxima visita» – poco fiable en un sitio tranquilo, y ralentiza a ese visitante. En su lugar, un CronJob de Kubernetes ejecuta wp-cron.php cada cinco minutos. |
| Una tarea de actualización nocturna | Actualizaciones de plugins, temas, traducciones y menores del núcleo, una vez por noche, con un volcado de la base de datos antes. |
| Exporters | Dos sidecars más traducen los contadores internos de MariaDB y PHP-FPM a métricas para Prometheus (parte 6). |
| NetworkPolicies y una política de imágenes | Reglas que limitan qué pod puede hablar con cuál y qué imágenes de contenedor pueden ejecutarse en absoluto. La seguridad es la parte 5 – pero estas dos pertenecen al blog, así que se tratan aquí. |
Dos palabras de Kubernetes aparecen constantemente más abajo. Un Deployment describe «mantén en marcha un pod de este tipo» – si el pod muere, Kubernetes arranca uno nuevo. Su estrategia aquí es siempre Recreate: primero detener el pod antiguo, luego arrancar el nuevo. Dos pods escribiendo a la vez en el mismo directorio del host es como se corrompen las bases de datos, así que unos segundos de caída durante un reinicio son el precio. Un ConfigMap es un conjunto de archivos de configuración guardado en el clúster y montado en un pod; un Secret es lo mismo para contraseñas.
🚀 blog_k3s_deploy: todo el blog como objetos de Kubernetes
El rol convierte la arquitectura de arriba en objetos. Cada componente es una plantilla Jinja en templates/ y un archivo de tareas en tasks/, aplicado con el patrón de aplicación K8s de la parte 2. El archivo principal es una lista de comprobación en orden de dependencias:
- import_tasks: dirs.yml # host directories with the right owners
- import_tasks: namespace_secrets.yml # namespace, Secret, ConfigMaps
- import_tasks: mariadb.yml # database (+ wait until ready)
- import_tasks: external_services.yml # a name for Grafana on the host
- import_tasks: redis.yml # object cache (+ wait)
- import_tasks: wordpress.yml # WordPress + nginx + exporter (+ wait)
- import_tasks: ingress.yml # routing from the internet
- import_tasks: cronjob.yml # wp-cron every 5 min, nightly updates
- import_tasks: exporters.yml # metrics ports for Prometheus
- import_tasks: networkpolicy.yml # who may talk to whom
- import_tasks: image_policy.yml # which images may runWordPress necesita una base de datos en marcha, así que el rol espera después de MariaDB, Redis y WordPress – con kubectl rollout status o kubectl wait, que se bloquean hasta que los pods nuevos informan de que están listos. Cuando el rol termina, el blog está en marcha.
Una guarda antes que nada
- name: blog_k3s_deploy | Check that the exporter DB password is present in the vault
ansible.builtin.assert:
that:
- blog_db_exporter_pass is defined
- blog_db_exporter_pass | length > 0
fail_msg: >-
blog_db_exporter_pass is missing from this host's vault.yml. Add a line
like blog_db_exporter_pass: "<a fresh random password>" ...Esta variable se añadió más tarde, cuando el exporter de la base de datos dejó de entrar como root. Los vaults más antiguos no la tienen. Sin esta comprobación, la ejecución fallaría a mitad de camino con «undefined variable» dentro de una plantilla. Con ella, falla al principio, y el mensaje dice exactamente qué añadir. assert es la herramienta para estas guardas: una lista de condiciones, todas deben ser verdaderas, y un mensaje para cuando no lo son. (Los argument_specs del rol la declaran opcional, así que esa comprobación sola no la atraparía.)
dirs.yml: propietarios que se mantienen correctos
Se crean dos directorios del host con el módulo file: los archivos de WordPress, propiedad del uid 33 (www-data dentro del contenedor), y los archivos de la base de datos, propiedad del uid 999 (mysql). Los números importan. Una versión anterior los creaba como root. Los contenedores de inicialización de los pods (más sobre ellos abajo) corregían el propietario al arrancar – y la siguiente ejecución del playbook lo devolvía a root. Todo siguió funcionando hasta que un plugin intentó crear una tabla en la base de datos y falló con un engañoso «Permission denied». La causa no era la base de datos en absoluto, sino esta tarea deshaciendo el trabajo del contenedor de inicialización. Desde entonces la tarea fija el mismo propietario que esperan los contenedores – y desde el 16/10/2026 también los mismos permisos: el contenedor de inicialización de la base de datos seguía poniendo el directorio en 0755 mientras el rol ponía 0750, así que se desviaba tras cada reinicio del pod. Ahora ambos dicen 0750, y los dos ya nunca se pelean. El mismo archivo coloca también la clave pública PGP del aviso legal en la raíz web.
namespace_secrets.yml: el marco
Un namespace es una zona con nombre dentro del clúster; todo lo del blog vive en wordpress. Tres detalles:
- Etiquetas de Pod Security, solo avisar y auditar. Kubernetes puede imponer los «Pod Security Standards». El estándar «baseline» prohíbe directorios del host en los pods – que es justo como este blog guarda sus datos. Imponerlo rechazaría todos los pods, incluido el cron de cada cinco minutos. Así que el namespace recibe las etiquetas en modo warn y audit: las infracciones se notifican, no se bloquea nada. Esta tarea usa
k8ssinapply: una fusión simple añade las etiquetas a las existentes en lugar de reemplazar todo el objeto namespace. - Sin token de service account. El mismo parche que en la parte 2, para el namespace que entonces aún no existía.
- El Secret, con
no_log. Contraseñas de la base de datos, acceso de administrador, credenciales del exporter. La plantilla se genera en memoria y se envía a la API;no_log: truelo mantiene todo fuera de la salida, incluso en una simulación con--diff.
Luego los ConfigMaps: la configuración del sitio de nginx, los ajustes de PHP (tamaño de subida, memoria, OPcache) y el pool de PHP-FPM (doce workers como máximo, una página de estado para el exporter). En la parte de nginx se define buena parte del comportamiento del blog: qué archivos no se sirven nunca (wp-config.php, sufijos de copia como .bak o .save, .phar, las exportaciones de un plugin de migración), cómo se sirve la caché de páginas antes de que se pregunte siquiera a PHP, y cuánto tiempo pueden los navegadores guardar en caché imágenes y scripts. Cada uno de los patrones bloqueados tiene un comentario con el motivo, varios con fecha – por ejemplo, un plugin de copias de seguridad que escribe exportaciones completas del sitio, base de datos incluida, en una carpeta accesible desde la web y confía en un archivo .htaccess que nginx ignora.
Un pod se reinicia cuando cambia su configuración
Una trampa en la que es fácil caer: los archivos de configuración se montan en el pod con subPath, y esos montajes nunca se actualizan en un pod en marcha. Un ConfigMap modificado solo surtía efecto en el siguiente reinicio casual. La solución es una anotación en la plantilla del pod de WordPress:
template:
metadata:
annotations:
checksum/config: "{{ lookup('ansible.builtin.template', 'nginx-cm.yml.j2') | hash('sha256') }}"La plantilla de los ConfigMaps se genera una vez más, aquí, y se reduce a una suma de comprobación SHA-256. Si la configuración cambia, la suma cambia – y con ella la plantilla del pod, lo que hace que Kubernetes despliegue un pod nuevo. Si no cambió nada, la suma es la misma y el pod se deja en paz. Un pequeño truco que convierte «acuérdate de reiniciar» en algo que no se puede olvidar.
mariadb.yml: la base de datos y un usuario con el mínimo privilegio
El Deployment de MariaDB usa la imagen oficial, que en el primer arranque crea la base de datos y el usuario de WordPress a partir de variables de entorno. Hay unas cuantas cosas en la plantilla que vale la pena señalar:
- Contraseñas como archivos, no como variables de entorno. Las variables de entorno son visibles en
/proc/<pid>/environ, enkubectl describey en algunas páginas de error. La imagen admite la convenciónMARIADB_ROOT_PASSWORD_FILE: lee la contraseña de un archivo en su lugar, y el archivo viene del Secret, montado en solo lectura. - Un contenedor de inicialización se ejecuta antes de que arranque la base de datos y fija el propietario del directorio de datos. Un contenedor de inicialización es un contenedor de vida corta que tiene que terminar antes de que arranquen los contenedores principales – el sitio adecuado para una preparación única.
- Sondas:
mysqladmin pingle dice a Kubernetes cuándo la base de datos está lista para aceptar conexiones (readiness) y cuándo está colgada y hay que reiniciarla (liveness). - Un contenedor bien cerrado: todas las capabilities de Linux retiradas y solo devueltas las cinco que necesita el entrypoint, sin escalada de privilegios, un sistema de archivos raíz de solo lectura con dos pequeños volúmenes escribibles para el socket y las tablas temporales. El volumen de las tablas temporales está en RAM desde el 16/10/2026 (antes acababan en el disco unas 2.000 tablas temporales al día); como un volumen en memoria cuenta para el límite de memoria del contenedor, ese límite pasó de 768 MiB a 1280 MiB. La parte 5 explica estos ajustes en general.
Tras el despliegue, el rol crea un usuario de base de datos para el exporter de métricas. Antes entraba como root – acceso total para un programa que solo lee contadores. Ahora tiene exactamente tres privilegios y acceso de lectura al performance schema, nada sobre las tablas de WordPress. Lo interesante es cómo se hace de forma idempotente sin un módulo de MySQL que pudiera llegar al interior del pod:
- name: MariaDB | Check the mysqld_exporter database user
ansible.builtin.command:
argv:
- k3s
- kubectl
- exec
- deployment/mariadb
- --namespace=wordpress
- --container=mariadb
- --
- sh
- -c
- >-
mariadb -N -u root -p"$(cat /etc/secrets/db-root-password)" -e "
SELECT
(SELECT COUNT(*) FROM mysql.user WHERE User = 'exporter' ...
AND authentication_string = PASSWORD('...'))
+ (SELECT COUNT(*) FROM mysql.db WHERE User = 'exporter' ...
AND Db = 'performance_schema' AND Select_priv = 'Y');"
register: exporter_user_check
changed_when: false
no_log: true
- name: MariaDB | Create least-privilege mysqld_exporter database user
ansible.builtin.command:
argv: [ ... CREATE OR REPLACE USER ... GRANT ... ]
when: exporter_user_check.stdout | default('') | trim != '4'
no_log: trueLéelo como una pregunta cuya respuesta es un número. El SQL cuenta dos cosas: usuarios llamados «exporter» con exactamente esta contraseña (dos hosts: localhost y 127.0.0.1) y el permiso de lectura sobre el performance schema para ambos. Todo correcto significa 4. Cualquier otro número – falta el usuario, se cambió la contraseña en el vault, se perdió un permiso – y la segunda tarea recrea el usuario con CREATE OR REPLACE. Eso hace también trivial rotar la contraseña: cambiarla en el vault, ejecutar el playbook.
Fíjate en argv en lugar de una cadena de comando: cada elemento de la lista se pasa como exactamente un argumento, sin un shell en medio que interprete comillas o signos de dólar. El SQL llega intacto al pod, donde sh -c lo ejecuta – y la contraseña de root se lee dentro del pod del archivo montado, así que nunca aparece en una línea de comandos del host.
redis.yml y un entrypoint que nunca se ejecutó
Redis se ejecuta con la persistencia desactivada (--save "" --appendonly no), un límite de memoria de 80 MB y la política de desalojo allkeys-lru: cuando la memoria se llena, sale la entrada usada hace más tiempo. Los 80 MB están a propósito muy por debajo del límite del contenedor de 128 MB, porque la contabilidad de memoria del propio Redis se midió en casi un tercio del uso real – Redis debe desalojar por sí mismo mucho antes de que el kernel lo mate. WordPress fija además una vida máxima de un día por entrada de caché, así que la caché se limpia sola.
La plantilla también contiene uno de los fallos más instructivos de este repositorio. El contenedor se arranca con su propio command: – y en Kubernetes, command sustituye al entrypoint de la imagen. Ese entrypoint es el script que habría cambiado de root al usuario redis. Así que hasta octubre de 2026 Redis se ejecutó como root, con dos capabilities añadidas para un cambio de usuario que nunca ocurrió. La solución no es restaurar el entrypoint, sino arrancar el contenedor directamente como el usuario correcto:
securityContext:
runAsUser: 999
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000Arrancado como uid 999, Redis no necesita ninguna capability. fsGroup hace que Kubernetes asigne los volúmenes del pod a ese grupo, de modo que el directorio de datos (sin usar, pero presente) sea escribible.
wordpress.yml: tres contenedores y un contenedor de inicialización
El pod de WordPress es la plantilla más grande del rol. Su contenedor de inicialización tiene dos trabajos: fijar el propietario de la raíz web y descargar WP-CLI, la herramienta de línea de comandos que usa blog_wordpress para controlar WordPress. Ambos trabajos encierran una lección:
- WP-CLI vive fuera de la raíz web. Antes estaba en
/var/www/html– y todo lo que hay ahí es accesible por HTTP. nginx solo rechazaba archivos.php, no.phar, así que cualquiera podía descargar del blog una herramienta de 7 MB. Ahora va a un emptyDir (un volumen temporal que vive tanto como el pod), montado en solo lectura en el contenedor de PHP. - Fijado y con suma de comprobación. La descarga viene de GitHub con una versión fija y se comprueba contra un SHA-512 de
versions.yml. Elwgetde busybox del contenedor de inicialización no verifica certificados TLS, y WP-CLI se ejecuta luego como root – una descarga sin verificar sería una ejecución remota de código esperando su momento. Si la suma no coincide, el contenedor de inicialización falla y el pod nunca arranca. sleep 5antes de la descarga. La primera conexión saliente de un pod recién creado puede fallar porque Calico aún no ha programado sus reglas de red. Eso causó una caída real: durante un reinicio la descarga se colgó, el pod antiguo ya no estaba (una réplica, Recreate), y el sitio respondió 502 hasta que se relanzó a mano el contenedor de inicialización. Cinco segundos de espera salen más baratos.
Luego los tres contenedores – PHP-FPM, el exporter de FPM y nginx –, cada uno con el mismo patrón de endurecimiento: todas las capabilities retiradas y solo devueltas las necesarias, con un comentario por capability («el maestro de FPM arranca como root y crea workers como www-data» necesita SETUID/SETGID), sin escalada de privilegios, el perfil seccomp por defecto, un sistema de archivos raíz de solo lectura con pequeños volúmenes escribibles para exactamente las rutas que lo necesitan. El exporter se ejecuta como el usuario «nobody» y no necesita ninguna capability.
La configuración propia de WordPress entra a través de una variable de entorno que la imagen entiende, WORDPRESS_CONFIG_EXTRA. Fija la URL pública, la conexión a Redis, la vida de la caché, la caché de páginas – y DISALLOW_FILE_EDIT, que elimina el editor de plugins y temas del área de administración. Sin eso, una contraseña de administrador robada está a un clic de ejecutar PHP arbitrario en el servidor.
Por último, las sondas, tres tipos por una buena razón:
| Sonda | Pregunta | Consecuencia |
|---|---|---|
| startupProbe | ¿Ha arrancado ya? | Retiene las otras dos hasta cinco minutos, para que un arranque lento no se tome por un cuelgue |
| readinessProbe | ¿Puede recibir tráfico ahora? | Si no, el Service deja de enviar peticiones a este pod |
| livenessProbe | ¿Sigue vivo? | Si no, Kubernetes reinicia el contenedor |
En nginx, las sondas comprueban a propósito el puerto TCP, no una página HTTP: una comprobación HTTP llegaría hasta PHP y podría declarar muerto a nginx cuando solo PHP tiene un problema.
ingress.yml: master, minion y un certificado provisional
El enrutamiento del blog son tres objetos Ingress. No es una elección, es la forma en que el controlador de F5 maneja varios Ingress para un mismo nombre de host: un master guarda el certificado TLS y los ajustes de todo el servidor, y los minions guardan las rutas. El Ingress temporal del desafío de cert-manager (parte 2) se une como un minion más. Aquí el master lleva la redirección a HTTPS (salvo para la ruta del desafío ACME), unos cuantos rechazos tajantes y el límite de tasa en wp-login.php – en su propia location, lo que en su día hizo que la página de inicio de sesión saliera sin la cabecera HSTS, porque las reglas de cabeceras viven en el minion. Un minion enruta todo a WordPress y añade las cabeceras de seguridad; un segundo enruta /grafana/ a Grafana en el host. Grafana tiene su propio minion porque necesita cabeceras WebSocket que WordPress no debe recibir, y porque de lo contrario se habrían enviado dos cabeceras de frame contradictorias. Un cuarto Ingress redirige el dominio desnudo sin «www» al completo.
Tras la aplicación, un pequeño trozo de lógica se ocupa del primerísimo despliegue en un servidor nuevo:
- name: Ingress | Check whether TLS secrets already exist
kubernetes.core.k8s_info:
kind: Secret
namespace: wordpress
name: "{{ item.secret }}"
loop:
- { secret: wordpress-tls, host: "{{ blog_hostname }}" }
- { secret: apt-upgrade-me-apex-tls, host: "{{ blog_hostname | regex_replace('^www\\.', '') }}" }
register: wp_tls_secret_check
no_log: true
- name: Ingress | Seed self-signed placeholder for any TLS secret cert-manager hasn't issued yet
ansible.builtin.shell: |
openssl req -x509 -nodes -newkey rsa:2048 -days 7 ... -subj "/CN={{ item.item.host }}"
k3s kubectl create secret tls {{ item.item.secret }} ...
loop: "{{ wp_tls_secret_check.results }}"
when: item.resources | length == 0Para un nombre de host cuyo certificado aún no existe, este controlador rechaza el handshake TLS – el sitio parece muerto hasta que Let’s Encrypt ha respondido. Así que para cada Secret de certificado esperado el rol pregunta si existe y, si no, crea un certificado provisional autofirmado de siete días; cert-manager lo sobrescribe en cuanto llega el certificado real. El bucle muestra un patrón que conviene conocer: la segunda tarea recorre los resultados de la primera. Cada resultado contiene el elemento de bucle original como item.item (nombre de host y nombre del Secret) y la respuesta como item.resources (lista vacía = no encontrado). El filtro regex_replace deriva el dominio desnudo del nombre con www. Y no_log está ahí porque k8s_info devuelve los datos de un Secret – claves privadas.
cronjob.yml: dos tareas programadas
El primer CronJob ejecuta wp-cron.php cada cinco minutos, con concurrencyPolicy: Forbid (nunca dos a la vez). Tuvo dos fallos que vale la pena contar. Sus pods llevaban antes la etiqueta app: wordpress – la misma etiqueta por la que selecciona el Service de WordPress. Así que durante sus diez segundos de vida, los pods del cron formaban parte del Service, y el Ingress enviaba visitantes reales a un pod sin servidor web: un 502 de vez en cuando, cada cinco minutos. Ahora llevan la etiqueta wordpress-cron, y las NetworkPolicies permiten ambas etiquetas. El segundo fallo: el contenedor del cron nunca tuvo el bloque WORDPRESS_CONFIG_EXTRA, así que no sabía dónde estaba Redis, probaba 127.0.0.1, y cada ejecución programada moría con «Error establishing a Redis connection» – en silencio, porque WordPress sale con 0 en ese caso. El bloque es ahora idéntico en ambas plantillas, con un comentario que dice que debe seguir así, y el contenedor también espera cinco segundos a Calico, como el contenedor de inicialización de arriba.
El segundo CronJob es la actualización nocturna. Los pods del cron montan la raíz web en solo lectura, así que las actualizaciones en segundo plano del propio WordPress se rendían en silencio – cada actualización de plugin esperaba un clic en el área de administración. En lugar de hacer escribibles los pods del cron, una tarea nocturna aparte lo hace como es debido: un contenedor de inicialización descarga WP-CLI (de nuevo fijado y con suma de comprobación), un segundo vuelca la base de datos en un directorio de copias y conserva 14 días, y solo si el volcado tuvo éxito el contenedor principal ejecuta las actualizaciones de núcleo, plugins, temas y traducciones como www-data y después comprueba la portada. El playbook de actualización de más abajo reutiliza exactamente esta tarea.
exporters.yml, networkpolicy.yml, image_policy.yml
Los exporters escuchan dentro de sus pods. Prometheus se ejecuta en el host, así que dos Services de tipo NodePort los hacen accesibles en puertos fijos del servidor – y el cortafuegos de la parte 5 descarta esos puertos para todo lo que no sean conexiones locales.
Las NetworkPolicies empiezan con un modelo de amenazas en el comentario, que para mí es lo mejor del archivo. PHP-FPM procesa entradas no fiables – peticiones, subidas, código de plugins – y es con diferencia el sitio más probable donde un atacante lograría ejecutar código. También necesita legítimamente la base de datos y Redis. Así que el objetivo no es aislarlo, sino (1) asegurarse de que solo los pods de WordPress puedan llegar a MariaDB y Redis, y (2) limitar lo que puede alcanzar un pod de WordPress comprometido. Las reglas permiten exactamente: el Ingress (a través de la dirección del host, porque el controlador se ejecuta en la red del host), DNS, MariaDB, Redis, el puerto del exporter – y HTTP/HTTPS saliente hacia internet. El comentario es honesto sobre esto último: las actualizaciones vienen de wordpress.org y GitHub tras direcciones de CDN cambiantes, y las NetworkPolicies estándar no pueden filtrar por nombres de dominio. Lo que sí impiden es el movimiento dentro del clúster.
La política de imágenes es una ValidatingAdmissionPolicy: una regla que el servidor de API comprueba antes de aceptar un pod. Su condición está escrita en CEL, un pequeño lenguaje de expresiones:
validations:
- expression: >
object.spec.containers.all(c, c.image.split(':')[0] in variables.allowedRepos)
&& (!has(object.spec.initContainers) ||
object.spec.initContainers.all(c, c.image.split(':')[0] in variables.allowedRepos))«La imagen de cada contenedor, sin la etiqueta, está en la lista de repositorios permitidos – y lo mismo para los contenedores de inicialización, si los hay». Comprobar el repositorio y no la etiqueta significa que un cambio de versión nunca necesita tocar nada aquí; lo que se bloquea es una imagen distinta. Una entrada de la lista tiene historia: el solucionador de desafíos de cert-manager ejecuta su pod en el namespace del blog, no en el suyo propio. Cuando se añadió el certificado del dominio desnudo, el solucionador fue rechazado, y no pudo emitirse ningún certificado hasta que se permitió su imagen.
| Pieza | Dónde | Qué llevarse |
|---|---|---|
assert con fail_msg | main.yml | Fallar pronto, con instrucciones |
import_tasks en orden de dependencias + esperas de despliegue | main.yml | El rol termina con una aplicación en marcha |
| propietarios como números | dirs.yml | Coincidir con los contenedores, o se pelean en cada ejecución |
k8s sin apply | namespace_secrets.yml | Fusionar unos cuantos campos en un objeto existente |
lookup(...) | hash('sha256') | plantilla de WordPress | Redesplegar pods cuando cambia su configuración |
argv, una consulta SQL que cuenta, when: ... != '4' | mariadb.yml | Cambios idempotentes sin módulo |
runAsUser / fsGroup | plantilla de Redis | Arrancar como el usuario correcto en lugar de cambiar |
bucle sobre results, item.item | ingress.yml | Actuar sobre las respuestas de un bucle anterior |
| CEL en una ValidatingAdmissionPolicy | política de imágenes | Reglas que impone el servidor de API |
🔧 blog_wordpress: configurar un sitio en marcha desde fuera
WordPress ya está en marcha, pero vacío o – en cada ejecución posterior – posiblemente desviado. Este rol lo configura. No hay ningún módulo de Ansible para WordPress; todo pasa por WP-CLI dentro del pod:
k3s kubectl exec deployment/wordpress --namespace wordpress --container wordpress-fpm --
php /usr/local/lib/wp-cli/wp-cli.phar <command> --path=/var/www/html ...Eso convierte todo el rol en una larga aplicación del patrón «primero preguntar, luego actuar» de la parte 2. El rol lo dice en su cabecera y enumera el orden: instalación, un plugin de endurecimiento, plugins, apariencia, el aviso de IA y tres pequeños plugins auxiliares, ajustes, actualizaciones.
¿root o www-data?
kubectl exec se ejecuta como root dentro del contenedor. Eso es un problema para una aplicación PHP: los archivos creados por root no los puede modificar PHP-FPM, que se ejecuta como www-data. El rol sigue una regla, escrita al principio de main.yml: los comandos que instalan o actualizan código se ejecutan como root con --allow-root (si no, WP-CLI rechaza root) y siempre van seguidos de un chown de vuelta a www-data. Las consultas de solo lectura y todo lo que escribe sin chown posterior se ejecutan como www-data mediante su -s /bin/sh www-data -c "...". Esa regla existe porque hacerlo al revés dejó una vez un directorio de caché propiedad de root, y el plugin de caché ya no podía escribir en él.
install.yml: solo una vez
wp core is-installed sale con 0 cuando existen las tablas de WordPress y con 1 cuando no. Registrado con failed_when: false, el código de salida se convierte en una decisión: when: wp_installed.rc != 0 ejecuta wp core install en una base de datos nueva y nunca más. La tarea de instalación tiene no_log: true, porque la contraseña de administrador forma parte de su línea de comandos. El título del sitio pasa por el filtro quote, que lo envuelve de forma segura para el shell.
security_muplugin.yml: un archivo que solo cambia cuando cambia
Un plugin must-use es un archivo PHP en wp-content/mu-plugins/. WordPress carga cada archivo de ahí en cada petición, antes que los plugins normales, y no se puede desactivar desde el área de administración – el sitio adecuado para reglas que deben aplicarse siempre. Este oculta los números de versión, elimina los enlaces de descubrimiento y bloquea a los visitantes anónimos el endpoint REST que lista los nombres de usuario.
Cómo se escribe es la parte interesante, porque el mismo patrón se usa para cada archivo PHP que despliega el rol:
- name: WordPress | Deploy security hardening mu-plugin
ansible.builtin.command:
argv:
- k3s
- kubectl
- exec
- deployment/wordpress
- ...
- sh
- -c
- |
F=/var/www/html/wp-content/mu-plugins/security-hardening.php
cat > "$F.new" << 'PHPEOF'
<?php
...
PHPEOF
if [ -f "$F" ] && [ "$(md5sum < "$F.new")" = "$(md5sum < "$F")" ]; then
rm -f "$F.new"
else
mv "$F.new" "$F" && chown www-data:www-data "$F" && echo CHANGED
fi
register: wp_mu_hardening
changed_when: "'CHANGED' in wp_mu_hardening.stdout"
notify: Restart wordpress deployment (OPcache picks up changed PHP files)- El archivo se escribe como
security-hardening.php.new– un nombre que WordPress no carga, porque no termina en.php. - El heredoc entre comillas (
<< 'PHPEOF') mantiene literal el código PHP: el shell no expande ningún$.argvse asegura de que Ansible tampoco lo toque. - Si el archivo nuevo tiene la misma suma de comprobación que el existente, se descarta. Si no, sustituye al antiguo y el script imprime
CHANGED. changed_whenbusca esa palabra. Solo entonces la tarea es «changed» – y solo entonces se notifica al handler.
El handler reinicia WordPress, porque PHP guarda el código compilado en el OPcache y solo revisa los archivos una vez por minuto; un reinicio hace efectivo al momento un archivo modificado. Antes de este patrón, el archivo simplemente se sobrescribía con changed_when: true – y cada ejecución del playbook reiniciaba WordPress, con treinta a sesenta segundos de 502 cada vez. El archivo de handlers contiene una sola entrada, notificada por cuatro tareas, y se ejecuta como mucho una vez al final del play.
plugins.yml: block, always y una diferencia de listas
La lista de plugins vive en el inventario del host (blog_wordpress_plugins). El rol lee una vez los plugins instalados, como JSON, y calcula el resto:
- name: WordPress | Install and activate missing plugins
ansible.builtin.command: >
... wp-cli.phar plugin install {{ item }} --activate --allow-root
loop: "{{ blog_wordpress_plugins | default([]) | difference(_wp_plugins_installed) }}"
register: wp_plugin_result
until: wp_plugin_result.rc == 0
retries: 3
delay: 10
vars:
_wp_plugins_installed: "{{ wp_plugin_state.stdout | from_json | map(attribute='name') | list }}"
- name: WordPress | Activate installed but inactive plugins
...
when: _wp_plugins_inactive | length > 0
vars:
_wp_plugins_inactive: >-
{{ wp_plugin_state.stdout | from_json
| selectattr('name', 'in', blog_wordpress_plugins | default([]))
| selectattr('status', 'equalto', 'inactive')
| map(attribute='name') | list }}from_json convierte la salida de WP-CLI en una lista de diccionarios. difference es resta de listas: plugins deseados menos instalados – solo esos se instalan. selectattr filtra una lista de diccionarios por un campo: de todos los plugins instalados, quedarse con los deseados, y de esos, con los inactivos. Antes, cada ejecución reinstalaba cada plugin con --force, los daba todos por cambiados – y podía traer en silencio una versión más nueva sin que nadie se diera cuenta. Las versiones nuevas ahora solo llegan desde el paso de actualización.
until con retries funciona por elemento del bucle: si una descarga agota el tiempo (el de WP-CLI es de diez segundos fijos), solo se reintenta ese plugin. Y todo esto está dentro de un block con una sección always:
- name: WordPress | Install plugins and fix ownership
block:
- ...read state, install, activate, Redis...
always:
- name: WordPress | Fix wp-content ownership after plugin installation
ansible.builtin.command: >
k3s kubectl exec ... -- chown -R www-data:www-data /var/www/html/wp-contentLas tareas bajo always se ejecutan incluso cuando falla una tarea del bloque. Por qué importa: WP-CLI como root escribe los archivos de los plugins como root. Una vez, una descarga agotó el tiempo a mitad, el play se detuvo y el chown nunca se ejecutó – un plugin de copias de seguridad se quedó sin poder crear su propia carpeta de almacenamiento hasta que alguien lo arregló a mano. Ahora la propiedad se restaura pase lo que pase más arriba.
El plugin de Redis tiene sus propias dos tareas, porque wp redis enable coloca un archivo drop-in que activa el plugin. Su changed_when es un buen ejemplo de leer con atención la salida de una herramienta: WP-CLI imprime «Activating …» incluso para un plugin que ya está activo, seguido de «already active» en stderr. Así que solo cuenta como cambio una ejecución que no informe a la vez de «already installed» y «already active».
appearance.yml, ai_disclosure.yml, settings.yml
Tres archivos más pequeños, cada uno con una idea:
- Apariencia ejecuta pequeños fragmentos de PHP mediante
wp evalpara fijar el CSS personalizado y el menú, ambos guardados en la base de datos. El comentario admite honestamente una limitación: el CSS se escribe en cada ejecución, pero solo su primera creación cuenta como «changed» – un bloque CSS modificado se aplica, pero el resumen muestra «ok». - El aviso de IA crea la página de transparencia y su enlace desde la página Sobre mí – primero se compara, solo se escribe si hay diferencia, así que una edición manual en el área de administración se revierte – y despliega tres plugins must-use más con el mismo patrón
.newy suma de comprobación: el aviso del pie de página, mejoras de rendimiento (precarga de fuentes y una primera imagen cargada de inmediato, después de que una prueba móvil de Lighthouse mostrara ambos problemas) y la línea «También disponible en» para las traducciones. - Ajustes cierra el registro público (lo estaban explotando bots de spam: dieciocho cuentas falsas en una sola limpieza) y desactiva el cron de WordPress disparado por los visitantes, porque el CronJob se encarga de eso. Dos ajustes, dos maneras de ser idempotente:
wp option updatedice «Value updated» solo cuando algo cambió de verdad, así quechanged_whenlee eso; la constante del cron primero se lee y solo se escribe si no es ya verdadera.
updates.yml: actualizaciones, limpieza y un reinicio honesto
El último archivo actualiza núcleo, plugins, temas y traducciones en cada ejecución, dentro de otra pareja block/always con la misma corrección de propietario. Dos técnicas lo hacen funcionar:
- Un
failed_whenque lee la salida.wp plugin update --allsale con 1 cuando no hay nada que actualizar – el caso normal. Así que la tarea solo falla si el código de salida no es 0 y la salida no dice «No plugins updated». Lo mismo para los temas, y para borrar plugins que ya no están. - Dos instantáneas. Al principio de
plugins.ymlel rol anota todas las versiones de núcleo, plugins y temas; aquí las anota de nuevo. WordPress solo se reinicia si algo informó de una actualización o las dos instantáneas difieren. Una ejecución sin actualizaciones no causa ninguna caída.
Entre los pasos de actualización, el rol también borra los plugins y temas sin usar, porque un plugin desactivado sigue en el disco y sigue siendo accesible – una vulnerabilidad en él sigue siendo explotable. Los temas inactivos se calculan, no se enumeran: theme list --status=inactive nunca puede devolver el tema activo, así que el tema del sitio está estructuralmente a salvo, y se conserva un tema por defecto como respaldo de WordPress. El borrado se ejecuta a propósito después de la actualización del núcleo: una actualización del núcleo puede reinstalar los temas por defecto incluidos.
Una limitación figura en la cabecera del rol: las tareas command se omiten en modo check. Por eso una simulación aquí solo puede ejecutar las consultas de solo lectura; lo que WP-CLI cambiaría no puede mostrarse de antemano.
📊 blog_visitor_stats: contar lectores sin rastreador
La idea tiene su propio artículo: sin script de seguimiento, sin cookies, sin terceros – contar visitantes a partir de los logs del Ingress, en el servidor, y quedarse solo con los números. K3s conserva los logs de los contenedores solo unos pocos días, así que hace falta una instantánea nocturna para ver una tendencia a lo largo de meses. El rol es corto, pero usa varias funciones que no aparecen en ningún otro sitio:
copy src: "{{ playbook_dir }}/scripts/analytics/visitor-stats.py"– un archivo de fuera del rol.playbook_dires el directorio del playbook en ejecución; un simplesrc: namebuscaría en elfiles/del rol. El script vive enscripts/porque también es una herramienta para ejecutar a mano.- Cuatro unidades de systemd a partir de plantillas, en un
loop, connotifya un handler que recarga systemd – ymeta: flush_handlersjusto después, porque los temporizadores tienen que arrancar con las unidades nuevas, no al final del play. - El servicio diario se ejecuta con las opciones de sandbox de systemd: todo el sistema de archivos en solo lectura salvo su propio directorio de datos, sin directorios personales, un
/tmpprivado, sin escalada de privilegios. Un script de estadísticas que lee logs no necesita más. - Un relleno único: los logs que siguen en el disco cubren los últimos días, así que el rol los registra una vez, con un
loopde 1 a 5 y una fecha calculada en Jinja –'%Y-%m-%d' | strftime(epoch - item * 86400), «hoy menos N días». Un{% for %}dentro de la línea de comandos añade un argumento--excludepor cada red a dejar fuera (mis propias conexiones, que de otro modo dominarían los números de un blog pequeño).creates:sobre un archivo marcador hace que todo el relleno se ejecute exactamente una vez.
✅ blog_verify y common_verify: ¿ha funcionado?
La parte 1 describió la idea: recoger todos los resultados, juzgarlos, informar y solo entonces fallar. Así está construido. blog_verify tiene tres archivos: main.yml fija los códigos de color del informe e importa los otros dos, checks.yml ejecuta las comprobaciones, report.yml las juzga.
Parte de las comprobaciones ni siquiera está escrita en este rol. common_verify es un rol biblioteca: nunca aparece en un playbook y no tiene tasks/main.yml, solo tres archivos de tareas – comprobaciones del host, de Kubernetes y HTTP. Ambos roles de verificación los incluyen:
- name: Verify | Shared host checks (common_verify)
ansible.builtin.import_role:
name: common_verify
tasks_from: hosttasks_from elige un archivo distinto de main.yml. El archivo del host comprueba nftables (servicio, tabla, política de descarte por defecto, lista de bloqueos), fail2ban, K3s, auditd, SELinux y TCP BBR; el de Kubernetes comprueba los pods del Ingress y de cert-manager y busca en todas partes pods en estado de error; el HTTP comprueba el sitio desde fuera, sus cabeceras y una ruta bloqueada. El blog añade las suyas: la página de inicio de sesión accesible, xmlrpc.php y readme.html bloqueados, ningún nombre de usuario a través de la API REST, OPcache activo, MariaDB accesible desde el pod de WordPress. Varias son exactamente las cosas que intenté romper una vez.
Cada tarea de comprobación tiene las mismas cuatro líneas: register: _v_something, failed_when: false, changed_when: false, check_mode: false. Nada falla, nada cambia, todo se ejecuta incluso en simulación. report.yml calcula luego un verdadero/falso por comprobación con set_fact, los cuenta, imprime una tabla en color y la envía por correo – antes del assert final, para que el correo salga también cuando algo crítico está roto. Solo el assert puede hacer fallar el play. Un último paso opcional ejecuta una autoprueba del perro guardián Monit, que envía correos de alarma reales – desactivado por defecto, activado por ejecución con -e monit_selftest=true.
🔄 blog-update.yml: la actualización rutinaria
El playbook completo puede actualizar WordPress, pero toca todo el servidor. El playbook de actualización solo toca la aplicación, y lo hace igual que la tarea nocturna – lanza esa tarea. La estrategia que hay detrás es más antigua que este código; esto es lo que hace el código hoy:
- Guardas. Negarse a ejecutarse sin
--limit. Negarse si WordPress no tiene ningún pod disponible – actualizar un sitio que ya está caído mezclaría dos problemas. - Versiones antes, para el informe y la decisión de reiniciar.
- Ejecutar ya la tarea de actualización.
kubectl create job --from=cronjob/wordpress-autoupdatearranca una copia única de la tarea nocturna: volcado de la base de datos y luego las actualizaciones. El playbook consulta después el estado de la tarea conuntilen lugar dekubectl wait --for=condition=complete– esto último se quedaría parado hasta su tiempo límite si la tarea falla en lugar de completarse. Se imprime el log de la tarea. Todo el paso se omite en modo check. - Imágenes reconstruidas.
common_image_refreshejecuta un pequeño script de Python en el servidor (con el móduloscriptde Ansible, que lo copia, lo ejecuta y lo borra). Para cada Deployment vuelve a descargar la etiqueta de imagen y compara el digest con aquel con el que arrancó el pod en marcha. Docker Hub reconstruye etiquetas sin cambios para correcciones de seguridad de la imagen base, pero K3s nunca vuelve a descargar por su cuenta. El script devuelve JSON: qué Deployments ejecutan una compilación desactualizada. - Reiniciar solo lo que cambió, en un orden fijo: primero la base de datos, WordPress el último – WordPress se reconecta a una base de datos reiniciada, pero un WordPress reiniciado frente a una base de datos que justo se está apagando serviría errores. La expresión del bucle mantiene ese orden y añade al final todo lo que no está en la lista.
- Comprobación de la portada con el módulo
uri: HTTP 200, o la ejecución falla.
Todo eso está dentro de un block. Si algo falla, la sección rescue muestra los pods y los eventos recientes, el volcado de base de datos más reciente de la tarea de actualización (el punto de restauración) y el estado en el momento del fallo – y luego se detiene. Sin vuelta atrás automática: una restauración es una decisión, no un reflejo.
🛠️ En operación: el blog en el día a día
Todo lo anterior ocurre de forma automática. Esta sección es lo que haces a mano – los comandos, la rutina y qué hacer cuando algo va mal. Todos los comandos se ejecutan como root en el servidor; k3s kubectl es el cliente de Kubernetes que viene con K3s.
Echar un vistazo
# Are all pods running? (mariadb, redis, wordpress; cron pods come and go)
k3s kubectl get pods -n wordpress
# Did the scheduled jobs succeed? (wordpress-cron every 5 min, wordpress-autoupdate nightly)
k3s kubectl get jobs -n wordpress
# Logs of a container (add --previous after a crash)
k3s kubectl logs deployment/wordpress -c wordpress-fpm -n wordpress --tail=50
# Is the certificate valid, and when is it renewed?
k3s kubectl get certificate -n wordpress \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,RENEWAL:.status.renewalTimeHablar con WordPress
WP-CLI está en el pod. Ejecútalo como www-data, nunca como root, salvo que instales código y corrijas el propietario después – los archivos propiedad de root son aquí la forma más habitual de romper plugins y cachés:
k3s kubectl exec -n wordpress deployment/wordpress -c wordpress-fpm -- \
su -s /bin/sh www-data -c 'php /usr/local/lib/wp-cli/wp-cli.phar --path=/var/www/html plugin list'La rutina
| Cuándo | Qué | Cómo |
|---|---|---|
| Cada noche | Actualizaciones de plugins, temas, traducciones y menores del núcleo, con volcado de la base de datos antes | automático (CronJob de actualización); comprobar con kubectl get jobs |
| Cuando lo quieras ya | Lo mismo, más las imágenes de contenedor reconstruidas | ansible-playbook blog-update.yml --limit <host> – ejecútalo primero con --check |
Cuando cambia versions.yml | Etiquetas de imagen nuevas (WordPress, MariaDB, nginx, Redis) | ansible-playbook blog.yml --limit <host> --tags blog_k3s_deploy, tras una simulación |
| Tras cualquier cambio en el repositorio | Todo, comprobado | el playbook completo; scripts/check-drift.sh antes y después |
| Tras cada ejecución | Leer el correo de verificación | todo en verde, o los nombres de las comprobaciones fallidas |
Cuando algo va mal
- 502 Bad Gateway: nginx está en marcha, PHP-FPM no responde. Mira los logs de FPM (arriba); un reinicio es
k3s kubectl rollout restart deployment/wordpress -n wordpress– alrededor de medio minuto de caída por la estrategia Recreate. - Un pod no arranca:
k3s kubectl describe pod -l app=wordpress -n wordpressmuestra los eventos al final. Las causas habituales son una base de datos que no está en marcha, una contraseña en el Secret que no coincide con el usuario de la base de datos (vuelve a ejecutar el playbook) o propietarios incorrectos en los directorios del host (uid 33 para la raíz web, 999 para la base de datos). - Una actualización de plugin rompió el sitio: la tarea nocturna volcó antes la base de datos, en el directorio de copias de actualización del host (14 días). Restaura el volcado si la actualización tocó la base de datos, y fija el plugin en la última versión buena:
wp plugin install <slug> --version=<x> --force. La salida de rescue del playbook de actualización indica el volcado más reciente. - Todo el servidor ha desaparecido: monta uno nuevo con el playbook completo, luego restaura archivos y base de datos desde la copia externa con el script de restauración, y vuelve a ejecutar el playbook una vez. La copia se recoge desde fuera cada día – los archivos con rsync, la base de datos como volcado verificado, con versiones durante 30 días – y el perro guardián avisa cuando la última copia correcta tiene más de tres días. El manual de operación del repositorio contiene cada paso.
🧭 Lo que viene
La parte 4 es Nextcloud: las mismas piezas, pero con la base de datos y Redis en el host, una suite ofimática al lado, un servicio push y un rol de configuración que fija cada valor con «leer, comparar, fijar». Si solo tienes un blog, puedes saltártela e ir directamente a la parte 5, los roles de seguridad – SSH, el cortafuegos, el endurecimiento, SELinux y los escáneres nocturnos.


