Recent, I needed to write several Ansible playbooks to prepare a server for deploying a Rails application. Surprisingly, I couldn't find a simple step-by-step manual. I didn't want to copy someone else's playbook without understanding what was happening, so I ended up reading the documentation and piecing everything together myself. Perhaps I can help someone speed up this process with this article.
First of all, it’s important to understand that Ansible provides you with a convenient interface to execute a predefined list of actions on a remote server (or servers) through SSH. There’s no magic here; you can’t just install a plugin and get zero downtime deployment of your application with Docker, monitoring, and other features right out of the box. To write a playbook, you need to know exactly what you want to do and how to do it. Therefore, I am not satisfied with ready-made playbooks from GitHub, or articles that say: 'Copy and run — it will work.'
What do we need?
As I mentioned, to write a playbook, you need to know what you want to accomplish and how to achieve it. Let’s determine what we need. For a Rails application, we will need several system packages: nginx, postgresql (redis, etc.). In addition, we need Ruby of a specific version. It’s best to install it through rbenv (rvm, asdf…). Running all this as the root user is always a bad idea, so you should create a separate user and set up permissions for them. After that, it’s necessary to upload our code to the server, copy the configs for nginx, postgres, etc., and start all these services.
As a result, the sequence of actions is as follows:
- Log in as root
- Install system packages
- Create a new user, set permissions, ssh key
- Configure system packages (nginx, etc.) and start them
- Create a user in the database (you can also create the database right away)
- Log in as the new user
- Install rbenv and Ruby
- Install the bundler
- Upload the application code
- Start the Puma server
Furthermore, the last steps can be performed using Capistrano; at least it can natively copy code to release directories, switch the release via a symlink after a successful deployment, copy configs from the shared directory, restart Puma, etc. All of this can also be done using Ansible, but why?
File structure
Ansible are o structură strictă pentru toate fișierele sale, așa că cel mai bine este să le păstrați într-un director separat. Nu este atât de important dacă este în aplicația Rails sau separat. Puteți stoca fișierele într-un repository git separat. Personal, mi s-a părut cel mai convenabil să creez un director ansible în /config-ul aplicației Rails și să păstrez totul într-un singur repository.
Playbook simplu
Playbook-ul este un fișier yml în care, utilizând o sintaxă specială, se descrie ce și cum trebuie să facă Ansible. Să creăm primul playbook care nu face nimic:
---
- nume: Playbook simplu
hosts: toateAici pur și simplu spunem că playbook-ul nostru se numește Playbook simplu și că conținutul său trebuie să fie executat pentru toate gazdele. Îl putem salva în directorul /ansible cu numele playbook.yml și să încercăm să-l rulăm:
ansible-playbook ./playbook.yml
PLAY [Playbook simplu] ************************************************************************************************************************************
skipping: no hosts matchedAnsible spune că nu știe gazdele care corespund listei toate. Acestea trebuie enumerate într-un fișier special de .
Să-l creăm în același director ansible:
123.123.123.123Aici specificăm pur și simplu gazda (ideal gazda VPS-ului dumneavoastră pentru teste, sau puteți specifica localhost) și îl salvăm cu numele inventory.
Puteți încerca să rulați ansible cu fișierul de inventar:
ansible-playbook ./playbook.yml -i inventory
PLAY [Playbook simplu] ************************************************************************************************************************************
TASK [Colectarea informațiilor] ************************************************************************************************************************************
PLAY RECAP ************************************************************************************************************************************Dacă aveți acces prin ssh la gazda specificată, Ansible se va conecta și va aduna informații despre sistemul remote. (TASK-ul implicit [Colectarea informațiilor]) după care va da un raport scurt despre execuție (PLAY RECAP).
În mod implicit, pentru conexiune se folosește numele utilizatorului cu care sunteți logat în sistem. Pe gazda respectivă, acesta, cel mai probabil, nu va exista. În fișierul playbook puteți specifica ce utilizator să folosească pentru conectare prin directiva remote_user. De asemenea, informațiile despre sistemul remote vă pot fi adesea inutile și nu merită să pierdeți timp pentru colectarea lor. Această sarcină poate fi, de asemenea, dezactivată:
---
- nume: Playbook simplu
hosts: toate
remote_user: root
become: true
gather_facts: nuÎncercați din nou să rulați playbook-ul și să vă asigurați că conexiunea funcționează. (Dacă ați indicat utilizatorul root, trebuie de asemenea să specificați directiva become: true pentru a obține drepturi administrative. Așa cum este menționat în documentație: become set to ‘true’/’yes’ pentru a activa escaladarea privilegiilor. deși nu este foarte clar de ce).
Este posibil să primiți o eroare cauzată de faptul că ansible nu poate determina interpretul Python, atunci îl puteți specifica manual:
ansible_python_interpreter: /usr/bin/python3 locația Python-ului poate fi obținută folosind comanda whereis python.
Instalarea pachetelor de sistem
În distribuția standard a Ansible se găsesc multe module pentru lucrul cu diverse pachete de sistem, motiv pentru care nu trebuie să scriem scripturi bash pentru orice. Acum ne va trebui unul dintre aceste module pentru a actualiza sistemul și a instala pachete de sistem. Eu am Ubuntu Linux pe VPS, așa că pentru instalarea pachetelor folosesc apt-get și . Dacă folosiți un alt sistem de operare, s-ar putea să aveți nevoie de un alt mod (amintiți-vă, la început am spus că trebuie să știm dinainte ce și cum vom face). Totuși, sintaxa va fi probabil similară.
Să completăm playbook-ul nostru cu primele sarcini:
---
- 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: presentTask — este de fapt sarcina pe care ansible o va executa pe serverele remote. Noi dăm sarcinii un nume pentru a-i urmări execuția în jurnal. Și descriem, folosind sintaxa modulului specific, ce trebuie să facă. În acest caz apt: update_cache=yes — indică să actualizăm pachetele sistemului folosind modulul apt. A doua comandă este puțin mai complexă. Transmitem modulului apt o listă de pachete și spunem că acestea state ar trebui să devină existent, adică spunem să instalăm aceste pachete. În mod similar, putem spune să le ștergem sau să le actualizăm, pur și simplu schimbând state. Rețineți că pentru ca rails să funcționeze cu postgresql, avem nevoie de pachetul postgresql-contrib, pe care îl instalăm acum. Acest lucru trebuie să fie știut și făcut, ansible nu va face asta de la sine.
Încercați să rulați din nou playbook-ul și verificați că pachetele se vor instala.
Crearea de noi utilizatori.
Pentru a lucra cu utilizatorii, Ansible are de asemenea un modul - user. Vom adăuga o nouă sarcină (am ascuns părțile cunoscute ale playbook-ului în comentarii, pentru a nu-l copia în întregime de fiecare dată):
---
- name: Playbook simplu
# ...
tasks:
# ...
- name: Adaugă un utilizator nou
user:
name: my_user
shell: /bin/bash
password: "{{ 123qweasd | password_hash('sha512') }}"Creăm un nou utilizator, îi setăm shell-ul și parola. Dar ne confruntăm imediat cu câteva probleme. Ce ar trebui să facem dacă numele utilizatorilor trebuie să fie diferite pentru diferite gazde? Și stocarea parolei în clar în playbook este o idee foarte proastă. La început, vom muta numele utilizatorului și parola în variabile, iar mai aproape de sfârșitul articolului voi arăta cum să criptăm parola.
---
- name: Playbook simplu
# ...
tasks:
# ...
- name: Adaugă un utilizator nou
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"Prin intermediul acoladelor duble în playbooks sunt definite variabilele.
Valorile variabilelor le vom specifica în fișierul de inventory:
123.123.123.123
[all:vars]
user=my_user
user_password=123qweasdObservați directiva [all:vars] — aceasta indică faptul că următorul bloc de text este variabile (vars) și se aplică pentru toate gazdele (all).
De asemenea, construcția "{{ user_password | password_hash('sha512') }}". Problema este că ansible nu adaugă utilizatorul prin user_add așa cum ați face manual. Ci stochează toate datele direct, motiv pentru care parola trebuie să fie prealabil transformată într-un hash, ceea ce face această comandă.
Să adăugăm utilizatorul nostru în grupul sudo. Totuși, înainte de asta, trebuie să ne asigurăm că acest grup există, pentru că nimeni nu se va ocupa de asta pentru noi:
---
- name: Playbook simplu
# ...
tasks:
# ...
- name: Asigură un grup 'sudo'
group:
name: sudo
state: present
- name: Adaugă un utilizator nou
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"
groups: "sudo"Totul este suficient de simplu, avem de asemenea modul group pentru a crea grupuri, cu o sintaxă foarte similară cu apt. După care, tot ce trebuie să facem este să atribuim acest grup utilizatorului (groups: "sudo").
De asemenea, este util să adăugăm cheia ssh acestui utilizator, astfel încât să ne putem conecta fără parolă:
---
- name: Playbook simplu
# ...
tasks:
# ...
- name: Asigură un grup 'sudo'
group:
name: sudo
state: present
- name: Adaugă un utilizator nou
user:
name: "{{ user }}"
shell: /bin/bash
password: "{{ user_password | password_hash('sha512') }}"
groups: "sudo"
- name: Distribuie cheia SSH
authorized_key:
user: "{{ user }}"
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
state: presentÎn acest caz, construcția este interesantă "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — ea copiază conținutul fișierului id_rsa.pub (poate că numele este diferit), adică partea publică a cheii SSH și o încarcă în lista de chei autorizate pentru utilizator pe server.
Roluri
Toate cele trei sarcini pentru crearea utilizatorului pot fi ușor legate de un singur grup de sarcini, și ar fi bine să păstrăm acest grup separat de playbook-ul principal, pentru a nu se extinde prea mult. Pentru aceasta, în ansible există .
Conform structurii de fișiere specificate la început, rolurile trebuie să fie plasate într-un director separat roles, fiecare rol — într-un director separat cu numele corespunzător, în interiorul directorului tasks, files, templates etc.
Să creăm structura de fișiere: ./ansible/roles/user/tasks/main.yml (main — acesta este fișierul principal care va fi încărcat și executat atunci când se atașează rolul la playbook, în el pot fi conectate alte fișiere de rol). Acum putem muta în acest fișier toate sarcinile referitoare la utilizator:
# 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: presentÎn playbook-ul principal, trebuie să specificați să folosiți rolul user:
---
- name: Playbook simplu
hosts: all
remote_user: root
gather_facts: no
tasks:
- name: Actualizează sistemul
apt: update_cache=yes
- name: Instalează dependențele sistemului
apt:
name: git,nginx,redis,postgresql,postgresql-contrib
state: present
roles:
- userDe asemenea, poate că are sens să executăm actualizarea sistemului înaintea tuturor celorlalte sarcini, pentru aceasta putem redenumi blocul tasks în care sunt definite pre_tasks.
Configurarea nginx
Nginx ar trebui să fie deja instalat, trebuie să-l configurăm și să-l pornim. Să facem acest lucru imediat în rol. Creăm structura de fișiere:
- ansible
- roles
- nginx
- files
- tasks
- main.yml
- templatesAcum avem nevoie de fișiere și șabloane. Diferența dintre ele este că Ansible copiază fișierele direct, așa cum sunt. Iar șabloanele trebuie să aibă extensia j2 și în ele putem folosi valori de variabile utilizând aceleași dublu acolade.
Să includem nginx în fișierul main.yml. Pentru aceasta avem modulul systemd: fișier. Pentru aceasta, avem modulul systemd:
# Copy nginx configs and start it
- name: enable service nginx and start
systemd:
name: nginx
state: started
enabled: yesAici nu doar că spunem că nginx trebuie să fie pornit (adică îl pornim), dar spunem și că trebuie să fie activat.
Acum vom copia fișierele de configurare:
# 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'Creăm fișierul principal de configurare nginx (îl putem lua direct de pe server sau îl putem scrie noi înșine). De asemenea, creăm fișierul de configurare pentru aplicația noastră în directorul sites_available (acesta nu este obligatoriu, dar este util). În primul caz, folosim modulul copy pentru a copia fișierele (fișierul trebuie să se afle în /ansible/roles/nginx/files/nginx.conf). În al doilea — copiem șablonul, înlocuind valorile variabilelor. Șablonul trebuie să se afle în /ansible/roles/nginx/templates/my_app.j2). Poate arăta cam așa:
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 }};
....
}Vă rugăm să rețineți inserțiile {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — acestea sunt toate variabile care vor fi înlocuite în șablon de ansible înainte de copiere. Acest lucru este util dacă utilizați un playbook pentru grupuri diferite de gazde. De exemplu, putem completa fișierul nostru de inventory:
[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_appDacă acum vom rula playbook-ul nostru, acesta va executa sarcinile indicate pentru ambele gazde. Dar pentru gazda staging, variabilele vor fi diferite față de production, și nu doar în roluri și playbook-uri, ci și în configurațiile nginx. {{ inventory_hostname }} nu trebuie să specificați în fișierul de inventory — aceasta este și aici se află gazda pentru care se execută playbook-ul în acest moment.
Dacă doriți să aveți un fișier de inventory pentru mai multe gazde, dar să rulați doar pentru un singur grup, puteți face acest lucru cu comanda următoare:
ansible-playbook -i inventory .\/playbook.yml -l "staging"o altă variantă — a avea fișiere de inventory separate pentru grupuri diferite. Sau puteți combina cele două abordări, dacă aveți multe gazde diferite.
Să ne întoarcem la configurarea nginx. După copierea fișierelor de configurare, trebuie să creăm un symlink în sites_enabled pentru my_app.conf din sites_available. Și să repornim nginx.
... # cod vechi în mail.yml
- 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: restartedAici totul este simplu — din nou module ansible cu o sintaxă destul de standard. Dar există un detaliu. Nu are sens să reporniți nginx de fiecare dată. Ați observat că nu scriem comenzi de genul: „faceți asta așa”, sintaxa arată mai degrabă ca „acesta trebuie să aibă o astfel de stare”. Și de cele mai multe ori ansible funcționează exact așa. Dacă grupul există deja sau pachetul sistemului este deja instalat, ansible va verifica asta și va sări peste sarcină. De asemenea, fișierele nu vor fi copiate dacă sunt identice cu cele ce există deja pe server. Putem profita de acest lucru și să repornim nginx doar dacă fișierele de configurare au fost modificate. Pentru aceasta există 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.changedDacă unul dintre fișierele de configurare se schimbă, atunci va fi efectuată copierea și va fi înregistrată variabila restart_nginx. Și doar dacă această variabilă a fost înregistrată, se va efectua repornirea serviciului.
Și, bineînțeles, trebuie să adăugăm rolul nginx în playbook-ul principal.
Configurarea postgresql
Trebuie să activăm postgresql prin systemd exact așa cum am făcut cu nginx, iar de asemenea să creăm un utilizator pe care îl vom utiliza pentru accesul la baza de date și baza de date în sine.
Să creăm 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 }}"Nu voi detalia cum să adăugați variabile în inventory, aceasta a fost făcută deja de multe ori, la fel ca și sintaxa modulelor postgresql_db și postgresql_user. Mai multe informații pot fi găsite în documentație. Aici este cea mai interesantă directivă become_user: postgres. Ceea ce este important, este că prin default accesul la baza de date postgresql este permis doar utilizatorului postgres și doar local. Această directivă ne permite să executăm comenzi în numele acestui utilizator (dacă, desigur, avem acces).
De asemenea, este posibil să fie necesar să adăugați o linie în pg_hba.conf pentru a deschide accesul noului utilizator la bază. Acest lucru se poate face la fel cum am modificat configurația nginx.
Și, desigur, trebuie să adăugăm rolul postgresql în playbook-ul principal.
Instalarea ruby prin rbenv
În ansible nu există module pentru lucru cu rbenv, și acesta se instalează prin clonarea unui repository git. Așadar, această sarcină devine cea mai neobișnuită. Să creăm un rol pentru aceasta /ansible/roles/ruby_rbenv/main.yml și să începem să-l completăm:
# Install rbenv and ruby
- name: Install rbenv
become_user: "{{ user }}"
git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenvFolosim din nou directiva become_user pentru a lucra sub utilizatorul creat de noi pentru aceste scopuri. Deoarece rbenv este instalat în directorul său home, nu global. De asemenea, folosim modulul git pentru a clona repository-ul, specificând repo și dest.
Apoi trebuie să adăugăm rbenv init în bashrc și să adăugăm rbenv în PATH. Pentru aceasta avem modulul lineinfile:
- name: Adaugă rbenv în PATH
become_user: "{{ user }}"
lineinfile:
path: ~\/\.bashrc
state: present
line: 'export PATH="${HOME}\/\.rbenv\/bin:${PATH}"'
- name: Adaugă rbenv init în bashrc
become_user: "{{ user }}"
lineinfile:
path: ~\/\.bashrc
state: present
line: 'eval "$(rbenv init -)"'După aceea, trebuie să instalăm ruby_build:
- name: Instalează ruby-build
become_user: "{{ user }}"
git: repo=https:\/\/github.com\/rbenv\/ruby-build.git dest=~\/\.rbenv\/plugins\/ruby-buildȘi, în final, instalează ruby. Acest lucru se face prin rbenv, adică pur și simplu printr-o comandă bash:
- name: Instalează ruby
become_user: "{{ user }}"
shell: |
export PATH="${HOME}\/\.rbenv\/bin:${PATH}"
eval "$(rbenv init -)"
rbenv install {{ ruby_version }}
args:
executable: \/bin\/bashSpecificăm ce comandă să executăm și cu ce. Totuși, aici ne vom confrunta cu faptul că ansible nu execută codul din bashrc înainte de a rula comenzile. Asta înseamnă că rbenv trebuie definit chiar în acest script.
Următoarea problemă este că comanda shell nu are un statut din perspectiva ansible. Adică nu va exista o verificare automată dacă această versiune de ruby este instalată sau nu — trebuie să facem asta noi înșine:
- name: Instalează 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\/bashȘi rămâne să instalăm bundler:
- name: Instalează bundler
become_user: "{{ user }}"
shell: |
export PATH="${HOME}\/\.rbenv\/bin:${PATH}"
eval "$(rbenv init -)"
gem install bundlerȘi din nou, adăugați rolul nostru ruby_rbenv în playbook-ul principal.
Fișiere partajate.
În general, aici s-ar putea încheia configurarea. Urmează să pornim capistrano și acesta va copia singur codul, va crea directoarele necesare și va porni aplicația (dacă totul este configurat corect). Totuși, deseori capistrano necesită fișiere de configurare suplimentare, cum ar fi database.yml sau .env Acestea pot fi copiate exact la fel ca fișierele și șabloanele pentru nginx. Există doar o mică nuanță. Înainte de a copia fișierele, trebuie creată structura directoarelor pentru ele, ceva de genul:
# Copy shared files for deploy
- name: Ensure shared dir
become_user: "{{ user }}"
file:
path: "{{ app_path }}/shared/config"
state: directoryspecificăm doar un singur director, iar ansible va crea automat părinții, dacă este necesar.
Ansible Vault
Am dat peste situații în care variabilele pot conține date sensibile, cum ar fi parola utilizatorului. Dacă ați creat .env un fișier pentru aplicație, și database.yml atunci ar trebui să existe și mai multe astfel de date critice. Ar fi bine să le ascundem de ochii curioșilor. Pentru aceasta se folosește .
Să creăm un fișier pentru variabile /ansible/vars/all.yml (aici se pot crea diferite fișiere pentru diferite grupuri de gazde, la fel ca în fișierul inventory: production.yml, staging.yml etc.).
În acest fișier trebuie aduse toate variabilele care trebuie să fie criptate, folosind sintaxa standard 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_baseDupă aceea, acest fișier poate fi criptat cu comanda:
ansible-vault encrypt .\/vars\/all.ymlDesigur, la criptare va trebui să stabiliți o parolă pentru decriptare. Puteți vedea ce va conține fișierul după executarea acestei comenzi.
Cu ajutorul ansible-vault decrypt fișierul poate fi decriptat, modificat și apoi criptat din nou.
Pentru funcționare nu este nevoie să decriptați fișierul. Îl păstrați într-o formă criptată și rulați playbook-ul cu argumentul --ask-vault-pass. Ansible vă va cere parola, va extrage variabilele și va executa sarcinile. Toate datele vor rămâne criptate.
Comanda completă pentru mai multe grupuri de gazde și ansible vault va arăta aproximativ așa:
ansible-playbook -i inventory .\/playbook.yml -l "staging" --ask-vault-passȘi nu vă voi oferi textul complet al playbook-urilor și rolurilor, scrieți-le singuri. Pentru că ansible este astfel — dacă nu înțelegi ce trebuie făcut, atunci nici el nu îți va face.
Sursa: habr.com
