Automatización de aprovisionamiento con Ansible y roles personalizados
Introducción
En el ecosistema del hosting moderno y la administración de servidores, la repetición manual de tareas de aprovisionamiento es el mayor enemigo de la consistencia, la seguridad y la escalabilidad. Cada servidor configurado "a mano" introduce una deriva de configuración (configuration drift) que, a la larga, se traduce en fallos difíciles de depurar y vulnerabilidades de seguridad.
Ansible se ha consolidado como la herramienta de automatización de facto para SysAdmins y DevOps por su simplicidad (sin agentes, basado en YAML y SSH) y su potencia. Sin embargo, el verdadero salto de calidad no está en ejecutar playbooks planos, sino en la creación y gestión de roles personalizados. Un rol es la unidad de reutilización y organización. Este artículo desglosa el flujo de trabajo profesional para aprovisionar un servidor de hosting (LAMP/LEMP) utilizando Ansible y roles personalizados, explicando el "por qué" de cada decisión.
Arquitectura de Automatización con Roles
Antes de escribir una línea de código, debemos entender la estructura. Un rol Ansible no es solo una carpeta; es una convención que separa responsabilidades: variables, tareas, handlers, templates y defaults.
Nota clave: La deriva de configuración se mitiga no solo automatizando, sino haciendo que la automatización sea idempotente. Un rol bien diseñado debe poder ejecutarse 100 veces sobre el mismo servidor sin causar cambios no deseados después de la primera ejecución.
Estructura de Directorios Recomendada
ansible-hosting/
├── ansible.cfg
├── inventory/
│ ├── production/
│ │ ├── hosts.ini
│ │ └── group_vars/
│ │ └── webservers.yml
│ └── staging/
│ └── hosts.ini
├── playbooks/
│ ├── site.yml
│ └── setup-webserver.yml
└── roles/
├── common/
│ ├── tasks/
│ ├── handlers/
│ └── defaults/
├── nginx/
│ ├── tasks/
│ ├── templates/
│ ├── vars/
│ └── handlers/
├── php-fpm/
├── mariadb/
└── deploy-app/
El uso de ansible.cfg nos permite definir comportamientos globales:
# ansible.cfg
[defaults]
inventory = ./inventory/production/hosts.ini
host_key_checking = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_cache
roles_path = ./roles
¿Por qué gathering = smart? Porque solo recopila hechos (facts) si no están cacheados, acelerando ejecuciones subsecuentes. El fact caching es crucial en infraestructuras con decenas de servidores.
Fase 1: Aprovisionamiento Base (Rol common)
El primer rol que cualquier servidor debe ejecutar es el de hardening y configuración base. No tiene sentido instalar Nginx en un sistema sin firewall ni repositorios actualizados.
roles/common/tasks/main.yml
---
- name: Actualizar cache de apt (Debian/Ubuntu)
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Instalar paquetes esenciales
apt:
name: "{{ common_packages }}"
state: present
vars:
common_packages:
- curl
- wget
- git
- ufw
- fail2ban
- unattended-upgrades
- name: Configurar UFW - Políticas por defecto
ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: 'incoming', policy: 'deny' }
- { direction: 'outgoing', policy: 'allow' }
- name: Permitir SSH y HTTP/HTTPS
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "22"
- "80"
- "443"
- name: Habilitar UFW
ufw:
state: enabled
¿Por qué cache_valid_time? Evita forzar una actualización de repositorios en cada ejecución. En producción, actualizar cada 3600 segundos (1 hora) es un balance óptimo entre seguridad y rendimiento.
Fase 2: Instalación y Configuración de Nginx (Rol nginx)
Aquí es donde la personalización y el uso de templates marcan la diferencia. No copiamos archivos de configuración estáticos; los generamos dinámicamente.
Variables del Rol (roles/nginx/vars/main.yml)
---
nginx_user: www-data
nginx_worker_processes: "{{ ansible_processor_cores }}"
nginx_worker_connections: 1024
nginx_sendfile: "on"
nginx_tcp_nopush: "on"
nginx_keepalive_timeout: 65
nginx_client_max_body_size: 64M
Nota importante: El uso de
ansible_processor_coreses un fact dinámico. Si migras el servidor a un hardware con más núcleos, Ansible ajustará automáticamente el número de workers de Nginx en el próximo playbook run.
Plantilla (roles/nginx/templates/nginx.conf.j2)
{% raw %}
user {{ nginx_user }};
worker_processes {{ nginx_worker_processes }};
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections {{ nginx_worker_connections }};
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile {{ nginx_sendfile }};
tcp_nopush {{ nginx_tcp_nopush }};
keepalive_timeout {{ nginx_keepalive_timeout }};
client_max_body_size {{ nginx_client_max_body_size }};
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
{% endraw %}
¿Por qué gzip_types explícito? Porque por defecto Nginx solo comprime texto plano. Para un servidor de hosting que sirve APIs JSON o CSS/JS, es obligatorio definir los tipos MIME.
Tasks del Rol (roles/nginx/tasks/main.yml)
---
- name: Añadir repositorio oficial de Nginx
apt_repository:
repo: "deb http://nginx.org/packages/ubuntu {{ ansible_distribution_release }} nginx"
state: present
update_cache: yes
when: ansible_distribution == "Ubuntu"
- name: Instalar Nginx
apt:
name: nginx
state: latest
- name: Renderizar configuración principal
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: recargar nginx
- name: Eliminar sitio por defecto
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: recargar nginx
- name: Asegurar que Nginx está iniciado y habilitado
systemd:
name: nginx
state: started
enabled: yes
¿Por qué state: latest para Nginx? En un servidor de hosting expuesto a internet, las vulnerabilidades en el servidor web son críticas. Usar latest asegura que siempre tengamos el último parche de seguridad, asumiendo que el repositorio oficial está configurado.
Handler (roles/nginx/handlers/main.yml)
---
- name: recargar nginx
systemd:
name: nginx
state: reloaded
El handler solo se ejecuta si la tarea lo notifica (cuando el archivo de configuración cambia). Esto evita recargas innecesarias y downtime.
Fase 3: PHP-FPM con Roles Personalizados y Pooles
Un servidor de hosting moderno rara vez ejecuta un solo sitio. La gestión de múltiples versiones de PHP y pooles aislados es un caso de uso perfecto para roles parametrizados.
Estructura del Rol php-fpm
roles/php-fpm/
├── defaults/
│ └── main.yml
├── tasks/
│ └── main.yml
├── templates/
│ └── pool.conf.j2
└── vars/
└── main.yml
roles/php-fpm/defaults/main.yml
---
php_version: "8.1"
php_packages:
- "php{{ php_version }}-fpm"
- "php{{ php_version }}-cli"
- "php{{ php_version }}-mysql"
- "php{{ php_version }}-curl"
- "php{{ php_version }}-xml"
- "php{{ php_version }}-mbstring"
php_memory_limit: "256M"
php_max_execution_time: 300
php_upload_max_filesize: "64M"
php_post_max_size: "68M"
php_pools: []
¿Por qué php_pools: []? Este rol está diseñado para ser invocado múltiples veces con diferentes variables. La lista de pooles se pasará desde el playbook o desde variables de grupo.
Template del Pool (templates/pool.conf.j2)
{% raw %}
[{{ pool_name }}]
user = {{ pool_user }}
group = {{ pool_group }}
listen = /var/run/php/php{{ php_version }}-fpm-{{ pool_name }}.sock
listen.owner = {{ nginx_user }}
listen.group = {{ nginx_user }}
listen.mode = 0660
pm = dynamic
pm.max_children = {{ pool_max_children | default(5) }}
pm.start_servers = {{ pool_start_servers | default(2) }}
pm.min_spare_servers = {{ pool_min_spare | default(1) }}
pm.max_spare_servers = {{ pool_max_spare | default(3) }}
pm.max_requests = 500
security.limit_extensions = .php .phar
env[APP_ENV] = {{ pool_app_env | default('production') }}
{% endraw %}
¿Por qué listen.mode = 0660? El socket de PHP debe ser legible y escribible por Nginx (www-data) pero no por otros usuarios del sistema. Esto es crítico en entornos multi-tenant para evitar que un sitio comprometido acceda al socket de otro.
Tasks del Rol (tasks/main.yml)
---
- name: Añadir repositorio de Ondrej PHP (Ubuntu)
apt_repository:
repo: "ppa:ondrej/php"
state: present
update_cache: yes
when: ansible_distribution == "Ubuntu"
- name: Instalar PHP {{ php_version }} y paquetes
apt:
name: "{{ php_packages }}"
state: present
- name: Configurar php.ini para producción
lineinfile:
path: "/etc/php/{{ php_version }}/fpm/php.ini"
regexp: "^{{ item.key }}"
line: "{{ item.key }} = {{ item.value }}"
loop:
- { key: 'memory_limit', value: "{{ php_memory_limit }}" }
- { key: 'max_execution_time', value: "{{ php_max_execution_time }}" }
- { key: 'upload_max_filesize', value: "{{ php_upload_max_filesize }}" }
- { key: 'post_max_size', value: "{{ php_post_max_size }}" }
notify: reiniciar php-fpm
- name: Renderizar pooles de PHP-FPM
template:
src: pool.conf.j2
dest: "/etc/php/{{ php_version }}/fpm/pool.d/{{ item.pool_name }}.conf"
owner: root
group: root
mode: '0644'
loop: "{{ php_pools }}"
notify: reiniciar php-fpm
Fase 4: Playbook de Orquestación y Variables por Entorno
Ahora unimos todo. El playbook principal (playbooks/site.yml) orquestará los roles en el orden correcto.
---
- hosts: all
gather_facts: yes
become: yes
roles:
- role: common
tags: [common, base]
- hosts: webservers
become: yes
roles:
- role: nginx
tags: [nginx, web]
- role: php-fpm
tags: [php, web]
vars:
php_version: "8.2"
php_pools:
- pool_name: "cliente_a"
pool_user: "clientea"
pool_group: "clientea"
pool_max_children: 10
pool_app_env: "staging"
- pool_name: "cliente_b"
pool_user: "clienteb"
pool_group: "clienteb"
pool_max_children: 20
Variables de Grupo (inventory/production/group_vars/webservers.yml)
---
nginx_worker_connections: 2048
nginx_client_max_body_size: 128M
php_memory_limit: "512M"
php_pools:
- pool_name: "cliente_c"
pool_user: "clientec"
pool_group: "clientec"
pool_max_children: 50
Nota de diseño: Las variables definidas en el playbook tienen prioridad sobre las de grupo, y estas sobre las de rol. Esto permite un control granular: valores por defecto en el rol, valores por entorno en
group_vars, y excepciones en el playbook.
Fase 5: Despliegue de Aplicación con Roles Compuestos
El rol deploy-app es un ejemplo de un rol que consume otros roles y ejecuta lógica de negocio. Aquí desplegamos una aplicación PHP simple.
roles/deploy-app/tasks/main.yml
---
- name: Crear usuario del sistema para el sitio
user:
name: "{{ app_user }}"
shell: /bin/bash
home: "{{ app_root }}"
create_home: yes
system: yes
- name: Crear estructura de directorios
file:
path: "{{ item }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0755'
loop:
- "{{ app_root }}"
- "{{ app_root }}/public"
- "{{ app_root }}/logs"
- name: Configurar virtualhost de Nginx
template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ app_domain }}.conf"
owner: root
group: root
mode: '0644'
notify: recargar nginx
- name: Habilitar sitio
file:
src: "/etc/nginx/sites-available/{{ app_domain }}.conf"
dest: "/etc/nginx/sites-enabled/{{ app_domain }}.conf"
state: link
notify: recargar nginx
- name: Clonar repositorio de la aplicación
git:
repo: "{{ app_repo }}"
dest: "{{ app_root }}"
version: "{{ app_version | default('main') }}"
accept_hostkey: yes
become_user: "{{ app_user }}"
- name: Configurar permisos de archivos
file:
path: "{{ app_root }}"
state: directory
recurse: yes
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0755'
- name: Asegurar permisos de escritura para cache
file:
path: "{{ app_root }}/var/cache"
state: directory
mode: '0777'
when: app_has_cache | default(false)
Template de VirtualHost (templates/vhost.conf.j2)
{% raw %}
server {
listen 80;
server_name {{ app_domain }} www.{{ app_domain }};
root {{ app_root }}/public;
index index.php index.html;
access_log {{ app_root }}/logs/access.log;
error_log {{ app_root }}/logs/error.log
