No hace mucho tiempo, tuve que escribir varios playbooks de Ansible para preparar un servidor para el despliegue de una aplicación Rails. Y, para mi sorpresa, no encontré un manual simple y paso a paso. No quería copiar un playbook ajeno sin entender lo que estaba sucediendo, así que terminé leyendo la documentación y recopilando todo por mí mismo. Quizás pueda ayudar a alguien a acelerar este proceso con este artículo.
Lo primero que hay que entender es que Ansible te proporciona una interfaz conveniente para ejecutar una lista de acciones predefinidas en un servidor (o varios servidores) a través de SSH. No hay ninguna magia aquí; no puedes instalar un plugin y obtener un despliegue de cero tiempo de inactividad de tu aplicación con Docker, monitoreo y otros extras listos para usar. Para escribir un playbook, debes saber exactamente qué quieres hacer y cómo hacerlo. Por eso, no me satisfacen los playbooks listos de GitHub, o artículos que digan: 'Copia y ejecuta, funcionará'.
¿Qué necesitamos?
Como ya mencioné, para escribir un playbook hay que saber qué quieres hacer y cómo hacerlo. Vamos a definir lo que necesitamos. Para una aplicación Rails, necesitaremos varios paquetes del sistema: nginx, postgresql (redis, etc.). Además, necesitaremos Ruby de una versión específica. Lo mejor es instalarlo a través de rbenv (rvm, asdf…). Ejecutar todo esto como usuario root siempre es una mala idea, por lo que es necesario crear un usuario adicional y configurar sus permisos. Después de eso, debemos subir nuestro código al servidor, copiar las configuraciones para nginx, postgres, etc., y activar todos estos servicios.
En resumen, la secuencia de acciones es la siguiente:
- Iniciamos sesión como root
- instalamos los paquetes del sistema
- creamos un nuevo usuario, configuramos permisos y la clave ssh
- configuramos los paquetes del sistema (nginx, etc.) y los activamos
- Creamos un usuario en la base de datos (puedes crear la base de datos de una vez)
- Iniciamos sesión como el nuevo usuario
- Instalamos rbenv y Ruby
- Instalamos Bundler
- Subimos el código de la aplicación
- Iniciamos el servidor Puma
Además, los últimos pasos se pueden realizar utilizando Capistrano, al menos puede copiar el código en los directorios de release, cambiar el enlace simbólico de release tras un despliegue exitoso, copiar las configuraciones desde el directorio compartido, reiniciar Puma, etc. Todo esto también se puede hacer con Ansible, pero ¿para qué?
Estructura de archivos
Ansible tiene una estructura de archivos estricta Simple Playbook
Un playbook es un archivo yml en el que, mediante una sintaxis especial, se describe qué y cómo debe hacer ansible. Vamos a crear nuestro primer playbook, que no hará nada:
--- - name: Simple playbook hosts: all
Aquí simplemente indicamos que nuestro playbook se llamay que el contenido debe ejecutarse en todos los hosts. Podemos guardarlo en el directorio /ansible con el nombre Un playbook es un archivo yml en el que, mediante una sintaxis especial, se describe qué y cómo debe hacer ansible. Vamos a crear nuestro primer playbook, que no hará nada: playbook.yml y probar a ejecutarlo: ansible-playbook ./playbook.ymlPLAY [Simple Playbook] ************************************************************************************************************************************ skipping: no hosts matched
Ansible dice que no conoce hosts que correspondan a la lista all. Deben enumerarse en un archivo especialde inventario. .
Así es como simplemente especificamos un host (idealmente el host de su VPS para las pruebas, o también puede usar localhost) y lo guardamos con el nombre
123.123.123.123inventory Podemos intentar ejecutar ansible con el archivo de inventario:.
ansible-playbook ./playbook.yml -i inventory PLAY [Simple Playbook] ************************************************************************************************************************************TASK [Gathering Facts] ************************************************************************************************************************************PLAY RECAP ************************************************************************************************************************************
Si tiene acceso por ssh al host especificado, ansible se conectará y recopilará información sobre el sistema remoto. (tarea por defecto [Gathering Facts]) después dará un breve informe sobre la ejecución (PLAY RECAP).Por defecto, se utiliza el nombre de usuario con el que está conectado al sistema para la conexión. En el host, es probable que no exista. En el archivo playbook, se puede indicar qué usuario usar para la conexión mediante la directiva remote_user. Además, a menudo puede que no necesite información sobre el sistema remoto y no vale la pena perder tiempo recopilándola. Esta tarea también puede desactivarse:
--- - name: Simple playbook hosts: all remote_user: root become: true gather_facts: no
---
- name: Guion simple
hosts: all
remote_user: root
become: true
gather_facts: noIntenta ejecutar el playbook de nuevo y asegúrate de que la conexión funcione. (Si has especificado el usuario root, también debes incluir la directiva become: true, para obtener privilegios elevados. Como se indica en la documentación: become establecido en 'true'/'yes' para activar la escalada de privilegios. aunque no está del todo claro por qué).
Es posible que obtengas un error causado por el hecho de que ansible no puede determinar el intérprete de Python, entonces puedes especificarlo manualmente:
ansible_python_interpreter: /usr/bin/python3 dónde tienes Python puedes averiguarlo con el comando whereis python.
Instalación de paquetes del sistema
La instalación estándar de Ansible incluye muchos módulos para trabajar con diversos paquetes del sistema, por lo que no tenemos que escribir scripts bash para cada necesidad. Ahora necesitaremos uno de esos módulos para actualizar el sistema e instalar paquetes. Tengo Ubuntu Linux en mi VPS, así que para instalar paquetes utilizo apt-get y . Si estás utilizando otro sistema operativo, es posible que necesites otro módulo (recuerda que al principio mencioné que debes saber de antemano qué haremos y cómo). Sin embargo, la sintaxis probablemente será similar.
Ampliemos nuestro playbook con las primeras tareas:
---
- name: Simple playbook
hosts: all
remote_user: root
become: true
gather_facts: no
tasks:
- name: Update system
apt: update_cache=yes
- name: Install system dependencies
apt:
name: git,nginx,redis,postgresql,postgresql-contrib
state: presentUna tarea — es precisamente la tarea que ansible va a ejecutar en los servidores remotos. Damos a la tarea un nombre para poder rastrear su ejecución en el registro. Y describimos, utilizando la sintaxis del módulo específico, lo que necesita hacer. En este caso, apt: update_cache=yes — indica que se deben actualizar los paquetes del sistema utilizando el módulo apt. El segundo comando es un poco más complejo. Pasamos al módulo apt una lista de paquetes y le decimos que su state debe convertirse en present, es decir, le decimos que instale estos paquetes. De manera similar, podemos indicar que los elimine o los actualice, simplemente cambiando state. Ten en cuenta que para que Rails funcione con PostgreSQL necesitamos el paquete postgresql-contrib, que estamos instalando ahora. Esto, de nuevo, es algo que hay que saber y hacer; ansible por sí mismo no lo hará.
Intenta ejecutar el playbook de nuevo y verifica que los paquetes se instalen.
Creación de nuevos usuarios.
Para trabajar con usuarios, Ansible también tiene un módulo: user. Agreguemos otra tarea (he ocultado las partes conocidas del playbook con comentarios para no tener que copiarlo por completo cada vez):
---
- name: Playbook simple
# ...
tasks:
# ...
- name: Agregar un nuevo usuario
user:
name: my_user
shell: /bin/bash
password: "{{ 123qweasd | password_hash('sha512') }}"Estamos creando un nuevo usuario, estableciendo su shell y contraseña. Y nos encontramos con varios problemas. ¿Qué pasa si los nombres de usuario deben ser diferentes para diferentes hosts? Además, almacenar la contraseña en texto plano en el playbook es una muy mala idea. Para empezar, vamos a sacar el nombre del usuario y la contraseña a variables, y más adelante en el artículo mostraré cómo encriptar la contraseña.
---
- name: Playbook simple
# ...
tasks:
# ...
- name: Agregar un nuevo usuario
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"Mediante dobles llaves en los playbooks se establecen variables.
Los valores de las variables los especificaremos en el archivo de inventario:
123.123.123.123
[all:vars]
user=my_user
user_password=123qweasdPresten atención a la directiva [all:vars] — indica que el siguiente bloque de texto son variables (vars) que se aplican a todos los hosts (all).
También es interesante la construcción "{{ user_password | password_hash('sha512') }}". La cuestión es que ansible no crea el usuario a través de user_add como lo harían manualmente. En cambio, almacena todos los datos directamente, por lo que también debemos transformar la contraseña en un hash de antemano, lo cual hace este comando.
Vamos a agregar a nuestro usuario al grupo sudo. Sin embargo, primero debemos asegurarnos de que dicho grupo existe, porque nadie lo hará por nosotros:
---
- name: Playbook simple
# ...
tasks:
# ...
- name: Asegurar un grupo 'sudo'
group:
name: sudo
state: present
- name: Agregar un nuevo usuario
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"
groups: "sudo"Todo es bastante simple, también tenemos un módulo group para crear grupos, con una sintaxis muy similar a apt. Después de esto, solo necesitamos asignar este grupo al usuario (groups: "sudo").
También es útil agregar una clave ssh a este usuario, para que podamos iniciar sesión como él sin contraseña:
---
- name: Playbook simple
# ...
tasks:
# ...
- name: Asegurar un grupo 'sudo'
group:
name: sudo
state: present
- name: Agregar un nuevo usuario
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"
groups: "sudo"
- name: Desplegar clave SSH
authorized_key:
user: "{{ user }}"
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
state: presentEn este caso, es interesante la construcción "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — copia el contenido del archivo id_rsa.pub (puede que su nombre sea diferente), es decir, la parte pública de la clave ssh y la carga en la lista de claves autorizadas para el usuario en el servidor.
Roles
Las tres tareas para crear usuarios pueden fácilmente atribuirse a un solo grupo de tareas, y sería recomendable mantener este grupo separado del playbook principal, para que no crezca demasiado. Para esto, en ansible existen .
De acuerdo con la estructura de archivos mencionada al principio, las roles deben colocarse en un directorio separado llamado roles, con un directorio diferente para cada rol llamado igual, dentro del cual se encuentran tasks, files, templates, etc.
Creemos la estructura de archivos: ./ansible/roles/user/tasks/main.yml (main es el archivo principal que se cargará y ejecutará al conectar el rol al playbook, en el que se pueden incluir otros archivos del rol). Ahora podemos trasladar a este archivo todas las tareas relacionadas con el usuario:
# Create user and add him to groups
- name: Ensure a 'sudo' group
group:
name: sudo
state: present
- name: Add a new user
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"
groups: "sudo"
- name: Deploy SSH Key
authorized_key:
user: "{{ user }}"
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
state: presentEn el playbook principal, debemos indicar que se use el rol user:
---
- name: Playbook simple
hosts: all
remote_user: root
gather_facts: no
tasks:
- name: Actualizar sistema
apt: update_cache=yes
- name: Instalar dependencias del sistema
apt:
name: git,nginx,redis,postgresql,postgresql-contrib
state: present
roles:
- userTambién, quizás tenga sentido realizar la actualización del sistema antes de todas las demás tareas, para esto se puede renombrar el bloque tasks en el que están definidas como pre_tasks.
Configuración de nginx
Nginx ya debería estar instalado, necesitamos configurarlo y ejecutarlo. Vamos a hacer esto de inmediato en el rol. Creemos la estructura de archivos:
- ansible
- roles
- nginx
- files
- tasks
- main.yml
- templatesAhora necesitaremos archivos y plantillas. La diferencia entre ellos es que los archivos los copia ansible directamente, tal cual. Y las plantillas deben tener la extensión j2 y se pueden usar valores de variables mediante las mismas dobles llaves.
Incluir nginx en el archivo main.yml. Para esto tenemos el módulo systemd: archivo. Para esto tenemos el módulo systemd:
# Copy nginx configs and start it
- name: enable service nginx and start
systemd:
name: nginx
state: started
enabled: yesAquí no solo decimos que nginx debe estar iniciado (es decir, lo ejecutamos), sino que también indicamos que debe estar habilitado.
Ahora copiemos los archivos de configuración:
# Copy nginx configs and start it
- name: enable service nginx and start
systemd:
name: nginx
state: started
enabled: yes
- name: Copy the nginx.conf
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
backup: yes
- name: Copy template my_app.conf
template:
src: my_app_conf.j2
dest: /etc/nginx/sites-available/my_app.conf
owner: root
group: root
mode: '0644'Creamos el archivo de configuración principal de nginx (se puede obtener directamente del servidor, o escribirlo uno mismo). También se crea el archivo de configuración para nuestra aplicación en el directorio sites_available (esto no es obligatorio pero es útil). En el primer caso, usamos el módulo copy para copiar los archivos (el archivo debe estar en /ansible/roles/nginx/files/nginx.conf). En el segundo — copiamos la plantilla, insertando los valores de las variables. La plantilla debe estar en /ansible/roles/nginx/templates/my_app.j2). Y puede verse más o menos así:
upstream {{ app_name }} {
server unix:{{ app_path }}\/shared\/tmp\/sockets\/puma.sock;
}
server {
listen 80;
server_name {{ server_name }} {{ inventory_hostname }};
root {{ app_path }}\/current\/public;
try_files $uri\/index.html $uri.html $uri @{{ app_name }};
....
}Presta atención a las inserciones {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — todas son variables, cuyos valores ansible insertará en la plantilla antes de la copia. Esto es útil si utilizas un playbook para diferentes grupos de hosts. Por ejemplo, podemos complementar nuestro archivo de inventario:
[production]
123.123.123.123
[staging]
231.231.231.231
[all:vars]
user=my_user
user_password=123qweasd
[production:vars]
server_name=production
app_path=\/home\/www\/my_app
app_name=my_app
[staging:vars]
server_name=staging
app_path=\/home\/www\/my_stage
app_name=my_stage_appSi ahora ejecutamos nuestro playbook, realizará las tareas especificadas para ambos hosts. Pero, para el host de staging, las variables serán diferentes de las de production, no solo en los roles y playbooks, sino también en las configuraciones de nginx. {{ inventory_hostname }} no es necesario indicar en el archivo de inventario — esta es y allí se almacena el host para el cual se está ejecutando el playbook en ese momento.
Si deseas tener un archivo de inventario para varios hosts, pero ejecutar solo para un grupo, se puede hacer con el siguiente comando:
ansible-playbook -i inventory .\/playbook.yml -l "staging"otra opción es tener archivos de inventario separados para diferentes grupos. O se pueden combinar dos enfoques, si tienes muchos hosts diferentes.
Regresamos a la configuración de nginx. Después de copiar los archivos de configuración, debemos crear un symlink en sites_enabled a my_app.conf desde sites_available. Y reiniciar nginx.
... # código antiguo en mail.yml
- name: Crear symlink a sites-enabled
file:
src: \/etc\/nginx\/sites-available\/my_app.conf
dest: \/etc\/nginx\/sites-enabled\/my_app.conf
state: link
- name: reiniciar nginx
service:
name: nginx
state: restartedAquí todo es sencillo: nuevamente, módulos de Ansible con una sintaxis bastante estándar. Pero hay un punto a mencionar. Reiniciar Nginx cada vez no tiene sentido. Te has dado cuenta de que no escribimos comandos del tipo: "hacer esto de esta manera", la sintaxis es más bien como "esto debe tener este estado". Y, con frecuencia, así es como realmente funciona Ansible. Si el grupo ya existe o el paquete del sistema ya está instalado, Ansible lo verificará y omitirá la tarea. Además, los archivos no se copiarán si son idénticos a los que ya están en el servidor. Podemos aprovechar esto y reiniciar Nginx solo si los archivos de configuración han cambiado. Para eso existe la directiva register:
# Copy nginx configs and start it
- name: enable service nginx and start
systemd:
name: nginx
state: started
enabled: yes
- name: Copy the nginx.conf
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
backup: yes
register: restart_nginx
- name: Copy template my_app.conf
template:
src: my_app_conf.j2
dest: /etc/nginx/sites-available/my_app.conf
owner: root
group: root
mode: '0644'
register: restart_nginx
- name: Create symlink to sites-enabled
file:
src: /etc/nginx/sites-available/my_app.conf
dest: /etc/nginx/sites-enabled/my_app.conf
state: link
- name: restart nginx
service:
name: nginx
state: restarted
when: restart_nginx.changedSi uno de los archivos de configuración cambia, entonces se realizará la copia y se registrará la variable restart_nginx. Y solo si esta variable ha sido registrada, se realizará el reinicio del servicio.
Y, por supuesto, debemos agregar el rol de Nginx en el playbook principal.
Configuración de PostgreSQL
Necesitamos habilitar PostgreSQL mediante systemd exactamente como lo hicimos con Nginx, así como crear un usuario que utilizaremos para acceder a la base de datos y la propia base de datos.
Crearemos un rol /ansible/roles/postgresql/tasks/main.yml:
# Create user in postgresql
- name: enable postgresql and start
systemd:
name: postgresql
state: started
enabled: yes
- name: Create database user
become_user: postgres
postgresql_user:
name: "{{ db_user }}"
password: "{{ db_password }}"
role_attr_flags: SUPERUSER
- name: Create database
become_user: postgres
postgresql_db:
name: "{{ db_name }}"
encoding: UTF-8
owner: "{{ db_user }}"No voy a detallar cómo agregar variables en el inventario, esto ya se ha hecho muchas veces, al igual que la sintaxis de los módulos postgresql_db y postgresql_user. Se puede encontrar más información en la documentación. Aquí lo más interesante es la directiva become_user: postgres. La cuestión es que, por defecto, solo el usuario postgres tiene acceso a la base de datos de PostgreSQL y solo de forma local. Esta directiva nos permite ejecutar comandos en nombre de este usuario (siempre que tengamos acceso).
También es posible que debas añadir una línea en pg_hba.conf para abrir acceso al nuevo usuario en la base de datos. Esto se puede hacer igual que cambiamos la configuración de Nginx.
Y, por supuesto, necesitamos agregar el rol de PostgreSQL en el playbook principal.
Instalación de Ruby a través de rbenv
En Ansible no hay módulos para trabajar con rbenv, y se instala clonando un repositorio de git. Por lo tanto, esta tarea se convierte en la más poco convencional. Crearemos un rol para ello /ansible/roles/ruby_rbenv/main.yml y comenzaremos a llenarlo:
# Install rbenv and ruby
- name: Install rbenv
become_user: "{{ user }}"
git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenvVolvemos a usar la directiva become_user para trabajar desde el usuario que hemos creado para estos fines. Dado que rbenv se instala en su directorio home y no de forma global. También usamos el módulo git para clonar el repositorio, especificando repo y dest.
A continuación, necesitamos agregar rbenv init en bashrc y también agregar rbenv a PATH. Para esto, contamos con el módulo lineinfile:
- name: Agregar rbenv a PATH
become_user: "{{ user }}"
lineinfile:
path: ~\/\.bashrc
state: present
line: 'export PATH="${HOME}\/\.rbenv\/bin:${PATH}"'
- name: Agregar rbenv init a bashrc
become_user: "{{ user }}"
lineinfile:
path: ~\/\.bashrc
state: present
line: 'eval "$(rbenv init -)"'Después de esto, es necesario instalar ruby_build:
- name: Instalar ruby-build
become_user: "{{ user }}"
git: repo=https:\/\/github.com\/rbenv\/ruby-build.git dest=~\/\.rbenv\/plugins\/ruby-buildY, por último, instalar ruby. Esto se hace a través de rbenv, es decir, simplemente con el comando bash:
- name: Instalar ruby
become_user: "{{ user }}"
shell: |
export PATH="${HOME}\/\.rbenv\/bin:${PATH}"
eval "$(rbenv init -)"
rbenv install {{ ruby_version }}
args:
executable: \/bin\/bashIndicamos qué comando ejecutar y con qué. Sin embargo, aquí nos encontramos con que ansible no ejecuta el código contenido en bashrc antes de ejecutar los comandos. Por lo tanto, rbenv debe definirse directamente en este mismo script.
El siguiente problema está relacionado con el hecho de que el comando shell no tiene estado desde el punto de vista de ansible. Es decir, no habrá una verificación automática de si esta versión de ruby está instalada o no. Podemos hacerlo nosotros mismos:
- name: Instalar ruby
become_user: "{{ user }}"
shell: |
export PATH="${HOME}\/\.rbenv\/bin:${PATH}"
eval "$(rbenv init -)"
if ! rbenv versions | grep -q {{ ruby_version }}
then rbenv install {{ ruby_version }} && rbenv global {{ ruby_version }}
fi
args:
executable: \/bin\/bashY queda instalar bundler:
- name: Instalar bundler
become_user: "{{ user }}"
shell: |
export PATH="${HOME}\/\.rbenv\/bin:${PATH}"
eval "$(rbenv init -)"
gem install bundlerY nuevamente agregar nuestro rol ruby_rbenv en el playbook principal.
Archivos compartidos.
En general, con esto se podría acabar la configuración. A continuación, solo queda ejecutar capistrano y este copiará el código, creará los directorios necesarios y ejecutará la aplicación (si está todo configurado correctamente). Sin embargo, a menudo capistrano necesita archivos de configuración adicionales, como database.yml o .env Se pueden copiar de la misma manera que los archivos y plantillas para nginx. Solo hay un pequeño detalle. Antes de copiar los archivos, es necesario crear la estructura de directorios para ellos, algo como esto:
# Copy shared files for deploy
- name: Ensure shared dir
become_user: "{{ user }}"
file:
path: "{{ app_path }}/shared/config"
state: directorysolo indicamos un directorio y ansible creará automáticamente los padres, si es necesario.
Ansible Vault
Ya nos hemos encontrado con que en las variables pueden encontrarse datos secretos como las contraseñas de los usuarios. Si has creado .env un archivo para la aplicación, y database.yml entonces allí debería haber aún más datos críticos. Sería bueno ocultarlos de miradas ajenas. Para esto se utiliza .
Crearemos un archivo para las variables /ansible/vars/all.yml (se pueden crear diferentes archivos para diferentes grupos de hosts, igual que en el archivo de inventario: production.yml, staging.yml, etc.).
En este archivo es necesario trasladar todas las variables que deben ser cifradas, utilizando la sintaxis estándar de yml:
# System vars
user_password: 123qweasd
db_password: 123qweasd
# ENV vars
aws_access_key_id: xxxxx
aws_secret_access_key: xxxxxx
aws_bucket: bucket_name
rails_secret_key_base: very_secret_key_baseDespués, este archivo se puede cifrar con el comando:
ansible-vault encrypt ./vars/all.ymlNaturalmente, al cifrar será necesario establecer una contraseña para la descifrado. Puedes ver qué habrá dentro del archivo después de ejecutar este comando.
Usando ansible-vault decrypt el archivo se puede descifrar, modificar y luego volver a cifrar.
Para trabajar no es necesario descifrar el archivo. Lo mantienes en forma cifrada y ejecutas el playbook con el argumento --ask-vault-pass. Ansible pedirá la contraseña, obtendrá las variables y ejecutará las tareas. Todos los datos permanecerán cifrados.
El comando completo para varios grupos de hosts y ansible vault se verá aproximadamente así:
ansible-playbook -i inventory ./playbook.yml -l "staging" --ask-vault-passY no te proporcionaré el texto completo de los playbooks y roles, escríbelos tú mismo. Porque ansible es así: si no entiendes qué necesitas hacer, tampoco él lo hará por ti.
Fuente: habr.com
