
🌐 También en: English · Deutsch · Français
Parte 2 de la visita a Ansible. La parte 1 explicó cómo está organizado el repositorio y qué ocurre en una ejecución; esta parte empieza en lo más bajo de la pila. Como toda la serie, describe la versión v3.2.0, y cada enlace a un archivo apunta allí.
Cada servidor de esta instalación empieza como una simple imagen cloud de AlmaLinux 9: un Linux mínimo, un acceso root y nada más. Al final de esta parte es un sistema base parcheado y ajustado que sabe enviar correos, se actualiza solo por la noche, rota sus logs – y ejecuta un clúster Kubernetes de un solo nodo con Helm, certificados automáticos de Let’s Encrypt, un controlador Ingress y un motor de políticas. Esa es la plataforma: todo lo que el blog y Nextcloud necesitan por debajo, y casi todo es común a ambos.
La parte cubre ocho roles. En cada uno recorro paso a paso qué hace, qué funciones de Ansible usa y por qué, y qué salió mal en el camino hasta la versión actual – incluidos los handlers, las entradas declaradas en meta/ y la lógica de las plantillas. Donde hace falta un concepto de Linux o Kubernetes para entender el código, lo explico donde aparece por primera vez.
| Rol | En una frase | Usado por |
|---|---|---|
blog_packages / next_packages | Nombre de host, repositorios, actualización del sistema, reinicio si hace falta, paquetes base | uno por pila |
common_sysctl | Parámetros del kernel para la red y el endurecimiento | ambos |
common_msmtp | Permite al servidor enviar correo a través de una cuenta SMTP externa | ambos |
common_dnf_automatic | Actualizaciones de seguridad cada noche, reinicio si hace falta | ambos |
common_logrotate | Reglas de rotación para logs que no tenían ninguna | ambos |
common_k3s | K3s, Helm, cert-manager, el controlador Ingress | ambos |
common_calico | Hace que las NetworkPolicies de Kubernetes se apliquen de verdad | ambos |
El lugar de estos roles en el playbook importa, así que recordemos el orden: el rol de paquetes se ejecuta justo después del informe de versiones, luego SSH, el cortafuegos y common_sysctl (los tres roles de seguridad están en la parte 5), luego el endurecimiento y SELinux, luego common_k3s y common_calico, y luego la aplicación. common_msmtp, common_dnf_automatic y common_logrotate se ejecutan más tarde, una vez desplegada la aplicación.
🖥️ Antes de cualquier rol: el propio servidor
Ansible necesita algo con lo que hablar: un servidor Linux con dirección pública y SSH. Los tres servidores aquí son servidores privados virtuales (VPS, también llamados vServer) – máquinas virtuales en el hardware de un proveedor, alquiladas por meses. Obtienes una parte fija de núcleos de CPU, memoria y disco, una dirección IPv4 e IPv6 pública, acceso root y una consola web para emergencias. No obtienes a nadie que cuide del sistema: actualizaciones, seguridad, copias de seguridad y monitorización son cosa tuya. Ese es exactamente el trabajo de este repositorio.
Elegir un proveedor
El mercado es grande y las ofertas se parecen, así que conviene compararlas en los puntos que cuentan para operar un servicio, no solo por el precio:
| Punto | En qué fijarse |
|---|---|
| Recursos | Número de vCPU, RAM, tamaño y tipo de disco (SSD/NVMe). Comprueba si los núcleos son dedicados o compartidos – los compartidos son más baratos y valen para un blog, pero pueden ir lentos cuando un vecino genera mucha carga. |
| Ubicación y legislación | Dónde está el centro de datos y bajo qué legislación opera el proveedor. Para datos personales de usuarios en la UE, un centro de datos en la UE y un contrato de encargo de tratamiento (RGPD art. 28) son el mínimo. |
| Red | Una dirección IPv4 fija y una IPv6 (o una red), el tráfico incluido y si el proveedor filtra ataques (DDoS) antes de que lleguen. |
| Acceso por consola | Una consola web que funcione aunque SSH no lo haga. Con SSH y el cortafuegos endurecidos, es el camino de vuelta tras un error – pruébala antes de necesitarla. |
| Snapshots | ¿Se puede tomar una imagen de todo el disco antes de un cambio arriesgado? No es una copia de seguridad (se queda en el proveedor), pero es la forma más rápida de deshacer. |
| Imágenes | ¿Ofrece el proveedor una imagen limpia de la distribución deseada, idealmente mínima? |
| DNS y DNS inverso | Necesitas registros DNS para tu dominio (en algún sitio), y un DNS inverso coincidente para la dirección del servidor ayuda cuando envía correos. |
| Contrato | Cancelación mensual, cómo funcionan las ampliaciones (¿más RAM sin reinstalar?) y qué soporte se obtiene en la práctica. |
Los tres servidores aquí están en dos proveedores distintos, todos con virtualización KVM. El reparto es una pequeña red de seguridad: una caída o un problema de cuenta en un proveedor no lo tumba todo.
Elegir la distribución
Los roles están escritos para AlmaLinux 9 y para nada más – se dan por supuestos en todas partes los nombres de paquetes, las rutas, SELinux y dnf. Cómo pasé de Debian a AlmaLinux es otra historia; las razones que cuentan para la operación son:
- Soporte largo. AlmaLinux es compatible a nivel binario con Red Hat Enterprise Linux y sigue su ciclo de versiones: cada versión mayor recibe actualizaciones de seguridad durante unos diez años. Un servidor instalado hoy no necesita una gran actualización durante años.
- Las actualizaciones de seguridad están etiquetadas. Cada actualización tiene un aviso con un tipo. Eso es justo lo que permite a
dnf-automatic(más abajo) instalar automáticamente solo las correcciones de seguridad y dejar el resto para una ejecución planificada. - SELinux en modo «enforcing» por defecto, con una política para contenedores que K3s admite. La parte 5 muestra por qué importa.
- Un gran ecosistema. La documentación, el CIS Benchmark y la mayoría de herramientas existen primero para esta familia.
Un servidor Debian o Ubuntu funcionaría igual de bien con otros roles – pero no con estos.
¿De qué tamaño?
El tamaño es primero una estimación y después una medición. Estos son los tres servidores, con lo que usan realmente en un día tranquilo:
| Servidor | vCPU | RAM | Disco | RAM en uso (en reposo) |
|---|---|---|---|---|
| Blog | 4 | 8 GB | 240 GB | unos 2,5 GB |
| Nextcloud pequeño (pocos usuarios) | 6 | 12 GB | 200 GB | unos 3 GB |
| Nextcloud grande (unos 150 usuarios) | 8 | 24 GB | 600 GB | unos 4 GB |
Adónde va la memoria, medido en los tres: la plataforma de esta parte – el propio K3s, el entorno de ejecución de contenedores, los pods de sistema del clúster, cert-manager y el Ingress – ocupa unos 1,3 a 1,5 GB. Prometheus y Grafana suman unos 0,5 GB. El resto es la aplicación y la caché de archivos del sistema, que Linux llena con toda la memoria libre (eso es bueno, no un problema).
De ahí, una regla aproximada:
- Un blog como este necesita al menos 2 vCPU y 4 GB de RAM; 4 GB van justos con monitorización, 8 GB son cómodos. Disco: unos pocos gigabytes para WordPress, más los archivos subidos, más espacio para las copias de la base de datos.
- Nextcloud con ofimática en línea necesita más: cada documento abierto en la suite cuesta 150–200 MB, y las vistas previas, la base de datos y PHP quieren memoria. 4 vCPU y 8 GB son el mínimo para un grupo pequeño, más para muchos usuarios simultáneos. El disco depende por completo de los datos de los usuarios – planifica el crecimiento y vigílalo (la parte 6 muestra las alertas al 80 y al 90 por ciento).
- Sin swap. Ninguno de los servidores tiene swap. Kubernetes espera tradicionalmente que esté desactivado, y un servidor que empieza a usar swap ya es demasiado pequeño – mejor ver la alerta de memoria y ampliar.
- Ampliar en lugar de adivinar a lo grande. La mayoría de proveedores permiten añadir núcleos y memoria más tarde. Empieza una talla por debajo, mide con la monitorización de la parte 6 y crece cuando lo digan los números.
Qué necesita el servidor antes de la primera ejecución
Muy poco, y de eso se trata: una imagen mínima y recién creada de AlmaLinux 9, acceso root con clave SSH en el puerto 22, las direcciones y el nombre de host del servidor en el inventario, y registros DNS que apunten el dominio al servidor (cert-manager solo puede obtener un certificado para un nombre que resuelva hacia él). La primera ejecución mueve SSH a su nuevo puerto, activa el cortafuegos y reinicia una vez – la parte 5 explica cómo funciona sin dejar a Ansible fuera.
📦 blog_packages y next_packages: de la imagen cloud al sistema base
Estos dos roles son la única pareja del repositorio que hace el mismo trabajo en dos variantes. El blog ejecuta todo en contenedores, así que su host necesita muy poco. Un host de Nextcloud ejecuta además MariaDB y Redis directamente en el host (la parte 4 explica por qué) y por eso necesita dos repositorios y dos servicios más. Todo lo demás es igual; describo ambos juntos y señalo las diferencias.
Paso 1: un nombre de host de verdad
- name: Packages | Set system hostname to the public FQDN
ansible.builtin.hostname:
name: "{{ blog_hostname }}"
- name: Packages | Ensure /etc/hosts has a matching entry
ansible.builtin.lineinfile:
path: /etc/hosts
regexp: '^127\.0\.1\.1'
line: "127.0.1.1 {{ blog_hostname }}"Una imagen cloud recién creada se llama localhost. Suena inofensivo hasta que varios servidores envían correos: cada informe de rkhunter, de ClamAV o de la tarea de actualización nocturna pone $(hostname -f) en su asunto, y cinco correos de «localhost» no sirven de nada. Así que la primerísima tarea fija el nombre de host público a partir del inventario. El módulo hostname hace lo que hace hostnamectl – cambia el nombre en uso y lo hace permanente.
La segunda tarea muestra un módulo que verás a menudo: lineinfile. Gestiona una línea de un archivo. regexp dice de qué línea se encarga (aquí: la que empieza por 127.0.1.1), line dice cómo debe ser. Si hay una línea que coincide y es correcta: ok. Si existe pero difiere: reemplazada. Si no hay ninguna: añadida. Es idempotente por diseño y deja en paz el resto del archivo – la herramienta adecuada cuando un archivo pertenece al sistema y Ansible solo gestiona una línea de él.
¿De dónde viene blog_hostname? Del vars.yml del host – y el rol lo dice en meta/argument_specs.yml:
argument_specs:
main:
short_description: Minimal host-level packages for the blog server
options:
blog_hostname:
description: Public hostname of the blog (e.g. www.example.de)
type: str
required: trueAntes de que se ejecute la primera tarea, Ansible comprueba cada opción listada ahí. Un host sin blog_hostname falla justo aquí con un claro «missing required arguments» – no tres tareas después con un error de variable no definida en una plantilla. La variante de Nextcloud declara nextcloud_hostname de la misma forma.
Paso 2: repositorios – primero preguntar, luego actuar
Un repositorio es una fuente de la que dnf descarga paquetes. AlmaLinux viene con BaseOS y AppStream activados. Muchas herramientas de esta instalación (fail2ban, Monit, rkhunter, el cliente Python de Kubernetes) están en EPEL, el repositorio «Extra Packages for Enterprise Linux», así que ambos roles instalan epel-release – un paquete pequeño cuyo único contenido es la definición del repositorio.
El rol de Nextcloud necesita dos más. CRB (CodeReady Builder) forma parte de AlmaLinux pero está desactivado, y algunos paquetes de EPEL dependen de él. No hay ninguna opción de módulo de Ansible para «activa este repositorio», así que el rol usa un comando – y lo hace idempotente con una guarda:
- name: Packages | Read list of currently enabled repositories (to check if CRB is active)
ansible.builtin.command: dnf repolist --enabled
register: enabled_repos
changed_when: false
check_mode: false
- name: Packages | Enable CRB repository (only if not already active)
ansible.builtin.command: dnf -y config-manager --set-enabled crb
when: "'crb' not in enabled_repos.stdout"
changed_when: trueEsta pareja «primero preguntar, luego actuar» es el patrón más común de todo el repositorio, así que merece la pena leerla despacio. La primera tarea solo lee: ejecuta dnf repolist y guarda el resultado con register. changed_when: false le dice a Ansible que leer no cambia nada (si no, una tarea command informaría siempre de «changed», porque Ansible no puede saber qué hace un comando). check_mode: false hace que se ejecute incluso en simulación – es inofensiva, y sin su salida la segunda tarea no podría decidir nada. La segunda tarea solo se ejecuta when CRB falta en esa salida, y si se ejecuta, ha cambiado algo (changed_when: true). Resultado: la primera ejecución informa de un cambio, todas las siguientes de ninguno.
El tercer repositorio es MariaDB Community. AlmaLinux tiene su propio MariaDB, pero los hosts de Nextcloud ejecutan una serie de versiones concreta directamente de MariaDB, fijada en versions.yml. Tres pasos:
rpm_keyimporta la clave GPG del proyecto. Cada paquete está firmado; con la clave instalada,dnfrechaza paquetes modificados por el camino.- Una tarea
copyescribe/etc/yum.repos.d/mariadb.repocon la serie de versiones fijada en la URL – la variable aparece incluso en el nombre de la tarea, de modo que la salida de la ejecución muestra qué versión se configuró. - Se desactiva el «módulo» de AppStream para MariaDB. Los módulos de AppStream son flujos de paquetes alternativos, y un módulo de MariaDB activado taparía los paquetes Community. Otra vez «primero preguntar, luego actuar»: primero
dnf module list --disabled mariadb, ydnf module disablesolo si aún no está desactivado.
Un detalle más: EPEL recibe module_hotfixes=1 en su archivo de repositorio, mediante el módulo community.general.ini_file. Un archivo .repo es un archivo INI, e ini_file fija exactamente una opción en una sección – el equivalente INI de lineinfile.
Paso 3: la actualización del sistema – y por qué el kernel se queda fuera
- name: Packages | Upgrade all system packages # noqa: package-latest
ansible.builtin.dnf:
name: "*"
state: latest
update_cache: true
exclude: kernel*name: "*" con state: latest es un dnf upgrade completo. ansible-lint normalmente avisa contra state: latest (hace que las ejecuciones no sean reproducibles), y el comentario # noqa le dice que aquí una actualización completa es precisamente el objetivo.
Lo interesante es exclude: kernel*, y para entenderlo hace falta un trozo del endurecimiento de SSH de la parte 5. El servidor SSH se ejecuta con un ajuste de systemd llamado ProtectKernelModules. systemd inicia cada sesión SSH en su propio espacio de nombres de montaje – una vista privada del sistema de archivos –, y en esa vista el directorio de módulos del kernel /usr/lib/modules está vacío y es de solo lectura. Nadie que entre por SSH puede cargar un módulo del kernel, de eso se trata. Pero Ansible también entra por SSH, e instalar un kernel nuevo significa escribir sus módulos justo en ese directorio. Dentro de la sesión, eso falla siempre.
Así que aquí los kernels quedan excluidos, y aun así llegan: dnf-automatic (más adelante en este artículo) se ejecuta como temporizador de systemd, fuera de cualquier sesión SSH, e instala cada noche las actualizaciones de seguridad – kernels incluidos.
Paso 4: reiniciar solo cuando de verdad hace falta
Dos situaciones exigen un reinicio antes de que el resto del play pueda funcionar. La primera es traicionera: una imagen cloud a veces ya tiene instalado un kernel más nuevo cuando Ansible se conecta por primera vez, mientras todavía se ejecuta el antiguo. El directorio de módulos del kernel antiguo ha desaparecido, y la primera tarea que necesita un módulo – common_sysctl, que carga tcp_bbr unos roles más adelante – falla con un desconcertante «No such file or directory». Mejor notarlo aquí:
- name: Packages | Check whether the running kernel's module directory still exists
ansible.builtin.command: >-
systemd-run --wait --pipe --quiet --
test -d /lib/modules/{{ ansible_facts['kernel'] }}
register: running_kernel_modules
changed_when: false
check_mode: false
failed_when: false
- name: Packages | Reboot – running kernel's modules are gone
ansible.builtin.reboot:
reboot_timeout: 300
when: running_kernel_modules.rc != 0¿Por qué no comprobar simplemente el directorio con stat? Por el mismo sandbox de SSH: dentro de la sesión, /lib/modules siempre parece vacío, y una comprobación sencilla «encontraría» en cada ejecución un kernel ausente y reiniciaría para nada. systemd-run es la salida. Pide a systemd (PID 1) que inicie un servicio efímero – una unidad transitoria – que se ejecuta fuera del espacio de nombres de la sesión SSH y ve el sistema de archivos real. --wait espera a que termine, --pipe devuelve su salida y su código de salida. El endurecimiento se mantiene íntegro; solo ese comando se ejecuta donde puede mirar. Volverás a ver systemd-run en common_sysctl, por el mismo motivo.
ansible_facts['kernel'] es uno de los pocos facts que usa este repositorio – la versión del kernel en ejecución, recogida al comienzo del play. failed_when: false convierte «test devolvió 1» en un resultado en lugar de un fallo, y el módulo reboot hace el resto: reinicia, espera hasta cinco minutos a que SSH vuelva a responder, y el play sigue sin más con la siguiente tarea.
El rol del blog tiene una segunda comprobación que el de Nextcloud no tiene: needs-restarting -r (de yum-utils) informa de si bibliotecas del sistema actualizadas como glibc, systemd u OpenSSL esperan un reinicio. Sale con 1 si es así, y el blog reinicia en el acto. El rol de Nextcloud no tiene esa segunda comprobación; allí esos reinicios ocurren por la noche mediante la tarea descrita más abajo.
Paso 5: paquetes y servicios
Después vienen las listas de paquetes propiamente dichas. En el blog: nftables, las herramientas de SELinux, container-selinux (la política de SELinux que permite ejecutar contenedores) y unas cuantas utilidades y herramientas de diagnóstico. En Nextcloud, además: MariaDB-server, redis, rsync y rdiff-backup para las copias de seguridad, y python3-PyMySQL – la biblioteca de Python que necesitan en el host los módulos MySQL de Ansible. El rol de Nextcloud activa e inicia después MariaDB y Redis.
El último bloque desactiva lo que este servidor no usa. firewalld se detiene, se desactiva y, en el blog, además se enmascara – masked: true enlaza la unidad a /dev/null, de modo que ya no puede iniciarse en absoluto, ni siquiera como dependencia de otro servicio (el rol de cortafuegos de la parte 5 lo enmascara en todos los hosts). Los auxiliares de NFS/RPC y LDAP (rpcbind, sssd) siguen el mismo camino, y gssproxy se enmascara porque de otro modo vuelve por activación de socket aunque esté desactivado. Todas estas tareas tienen failed_when: false: en un host donde el servicio no existe, «detén X» no debe abortar la ejecución.
| Pieza | Dónde | Qué llevarse |
|---|---|---|
hostname, lineinfile | Paso 1 | Cambiar una cosa (un nombre, una línea) sin gestionar todo el archivo |
argument_specs | meta/ | Las entradas se comprueban antes de la primera tarea |
register + when + changed_when | Paso 2 | «Primero preguntar, luego actuar» para comandos sin módulo |
rpm_key, ini_file | Paso 2 | Confiar en un repositorio, cambiar una opción INI |
# noqa | Paso 3 | Silenciar una regla de lint donde romperla es el objetivo |
systemd-run, reboot | Paso 4 | Mirar más allá del sandbox de SSH; reiniciar y seguir |
masked: true, failed_when: false | Paso 5 | Hacer que un servicio no pueda arrancar; ignorar los que faltan |
Sin handlers ni defaults: las listas de paquetes están directamente en las tareas, y cada servicio se inicia donde se instala.
🧮 common_sysctl: parámetros del kernel
El kernel de Linux expone en /proc/sys cientos de ajustes – cómo se comporta TCP, el tamaño de las colas, si el sistema acepta ciertos paquetes. La herramienta para fijarlos se llama sysctl, y para hacerlos permanentes se usa un archivo en /etc/sysctl.d/: cada archivo de ahí se lee al arrancar. Este rol escribe dos de esos archivos y carga un módulo del kernel.
Dos plantillas, dos propósitos
99-webserver.conf va de velocidad:
- TCP BBR en lugar del CUBIC por defecto como algoritmo de control de congestión – la parte de TCP que decide a qué velocidad enviar. BBR mide el ancho de banda y el tiempo de ida y vuelta en lugar de esperar a perder paquetes, lo que ayuda en enlaces largos o con pérdidas. Necesita el planificador de paquetes
fq(fair queueing), fijado en la línea anterior. - TCP Fast Open y sin arranque lento tras inactividad: en un sitio con visitas irregulares, una conexión que ha estado tranquila un momento no debe volver a empezar despacio.
- Tiempos de espera más cortos para conexiones que se cierran o están inactivas, y colas más grandes para conexiones entrantes, para que una avalancha de visitantes no se quede en la puerta.
98-cis-hardening.conf va de seguridad – los puntos de red del CIS Benchmark (toda una historia): sin redirecciones ICMP (un router que le dice al host «mejor envíalo por aquí» es la forma en que un atacante en la ruta desvía tráfico), sin enrutamiento por origen, registro de paquetes con direcciones de remitente imposibles, SYN cookies contra inundaciones SYN, aleatorización completa del espacio de direcciones, ptrace restringido y sin volcados de memoria de procesos privilegiados.
Lo más instructivo de este archivo es lo que omite. Un perfil CIS habitual desactiva el reenvío IP y activa el filtrado estricto de ruta inversa (reverse path filtering). En un nodo de Kubernetes, ambas cosas romperían el clúster: a los pods se llega mediante enrutamiento en el host, así que el reenvío debe seguir activo, y el filtrado estricto de ruta inversa es una causa conocida de tráfico de pods descartado. La plantilla lo dice en un comentario, con la fecha de la comprobación – para que nadie lo «arregle» más tarde siguiendo a ciegas el benchmark.
Cargar BBR – otra vez a través del sandbox
- name: Sysctl | Load tcp_bbr kernel module
ansible.builtin.command: systemd-run --wait --pipe --quiet -- modprobe tcp_bbr
changed_when: false
- name: Sysctl | Persist tcp_bbr kernel module across reboots
ansible.builtin.copy:
dest: /etc/modules-load.d/tcp_bbr.conf
content: |
tcp_bbrBBR es un módulo del kernel, y cargar módulos es justo lo que prohíbe el sandbox de SSH. Así que el módulo se carga mediante systemd-run, y un archivo en /etc/modules-load.d/ hace que systemd lo cargue en cada arranque. Existe un módulo de Ansible para esto, community.general.modprobe, pero primero lee el directorio de módulos para comprobar el estado – y dentro de la sesión ese directorio está vacío, así que falla con un mensaje engañoso. Dos tareas pequeñas que hacen lo mismo son más honestas que un módulo que no ve nada.
Aplicar los valores: un handler y una tarea
- name: Sysctl | Deploy web server kernel tuning
ansible.builtin.template:
src: 99-webserver.conf.j2
dest: /etc/sysctl.d/99-webserver.conf
notify: apply sysctl
- name: Sysctl | Apply settings immediately
ansible.builtin.command: systemd-run --wait --pipe --quiet -- sysctl --system
changed_when: falseEscribir un archivo en /etc/sysctl.d/ solo surte efecto en el siguiente arranque; sysctl --system aplica todos los archivos de inmediato. El rol lo hace a propósito dos veces. El handler apply sysctl se ejecuta al final del play si alguno de los archivos ha cambiado – la lógica normal de handlers de la parte 1. La tarea sin condición de arriba se ejecuta en cada ejecución, y atrapa algo que ningún cambio de archivo notaría: que alguien haya cambiado un valor en caliente con sysctl -w. El archivo sigue siendo correcto, así que no se dispara ningún handler, pero el kernel en ejecución no lo es. Esta tarea lo restablece. No puede saber si ha cambiado algo, así que dice changed_when: false. Ambas pasan por systemd-run: el sandbox de SSH también deja /proc/sys en solo lectura (ProtectKernelTunables).
Transparent Huge Pages
Un ajuste no es un sysctl en absoluto: las Transparent Huge Pages, una función de memoria que agrupa la memoria en páginas grandes. En «always» provoca picos de latencia en bases de datos y Redis, así que pasa a madvise – solo los programas que lo piden las reciben. El valor vive en /sys/kernel/mm/, y no hay archivo de configuración para él. Por eso el rol escribe un diminuto servicio de systemd que lo fija al arrancar (Type=oneshot con RemainAfterExit=yes: ejecutarse una vez y luego contar como «activo»), lo activa y notifica a un segundo handler que fija el valor de inmediato – de nuevo mediante systemd-run.
| Pieza | Qué llevarse |
|---|---|
template hacia /etc/sysctl.d/ | Los ajustes permanentes del kernel son simplemente archivos |
notify + una tarea sin condición | Handler para cambios de archivo, tarea para desviaciones en caliente |
systemd-run | Escribir en el kernel desde fuera del sandbox de SSH |
| unidad oneshot | Para ajustes sin archivo de configuración |
✉️ common_msmtp: un servidor que sabe enviar correo
La mitad de los mecanismos de protección de esta instalación avisan por correo: los escáneres nocturnos, la tarea de actualización, el perro guardián, la alarma de inicio de sesión SSH. Un servidor de correo completo sería excesivo y una cosa más que asegurar. msmtp es lo contrario: sin servicio, sin cola, sin puerto abierto. Un programa que quiere enviar un correo llama a sendmail (el paquete msmtp proporciona ese comando), msmtp se conecta a una cuenta SMTP externa, entrega el correo y termina.
El rol tiene tres tareas – paquete, archivo de configuración, archivo de log –, pero cada una enseña algo:
- Defaults como capa de traducción.
defaults/main.ymlno contiene valores reales, solo correspondencias comomsmtp_host: "{{ mail_smtphost }}". El inventario usa los mismos nombresmail_*que Nextcloud para sus propios ajustes de correo, y el rol los traduce a sus propios nombres. Un único juego de valores, varios usuarios – Nextcloud, el perro guardián Monit y msmtp. - Secretos declarados como secretos. Los
argument_specsdel rol marcan el usuario y la contraseña SMTP conno_log: true, así que ni siquiera la validación de entradas los muestra nunca. no_logen la tarea de plantilla. El/etc/msmtprcgenerado contiene la contraseña en claro (permisos 0600, solo root). Una simulación con--diffimprimiría el contenido del archivo – así que la tarea oculta su salida por completo. El precio: una comprobación de deriva solo dice entonces «cambiaría», sin diff. Para un archivo con una contraseña, es el intercambio correcto.- Un archivo de log sin cambios falsos.
file: state=touchcrea el log, pero actualiza sus marcas de tiempo en cada ejecución, y eso cuenta como cambio.modification_time: preserveyaccess_time: preserveconservan las marcas de tiempo, de modo que la tarea solo es «changed» cuando hubo que crear el archivo.
🌙 common_dnf_automatic: actualizaciones mientras duermo
dnf-automatic forma parte de AlmaLinux: un temporizador de systemd (la versión systemd de una tarea cron) que ejecuta dnf upgrade sin supervisión. El rol lo instala, lo limita a actualizaciones de seguridad y – esta es la parte interesante – lo encaja en un plan para toda la noche.
Dos líneas en un archivo de configuración
Dos tareas lineinfile fijan upgrade_type = security (solo paquetes marcados como corrección de seguridad) y apply_updates = yes (instalar de verdad, no solo descargar). Todo lo demás sigue siendo un paso manual deliberado con el playbook.
La noche tiene un plan
Por la noche se ejecutan varias tareas, y su orden importa. Este es el plan tal como figura en el rol:
| Hora | Qué | Rol |
|---|---|---|
| 00:00 | AIDE compara los archivos del sistema con su referencia | common_cis_hardening (parte 5) |
| 01:00–02:00 | Se instalan las actualizaciones de seguridad | common_dnf_automatic |
| 03:30 | Reinicio si alguna actualización lo necesita | common_dnf_automatic |
| 04:00 | Se renueva la referencia de AIDE | common_cis_hardening (parte 5) |
| 04:30 | ClamAV analiza los datos de los usuarios (Nextcloud) | common_clamav (parte 5) |
| 06:00 | Correo de aviso si aún hay un reinicio pendiente | common_dnf_automatic |
El temporizador de serie se dispara a las 06:00 – después de la ventana de reinicio y después de la renovación de AIDE. La consecuencia se vio por las malas: cada actualización de seguridad aparecía la medianoche siguiente como alerta de AIDE, y un kernel nuevo esperaba casi un día al siguiente reinicio de las 03:30. Así que el rol mueve el temporizador con un drop-in:
- name: dnf-automatic | Move the install timer into the 01:00-02:00 window
ansible.builtin.copy:
dest: /etc/systemd/system/dnf-automatic-install.timer.d/schedule.conf
content: |
[Timer]
OnCalendar=
OnCalendar=*-*-* 01:00
RandomizedDelaySec=60m
register: dnf_automatic_timer_dropin
- name: dnf-automatic | Enable and start install timer
ansible.builtin.systemd:
name: dnf-automatic-install.timer
enabled: true
state: "{{ 'restarted' if dnf_automatic_timer_dropin is changed else 'started' }}"
daemon_reload: trueUn drop-in es un archivo en <unit>.d/ que sobrescribe partes de una unidad sin tocar el archivo de serie – las actualizaciones de paquetes pueden reemplazar el original, el drop-in se queda. Fíjate en la línea vacía OnCalendar=: en los ajustes de tipo lista, systemd añade cada asignación. Sin la línea vacía, el temporizador se dispararía a las 06:00 y a las 01:00. Una asignación vacía vacía primero la lista.
La segunda tarea calcula su propio state, con un if/else de Jinja en línea. Un temporizador en marcha conserva su horario antiguo hasta que se reinicia, así que hay que reiniciarlo si – y solo si – el drop-in ha cambiado. register en la tarea copy y el test is changed expresan exactamente eso, sin handler. (Un handler también serviría, pero solo se ejecutaría al final del play; así el temporizador queda bien al instante.)
Reinicio a las 03:30, control a las 06:00
- name: dnf-automatic | Configure nightly automatic reboot if required
ansible.builtin.cron:
name: "automatic reboot after security update"
hour: "3"
minute: "30"
job: >-
OUT=$(/usr/bin/needs-restarting -r 2>&1);
[ $? -ne 0 ] && { echo "$OUT" | /usr/bin/mail -s "... Rebooting now ..." {{ dnf_automatic_reboot_mail }};
/usr/sbin/shutdown -r now; }El módulo cron gestiona una entrada de crontab, identificada por su name – si cambias la tarea, se actualiza la misma entrada en lugar de aparecer una segunda. La tarea pregunta a needs-restarting -r si hay un reinicio pendiente, y solo entonces envía el motivo por correo y reinicia. >- es YAML para «pliega estas líneas en una» – una entrada de crontab tiene que ser una sola línea. Una segunda entrada a las 06:00 repite la misma comprobación: si todavía hay un reinicio pendiente, el automático ha fallado, y sale un aviso. El destinatario viene de defaults/, donde dnf_automatic_reboot_mail apunta simplemente a la dirección de administración del host y se puede sobrescribir por host.
Un detalle más que mantiene el plan unido: los hosts de Nextcloud van con hora alemana, el blog con UTC. Como los temporizadores y las tareas cron usan ambos el reloj del host, el orden es el mismo en todas partes, aunque las horas en el reloj de pared difieran.
🗂️ common_logrotate: logs que no crecen sin fin
logrotate se ejecuta a diario mediante un temporizador que viene con el sistema operativo. Lee cada archivo de /etc/logrotate.d/ y rota los logs que allí se describen: renombrar el log actual, empezar uno nuevo, comprimir los antiguos y acabar borrándolos. Grafana y Prometheus vienen sin ese archivo; fail2ban y rkhunter, con archivos incompletos. El rol consiste en cuatro tareas copy con content: – el texto del archivo está directamente en la tarea – y ningún handler: logrotate lee los archivos en su próxima ejecución.
La única directiva que necesita explicación es postrotate. Un programa en ejecución mantiene su archivo de log abierto. Si logrotate renombra el archivo, el programa sigue escribiendo en el renombrado – el log antiguo crece, el nuevo se queda vacío. postrotate le dice al programa que reabra su log: Grafana recibe un SIGHUP, fail2ban un fail2ban-client flushlogs. rkhunter no necesita nada, porque solo escribe durante su análisis nocturno y nunca mantiene el archivo abierto. || true al final evita que logrotate informe de un error si el servicio no está en marcha en ese momento.
☸️ common_k3s: un clúster Kubernetes en un solo servidor
Ahora el rol grande. Primero, lo que construye. Kubernetes ejecuta aplicaciones en contenedores y las mantiene en el estado que describes: «ejecuta un pod de WordPress con estos ajustes, accesible con este nombre». Esa descripción se escribe como objetos (YAML), se entrega al servidor de API, y Kubernetes hace que la realidad coincida. K3s es una distribución compacta de Kubernetes: un binario que contiene el servidor de API, el planificador, el agente del nodo (kubelet), un entorno de ejecución de contenedores y una red de pods sencilla (Flannel). Aquí se ejecuta como clúster de un solo nodo – un servidor es todo el clúster. Cómo llegué hasta ahí es una historia más antigua.
¿Por qué Kubernetes en un único servidor? Un blog y un Nextcloud también funcionarían como paquetes normales en el host. El clúster compensa de varias formas que importan para la operación: cada aplicación está descrita como un conjunto de objetos en el repositorio y puede reconstruirse idéntica en un servidor nuevo; actualizar es cambiar una etiqueta de imagen, y volver atrás es cambiarla de nuevo; cada aplicación vive en su propio namespace con sus propias reglas de red y límites de recursos; y ambas pilas se despliegan igual, así que lo que aprendes en una vale para la otra. El precio es la complejidad y unos 1,5 GB de memoria para la plataforma – por eso esta parte le dedica tanto tiempo.
Sobre K3s, el rol instala tres cosas: Helm, un gestor de paquetes para Kubernetes; cert-manager, que obtiene y renueva los certificados de Let’s Encrypt; y el F5 NGINX Ingress Controller, el servidor web en el borde que recibe cada petición HTTPS y la pasa a la aplicación correcta. El rol está repartido en cinco archivos, incluidos en un orden fijo:
- name: common_k3s | K3s install/upgrade
ansible.builtin.import_tasks: install_k3s.yml
- name: common_k3s | Helm install/upgrade
ansible.builtin.import_tasks: install_helm.yml
- name: common_k3s | cert-manager + Let's Encrypt ClusterIssuer
ansible.builtin.import_tasks: cert_manager.yml
- name: common_k3s | F5 nginx-ingress controller
ansible.builtin.import_tasks: nginx_ingress.yml
- name: common_k3s | Default ServiceAccount hardening (CIS 5.1.5)
ansible.builtin.import_tasks: default_sa_hardening.ymlimport_tasks es estático: Ansible lee los cinco archivos al arrancar el playbook, como si fueran uno largo. Así, las etiquetas, --list-tasks y las comprobaciones de sintaxis ven todas las tareas. Su pariente dinámico, include_tasks, lee un archivo solo cuando el play llega a él – útil cuando el nombre del archivo o la decisión de incluirlo dependen de algo que solo se calcula durante la ejecución. La parte 5 muestra el único sitio donde eso importa. Aquí todo se sabe de antemano, así que la forma estática es la correcta.
install_k3s.yml, parte 1: configuración antes de la instalación
El primer archivo escribe tres archivos de configuración antes de instalar o actualizar K3s – para que los ajustes valgan desde el primerísimo arranque y en cada ejecución posterior:
- Una política de auditoría para el servidor de API: quién hizo qué en el clúster. Los Secrets y ConfigMaps se registran solo con metadatos – quién accedió, nunca su contenido. Los comandos dentro de pods (
pods/exec) se registran con la petición, porque es lo más poderoso que se puede hacer con una aplicación en marcha. Los cambios de permisos (RBAC) se registran por completo. El ruidoso flujo de solo lectura de «events» se descarta. - Una configuración de admisión que limita cuántos events por segundo acepta el servidor de API, para que un pod que se cae en bucle no pueda inundarlo.
config.yaml, los ajustes propios de K3s: qué componentes integrados se desactivan, los parámetros del servidor de API para el log de auditoría y los plugins de admisión, y un límite de procesos por pod.
- name: K3s | Deploy /etc/rancher/k3s/config.yaml
ansible.builtin.copy:
dest: /etc/rancher/k3s/config.yaml
mode: "0600"
content: |
disable:
- traefik
- servicelb
- local-storage
flannel-backend: vxlan
write-kubeconfig-mode: "0600"
kube-apiserver-arg:
- "audit-log-path=/var/lib/rancher/k3s/server/logs/audit.log"
- "audit-policy-file=/etc/rancher/k3s/audit-policy.yaml"
- "enable-admission-plugins=NodeRestriction,EventRateLimit"
- "admission-control-config-file=/etc/rancher/k3s/admission-control-config.yaml"
kubelet-arg:
- "pod-max-pids=4096"
notify: Restart k3s serviceSe desactivan tres componentes. Traefik es el Ingress por defecto de K3s; esta instalación usa NGINX en su lugar. ServiceLB es un pequeño balanceador de carga; el Ingress se enlaza directamente a los puertos del host, así que no hace falta. local-storage crea volúmenes de almacenamiento bajo demanda – aquí nadie los pide, todos los datos están en directorios fijos del host, así que solo funcionaba como un proceso privilegiado más. Lo que no se ejecuta no puede ser atacado.
La línea de los plugins de admisión encierra una de las mejores lecciones del repositorio, documentada en el archivo: en Kubernetes normal, enable-admission-plugins se suma a la lista por defecto. En K3s, la sustituye. Poner solo EventRateLimit eliminó en silencio NodeRestriction – un plugin que impide que un agente de nodo comprometido modifique algo que no sean sus propios objetos. Una nueva pasada del verificador CIS (kube-bench) lo encontró. Desde entonces, NodeRestriction figura explícitamente en la lista, con un comentario que explica por qué. El archivo también explica una recomendación CIS que deliberadamente no sigue (volver a descargar la imagen del registro en cada arranque de pod): en una instalación sin inquilinos separados aporta poca seguridad, pero convierte cada breve caída del registro en pods que fallan.
Las tres tareas notifican al mismo handler, Restart k3s service – un solo reinicio, cambien los archivos que cambien. Y una pequeña tarea justo después crea el directorio del log de auditoría, porque el servidor de API no lo crea por sí mismo y no arranca sin él. Tiene que existir antes de ese reinicio.
install_k3s.yml, parte 2: instalar o actualizar, fijado
Ahora el binario. El rol compara la versión instalada con la fijada:
- name: K3s | Check installed k3s version
ansible.builtin.shell: |
set -o pipefail
k3s --version 2>/dev/null | awk '{print $3}'
register: k3s_installed_version
changed_when: false
check_mode: false
failed_when: false
- name: K3s | Download k3s binary (install or upgrade to {{ k3s_version }})
ansible.builtin.get_url:
url: "https://github.com/k3s-io/k3s/releases/download/{{ k3s_version }}/k3s"
dest: /usr/local/bin/k3s
mode: "0755"
when: not k3s_binary.stat.exists or (k3s_installed_version.stdout | trim) != k3s_version
notify: Restart k3s service
timeout: 600Este es el patrón del binario fijado, y se repite con Helm, el plugin helm-diff y todos los programas de monitorización: leer la versión instalada, descargar solo si difiere, disparar un reinicio. En un servidor nuevo, la versión es una cadena vacía, que nunca coincide con la fijada – así que la misma tarea cubre instalación y actualización. En K3s, actualizar es literalmente «reemplazar el binario, reiniciar el servicio»; los datos del clúster se conservan. set -o pipefail hace que la tubería falle si falla k3s --version, no solo si falla awk. timeout: 600 es una palabra clave de la tarea, no una opción del módulo: si la descarga se cuelga, Ansible se rinde a los diez minutos en lugar de esperar para siempre.
El script de instalación que crea el servicio de systemd viene del repositorio de K3s con la misma etiqueta que el binario, no del habitual get.k3s.io. Esa dirección sirve siempre el script más reciente, y estuvo un rato respondiendo con HTTP 500 durante la primera instalación de un servidor nuevo, mientras GitHub iba bien. Binario fijado más script fijado significa la misma instalación cada vez. La etiqueta contiene un +, que en una URL debe escribirse %2B – de eso se encarga el filtro urlencode.
- name: K3s | Run K3s installer (skip binary download, flags from config.yaml)
ansible.builtin.command: sh /tmp/k3s-install.sh
environment:
INSTALL_K3S_SKIP_DOWNLOAD: "true"
args:
creates: /etc/systemd/system/k3s.service
when: not k3s_unit.stat.exists
- name: K3s | Apply pending handlers (restart on config/binary change)
ansible.builtin.meta: flush_handlersTres detalles en dos tareas. environment fija una variable solo para este comando: el script de instalación no debe volver a descargar el binario. creates ejecuta el comando solo si ese archivo aún no existe – el truco de idempotencia más antiguo de Ansible. Y el script se lanza como sh /tmp/k3s-install.sh en lugar de ejecutarse directamente, porque el endurecimiento CIS monta /tmp con noexec: los archivos de ahí no pueden ejecutarse, pero un shell sí puede leerlos.
La condición when también tiene su historia. Al principio comprobaba si existía el binario. Luego, una primera instalación se interrumpió tras descargar el binario pero antes del script de instalación – y cada ejecución posterior veía el binario, se saltaba el script de instalación y fallaba más abajo porque el servicio no existía. Ahora decide la unidad de systemd, y una instalación interrumpida se cura sola en la siguiente ejecución.
Después, meta: flush_handlers. Normalmente los handlers se ejecutan al final del play – pero cada rol después de este habla con Kubernetes. Si K3s acaba de actualizarse o reconfigurarse, el reinicio tiene que ocurrir ahora, no después de desplegar la aplicación contra la versión antigua. flush_handlers ejecuta en ese punto todos los handlers pendientes. El archivo de handlers en sí es una sola entrada:
- name: Restart k3s service
ansible.builtin.service:
name: k3s
state: restartedDespués, el rol espera a que responda el servidor de API – no con un sleep fijo, sino preguntando:
- name: K3s | Wait for the API server to become available
ansible.builtin.command: k3s kubectl get nodes
register: k3s_nodes
until: k3s_nodes.rc == 0
retries: 30
delay: 5
changed_when: falseuntil repite la tarea hasta que la condición sea verdadera, aquí como máximo 30 veces con 5 segundos de intervalo. Tras un reinicio rápido, el play sigue a los pocos segundos; tras uno lento, espera hasta dos minutos y medio; si K3s nunca arranca, la ejecución falla con un mensaje claro en lugar de con un error confuso en el siguiente rol.
Dos cosas pequeñas cierran el archivo. K3s escribe sus archivos de certificados legibles por todos y regenera algunos al reiniciar. find reúne todos los archivos *.crt, y una tarea file con loop pone cada uno en 0600 – en cada ejecución, porque los archivos vuelven. (loop_control.label muestra solo la ruta por elemento en lugar de toda la entrada del archivo.) Por último, la configuración de administración del clúster se copia a /root/.kube/config, para que kubectl y helm encuentren el clúster en un shell de root. remote_src: true significa: copiar de un sitio del servidor a otro, no desde mi máquina.
install_helm.yml: Helm y el plugin helm-diff
Helm instala charts – paquetes de objetos de Kubernetes con ajustes («values») – y lleva la cuenta de cada instalación como release. El rol lo instala con el mismo patrón de fijación: comprobación de versión, descarga del archivo comprimido solo si difiere, descompresión, copia a /usr/local/bin. Dos líneas de las tareas de descompresión e instalación merecen una mirada:
when:
- helm_download.changed | default(false)
- not ansible_check_modeUna lista bajo when significa «todas ellas». La primera condición: solo si se descargó algo. La segunda tiene que ver con las simulaciones. En modo check, la tarea de descarga solo finge – informa de «cambiaría», pero no hay archivo. Descomprimir un archivo que no existe fallaría, así que el rol se salta ese paso en modo check. Así, una simulación en un servidor con un Helm desactualizado informa correctamente de la actualización en lugar de estrellarse. Las mismas dos líneas protegen cada archivo descargado del repositorio.
La segunda mitad instala helm-diff, un plugin con el que el módulo de Helm puede calcular una diferencia real entre la release en marcha y la solicitada. La versión instalada se lee de helm plugin list, que muestra una pequeña tabla, más o menos así:
NAME VERSION TYPE APIVERSION PROVENANCE SOURCE
diff 3.x.x cli/v1 legacy ...…y una sola expresión de Jinja extrae la versión:
when: >-
(helm_plugins.stdout_lines | default([]) | map('split')
| selectattr('0', 'equalto', 'diff') | map(attribute='1') | first | default('')) != helm_diff_versionLéela como una tubería de izquierda a derecha: tomar las líneas de salida → partir cada línea en palabras (ahora cada línea es una lista) → quedarse solo con las líneas cuya primera palabra (índice 0) es diff → tomar la segunda palabra (índice 1) de cada una → quedarse con la primera, o con una cadena vacía si no hay ninguna. Si no es la versión fijada, se ejecuta el block de abajo. Un block agrupa tareas, de modo que un when se aplica a todas.
Dentro del bloque, la descarga lleva una suma de comprobación: checksum: "sha256:{{ helm_diff_sha256 }}". get_url verifica el archivo tras descargarlo y falla si no coincide. El plugin está fijado junto con su suma de comprobación en versions.yml, y el bot que propone actualizaciones de versiones está configurado para recordarme que ambas cosas deben cambiar juntas.
cert_manager.yml: certificados sin certbot
cert-manager vigila el clúster en busca de objetos que digan «este nombre de host necesita un certificado», obtiene uno de Let’s Encrypt, lo guarda como Secret de Kubernetes y lo renueva antes de que caduque. Let’s Encrypt comprueba con un desafío HTTP-01 que controlas un dominio: le da un token a cert-manager y luego consulta http://your-domain/.well-known/acme-challenge/<token>. Si vuelve el token, el dominio es tuyo.
- name: cert-manager | Install/upgrade cert-manager via Helm
kubernetes.core.helm:
kubeconfig: "{{ k3s_kubeconfig }}"
name: cert-manager
chart_ref: jetstack/cert-manager
chart_version: "{{ cert_manager_chart_version }}"
release_namespace: cert-manager
create_namespace: true
update_repo_cache: true
values:
crds:
enabled: true
wait: true
timeout: 300sAntes era una tarea de shell con helm upgrade --install. Funcionaba, pero informaba de «changed» en cada ejecución y creaba cada vez una nueva revisión de Helm – así que el playbook nunca podía decir «nada que hacer». El módulo kubernetes.core.helm compara la versión del chart y los values solicitados con la release en marcha y solo actualiza si hay una diferencia real. wait: true espera a que los pods de la release estén listos.
Luego viene un paso que confunde a mucha gente la primera vez. cert-manager trae nuevos tipos de objetos – por ejemplo ClusterIssuer, «dónde y cómo obtener certificados». Kubernetes solo conoce un tipo así cuando su CustomResourceDefinition (CRD) está registrada. Aplicar un ClusterIssuer un instante antes de tiempo falla con «no matches for kind». Así que el rol espera a que el webhook esté disponible y la CRD esté established, y entonces aplica el emisor con el patrón de aplicación K8s (explicado a fondo en la sección de Calico más abajo).
La plantilla del emisor contiene el ajuste más trabajado de este archivo. La petición del desafío tiene que llegar al solucionador temporal de cert-manager, pero ya existe un Ingress para ese mismo nombre de host – el de la aplicación. El controlador de F5 ignora en silencio un segundo Ingress para el mismo host. La solución está en dos anotaciones: el Ingress del solucionador se crea como minion, que el controlador incorpora al Ingress master existente de ese host, y se excluye de la redirección de HTTP a HTTPS, porque Let’s Encrypt comprueba por HTTP simple.
nginx_ingress.yml: la puerta de entrada
El controlador Ingress es lo único que responde en los puertos 80 y 443. Se ejecuta como DaemonSet (un pod por nodo – aquí: exactamente uno) con hostNetwork, así que se enlaza directamente a los puertos del host como un servidor web normal. El archivo tiene cuatro pasos.
Un certificado de respaldo. Cuando el controlador recibe una petición para un nombre de host del que no tiene certificado – DNS erróneo, un certificado aún no emitido –, el controlador de F5 no presenta uno autofirmado, sino que simplemente cierra la conexión. En el navegador, eso es indistinguible de un servidor caído. Se notó con un error de DNS, cuando «DNS erróneo» y «servidor caído» parecían exactamente lo mismo. Por eso el rol genera una vez un certificado autofirmado (openssl con creates:) y lo registra como predeterminado. Los certificados reales no se ven afectados: nginx usa el que coincide con el nombre de host.
- name: ingress-nginx | Read default TLS certificate and key
ansible.builtin.slurp:
src: "/etc/ingress-nginx-defaults/{{ item }}"
loop: [tls.crt, tls.key]
register: ingress_default_tls
no_log: true
- name: ingress-nginx | Apply default TLS certificate as a K8s Secret
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: Secret
type: kubernetes.io/tls
metadata: { name: ingress-default-tls, namespace: ingress-nginx }
data:
tls.crt: "{{ ingress_default_tls.results[0].content }}"
tls.key: "{{ ingress_default_tls.results[1].content }}"
no_log: trueslurp lee un archivo del servidor y lo devuelve codificado en Base64 – que resulta ser justo el formato que Kubernetes espera en el data de un Secret. Como la tarea tiene un loop, la variable registrada tiene una lista results, una entrada por elemento, en el orden del bucle: results[0] es el certificado, results[1] la clave. Ambas tareas ocultan su salida con no_log: uno de esos valores es una clave privada.
Una migración única. Antes funcionaba el proyecto comunitario kubernetes/ingress-nginx, que se ha abandonado. Una tarea de shell comprueba si ese chart sigue instalado y lo elimina antes de instalar el chart de F5, para que Helm nunca intente actualizar entre dos charts incompatibles. Imprime «uninstalled» o «skipped», y changed_when busca la palabra – una forma sencilla de dar a un script un estado honesto.
La release en sí. Los values de Helm vienen de una plantilla, generada en memoria y leída como YAML: values: "{{ lookup('ansible.builtin.template', 'ingress-nginx-values.yml.j2') | from_yaml }}". Ningún archivo temporal en el servidor. En el archivo de values se configura el borde: solo TLS 1.2 y 1.3, una lista fija de suites de cifrado, HTTP/2, reutilización de conexiones hacia los pods de la aplicación, gzip, ningún número de versión en la cabecera Server, y las zonas de limitación de tasa que usan las páginas de inicio de sesión del blog y de Nextcloud (partes 3 y 4). También documenta lo que falta a propósito: la compresión Brotli (la imagen del controlador no incluye el módulo, los ajustes no tenían efecto) y el OCSP stapling (Let’s Encrypt ya no opera OCSP). El artículo sobre TLS tiene el cuadro completo.
Esperar. Por último, kubectl wait se bloquea hasta que el pod del controlador está listo, para que los roles de aplicación no desplieguen en un clúster sin puerta de entrada.
default_sa_hardening.yml: sin tokens por defecto
Cada pod en Kubernetes se ejecuta bajo una ServiceAccount y, por defecto, recibe montado un token con el que puede hablar con el servidor de API. Una aplicación web no lo necesita – y una comprometida no debería tenerlo. CIS recomienda desactivarlo para la cuenta default de cada namespace:
- name: K3s | Find non-kube-system default ServiceAccounts (CIS 5.1.5)
kubernetes.core.k8s_info:
kind: ServiceAccount
field_selectors:
- metadata.name=default
register: k3s_default_sas
- name: K3s | Disable SA token automount on every non-kube-system default ServiceAccount
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: "{{ item }}"
automountServiceAccountToken: false
loop: >-
{{ k3s_default_sas.resources | map(attribute='metadata.namespace')
| reject('equalto', 'kube-system') | list }}k8s_info es el equivalente de solo lectura de k8s – como kubectl get. field_selectors filtra en el servidor: solo las cuentas llamadas default. El bucle construye su lista con una cadena de filtros: de todas las cuentas encontradas, tomar el namespace, dejar fuera kube-system (los componentes propios del clúster necesitan allí sus tokens) y convertirlo en una lista. La tarea k8s aplica entonces una definición parcial – solo el campo que debe cambiar; todo lo demás de la cuenta queda como está. Los namespaces de las aplicaciones reciben el mismo parche en sus propios roles, porque en este punto de una instalación nueva todavía no existen.
| Pieza | Dónde | Qué llevarse |
|---|---|---|
import_tasks | main.yml | Dividir un rol grande en archivos de forma estática |
| Patrón del binario fijado | K3s, Helm, helm-diff | Comprobación de versión → descarga solo si difiere → reinicio |
notify + meta: flush_handlers | install_k3s | Un reinicio para muchos cambios, y ahora en lugar de al final |
until / retries / delay | install_k3s | Esperar a una condición, no un tiempo fijo |
creates, environment | install_k3s, Ingress | Ejecutar una vez; fijar una variable para un comando |
find + loop + loop_control.label | install_k3s | Actuar sobre una lista de archivos que solo se encuentra durante la ejecución |
not ansible_check_mode | install_helm | Saltarse pasos que dependen de algo que una simulación no hizo |
map / selectattr / first | install_helm | Analizar la salida de comandos con filtros de Jinja |
get_url con checksum | install_helm | Verificar lo que se descarga |
kubernetes.core.helm | cert-manager, Ingress | Releases de Helm idempotentes |
slurp + results[n] + no_log | Ingress | Leer un archivo en un Secret sin mostrarlo |
k8s_info, definiciones parciales, reject | default_sa_hardening | Leer objetos, cambiar un campo |
El rol declara una entrada en meta/argument_specs.yml: email, la dirección a la que Let’s Encrypt envía los avisos de caducidad – marcada con no_log como cualquier valor personal.
🛡️ common_calico: para que las NetworkPolicies sean reales
Una NetworkPolicy es un objeto de Kubernetes que dice qué pods pueden hablar con cuáles – por ejemplo: «el pod de la base de datos solo acepta conexiones del pod de WordPress». El blog y Nextcloud se apoyan ambos en reglas así. Hay una trampa fácil de pasar por alto: Kubernetes no las aplica por sí mismo. Lo hace el plugin de red – y Flannel, el plugin integrado en K3s, no lo hace. Una regla que lo prohíbe todo se acepta sin rechistar y no tiene ningún efecto. Se comprobó en el servidor de pruebas: una regla de prohibirlo-todo no cambió nada hasta instalar Calico, y luego surtió efecto al instante.
Calico también puede encargarse él mismo de la red, pero aquí funciona en modo policy-only: Flannel sigue transportando los paquetes, y el agente de Calico (Felix) solo lee las reglas de la API de Kubernetes y las convierte en reglas de filtrado de paquetes en el host. El manifiesto se deriva del manifiesto oficial «Canal» de Calico (Calico para las reglas, Flannel para la red), sin todo lo que K3s ya trae: la instalación del plugin de red, el contenedor de Flannel, la configuración de red. Quedan 34 objetos – 23 CRD, los permisos, un DaemonSet para el agente y un Deployment para el controlador.
El patrón de aplicación K8s, completo
Todo el rol es una tarea más dos esperas, y la tarea es el patrón con el que 26 tareas del repositorio llevan objetos de Kubernetes al clúster. Aquí está, para leerlo de dentro hacia fuera:
- name: Calico | Apply policy-only manifest
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ k8s_objects if ansible_diff_mode else [k8s_objects] }}"
loop_control:
label: "{{ (item.kind ~ '/' ~ item.metadata.name) if item is mapping
else ((item | length) ~ ' objects') }}"
vars:
k8s_objects: "{{ lookup('ansible.builtin.template', 'calico-policy-only.yml.j2')
| ansible.builtin.from_yaml_all | select | list }}"
register: calico_applylookup('ansible.builtin.template', ...)genera la plantilla Jinja en mi máquina como una cadena larga. No se copia nada al servidor. (Los lookups se ejecutan siempre en el controlador.)from_yaml_alldivide esa cadena – muchos documentos YAML separados por---– en una lista de objetos.selectsin argumento elimina las entradas vacías, por ejemplo de un---final.vars:en una tarea definek8s_objectssolo para esa tarea.kubernetes.core.k8sconstate: presentyapply: truese comporta comokubectl apply: crear lo que falta, actualizar lo que difiere, dejar el resto – e informar de «changed» solo si un objeto en marcha cambió de verdad. Habla directamente con la API mediante el cliente de Python que instalócommon_k3s; sinkubectl, sin archivo temporal.- El bucle es la parte ingeniosa. En una ejecución normal, la lista
[k8s_objects]tiene un elemento – toda la lista de 34 objetos –, así que el módulo se ejecuta una vez. Con--diff(ansible_diff_modees verdadero), el bucle recorre los propios objetos, una llamada al módulo por objeto, porque solo así el diff muestra el cambio por objeto. Rápido cuando da igual, detallado cuando quiero verlo. loop_control.labelmantiene legible la salida: «DaemonSet/calico-node» por objeto, o «34 objects».
Las dos tareas siguientes esperan con kubectl rollout status al agente y al controlador – pero solo when: calico_apply.changed. Una ejecución sin cambios no espera nada.
Una nota de diseño más del comentario de cabecera del rol, porque es una buena lección general: los permisos del agente de Calico se toman íntegros de upstream, no recortados a lo que el modo policy-only «debería» necesitar. Varios bucles internos del agente se ejecutan sea cual sea el modo, y sin sus permisos no se desactivan con elegancia – el pod simplemente nunca llega a estar listo. Recortar permisos es una buena práctica, pero no por debajo de lo que el software necesita de verdad.
🛠️ En operación: la plataforma en el día a día
Casi todo lo de esta parte funciona solo – actualizaciones nocturnas, renovación de certificados, rotación de logs. Lo que le queda al administrador es saber dónde mirar y cómo hacer un cambio planificado.
Comprobar
# The nightly plan: when do the timers fire next?
systemctl list-timers dnf-automatic-install.timer logrotate.timer
# Is a reboot pending? (exit code 1 = yes)
needs-restarting -r
# Does the server send mail? (the address from the inventory)
echo "test from $(hostname -f)" | mail -s "mail test" you@example.org
tail /var/log/msmtp.log
# Kubernetes: the node, all pods, and the Helm releases
k3s kubectl get nodes
k3s kubectl get pods -A
helm list -A
# All certificates: valid, and when they are renewed
k3s kubectl get certificate -A \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,RENEWAL:.status.renewalTimeCorreos de esta parte
| El asunto contiene | Significado | Qué hacer |
|---|---|---|
| «Rebooting now after security update» | Por la noche se actualizó un kernel o una biblioteca central; el servidor reinició a las 03:30 | Nada – comprobar más tarde que todo ha vuelto (el perro guardián de la parte 6 avisa si no) |
| «WARNING: reboot still required» | El reinicio automático no se produjo | Averiguar por qué (journalctl -b -1), reiniciar a mano |
Cambios planificados
- Los paquetes del host que no son correcciones de seguridad (un Grafana nuevo, por ejemplo) solo llegan con el rol de paquetes:
ansible-playbook blog.yml --limit <host> --tags blog_packages(onext_packagescon el playbook de Nextcloud). - K3s, Helm, los charts: cambiar la versión fijada en
versions.yml(o fusionar la pull request del bot), ejecutarscripts/check-drift.sh <playbook> <host> --tags common_k3spara ver qué pasaría, y luego la ejecución real con--tags common_k3s. K3s se actualiza siempre de una versión menor en una – lee las notas de la versión y toma antes un snapshot del servidor. - Calico: lo mismo con
--tags common_calico. Después,k3s kubectl -n kube-system get ds calico-nodedebe volver a mostrar el pod como listo. - Parámetros del kernel: cambiar la plantilla, ejecutar
--tags common_sysctl. Nunca «arregles» el reenvío IP ni el filtrado de ruta inversa según el manual – el clúster los necesita tal como están.
🧭 Lo que viene
En este punto la plataforma está completa: un sistema base parcheado con ajustes de kernel razonables, correo, actualizaciones nocturnas y logs rotados, y un clúster Kubernetes con Helm, certificados, una puerta de entrada y reglas de red efectivas. La parte 3 pone el blog encima – WordPress, MariaDB y Redis como pods, y todo lo que blog_wordpress hace en la aplicación en marcha. La parte 4 hace lo mismo con Nextcloud. Los roles que se han saltado aquí – SSH, el cortafuegos, el endurecimiento y SELinux – están todos en la parte 5, la parte de seguridad.



