Configurando um servidor para implantar uma aplicação Rails usando Ansible

Não faz muito tempo, precisei escrever vários playbooks do Ansible para preparar o servidor para implantar uma aplicação Rails. E, surpreendentemente, não encontrei um manual passo a passo simples. Eu não queria copiar o manual de outra pessoa sem entender o que estava acontecendo e, no final, tive que ler a documentação, coletando tudo sozinho. Talvez eu possa ajudar alguém a acelerar esse processo com a ajuda deste artigo.

A primeira coisa a entender é que o ansible fornece uma interface conveniente para executar uma lista predefinida de ações em servidores remotos via SSH. Não há mágica aqui, você não pode instalar um plug-in e obter uma implantação de seu aplicativo com tempo de inatividade zero com docker, monitoramento e outras vantagens prontas para uso. Para escrever um manual, você deve saber exatamente o que deseja fazer e como fazê-lo. É por isso que não estou satisfeito com manuais prontos do GitHub ou artigos como: “Copie e execute, vai funcionar”.

O que precisamos?

Como já disse, para escrever um playbook você precisa saber o que quer fazer e como fazer. Vamos decidir o que precisamos. Para uma aplicação Rails precisaremos de vários pacotes de sistema: nginx, postgresql (redis, etc). Além disso, precisamos de uma versão específica do Ruby. É melhor instalá-lo via rbenv (rvm, asdf...). Executar tudo isso como usuário root é sempre uma má ideia, então você precisa criar um usuário separado e configurar seus direitos. Depois disso, você precisa fazer upload do nosso código para o servidor, copiar as configurações do nginx, postgres, etc e iniciar todos esses serviços.

Como resultado, a sequência de ações é a seguinte:

  1. Faça login como root
  2. instalar pacotes do sistema
  3. crie um novo usuário, configure direitos, chave ssh
  4. configure pacotes do sistema (nginx etc) e execute-os
  5. Criamos um usuário no banco de dados (você pode criar um banco de dados imediatamente)
  6. Faça login como um novo usuário
  7. Instale rbenv e ruby
  8. Instalando o empacotador
  9. Fazendo upload do código do aplicativo
  10. Iniciando o servidor Puma

Além disso, os últimos estágios podem ser feitos usando o capistrano, pelo menos pronto para uso, ele pode copiar o código nos diretórios de lançamento, alternar o lançamento com um link simbólico após a implantação bem-sucedida, copiar configurações de um diretório compartilhado, reiniciar o puma, etc. Tudo isso pode ser feito usando Ansible, mas por quê?

Estrutura de arquivo

Ansible tem rigoroso estrutura de arquivo para todos os seus arquivos, então é melhor manter tudo em um diretório separado. Além disso, não é tão importante se será na própria aplicação Rails ou separadamente. Você pode armazenar arquivos em um repositório git separado. Pessoalmente, achei mais conveniente criar um diretório ansible no diretório /config do aplicativo Rails e armazenar tudo em um repositório.

Manual Simples

Playbook é um arquivo yml que, usando sintaxe especial, descreve o que o Ansible deve fazer e como. Vamos criar o primeiro manual que não faz nada:

---
- name: Simple playbook
  hosts: all

Aqui dizemos simplesmente que nosso manual se chama Simple Playbook e que seu conteúdo deve ser executado para todos os hosts. Podemos salvá-lo no diretório /ansible com o nome playbook.yml e tente executar:

ansible-playbook ./playbook.yml

PLAY [Simple Playbook] ************************************************************************************************************************************
skipping: no hosts matched

Ansible diz que não conhece nenhum host que corresponda à lista completa. Eles devem ser listados em um especial arquivo de inventário.

Vamos criá-lo no mesmo diretório ansible:

123.123.123.123

É assim que simplesmente especificamos o host (de preferência o host do nosso VPS para teste, ou você pode registrar localhost) e salvamos com o nome inventory.
Você pode tentar executar o ansible com um arquivo de inventário:

ansible-playbook ./playbook.yml -i inventory
PLAY [Simple Playbook] ************************************************************************************************************************************

TASK [Gathering Facts] ************************************************************************************************************************************

PLAY RECAP ************************************************************************************************************************************

Se você tiver acesso ssh ao host especificado, o ansible se conectará e coletará informações sobre o sistema remoto. (padrão TASK [Gathering Facts]) após o qual fornecerá um breve relatório sobre a execução (PLAY RECAP).

Por padrão, a conexão usa o nome de usuário com o qual você está conectado ao sistema. Provavelmente não estará no host. No arquivo do playbook, você pode especificar qual usuário usar para se conectar usando a diretiva remote_user. Além disso, muitas vezes as informações sobre um sistema remoto podem ser desnecessárias para você e você não deve perder tempo coletando-as. Esta tarefa também pode ser desativada:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  become: true
  gather_facts: no

Tente executar o manual novamente e verifique se a conexão está funcionando. (Se você especificou o usuário root, também precisará especificar a diretiva tornar-se: true para obter direitos elevados. Conforme escrito na documentação: become set to ‘true’/’yes’ to activate privilege escalation. embora não esteja totalmente claro o porquê).

Talvez você receba um erro causado pelo fato de o ansible não poder determinar o interpretador Python, então você pode especificá-lo manualmente:

ansible_python_interpreter: /usr/bin/python3 

Você pode descobrir onde você tem python com o comando whereis python.

Instalando pacotes do sistema

A distribuição padrão do Ansible inclui muitos módulos para trabalhar com vários pacotes de sistema, portanto, não precisamos escrever scripts bash por nenhum motivo. Agora precisamos de um desses módulos para atualizar o sistema e instalar pacotes do sistema. Eu tenho Ubuntu Linux no meu VPS, então para instalar pacotes eu uso apt-get и módulo para isso. Se você estiver usando um sistema operacional diferente, poderá precisar de um módulo diferente (lembre-se, eu disse no início que precisamos saber com antecedência o que e como faremos). No entanto, a sintaxe provavelmente será semelhante.

Vamos complementar nosso manual com as primeiras tarefas:

---
- 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: present

Tarefa é exatamente a tarefa que o Ansible executará em servidores remotos. Damos um nome à tarefa para que possamos rastrear sua execução no log. E descrevemos, usando a sintaxe de um módulo específico, o que ele precisa fazer. Nesse caso apt: update_cache=yes - diz para atualizar os pacotes do sistema usando o módulo apt. O segundo comando é um pouco mais complicado. Passamos uma lista de pacotes para o módulo apt e dizemos que eles são state Deve se tornar present, isto é, dizemos instalar esses pacotes. De forma semelhante, podemos dizer-lhes para excluí-los ou atualizá-los simplesmente alterando state. Observe que para o Rails funcionar com o postgresql precisamos do pacote postgresql-contrib, que estamos instalando agora. Novamente, você precisa saber e fazer isso; o ansible por si só não fará isso.

Tente executar o manual novamente e verifique se os pacotes estão instalados.

Criando novos usuários.

Para trabalhar com usuários, o Ansible também possui um módulo - usuário. Vamos adicionar mais uma tarefa (escondi as partes já conhecidas do manual atrás dos comentários para não copiá-lo inteiramente todas as vezes):

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: my_user
        shell: /bin/bash
        password: "{{ 123qweasd | password_hash('sha512') }}"

Criamos um novo usuário, definimos um shell e uma senha para ele. E então nos deparamos com vários problemas. E se os nomes de usuário precisarem ser diferentes para hosts diferentes? E armazenar a senha em texto não criptografado no manual é uma péssima ideia. Para começar, vamos colocar o nome de usuário e a senha em variáveis, e no final do artigo mostrarei como criptografar a senha.

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - name: Add a new user
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"

As variáveis ​​são definidas em manuais usando chaves duplas.

Indicaremos os valores das variáveis ​​no arquivo de inventário:

123.123.123.123

[all:vars]
user=my_user
user_password=123qweasd

Observe a diretriz [all:vars] - diz que o próximo bloco de texto são variáveis ​​(vars) e elas são aplicáveis ​​a todos os hosts (todos).

O design também é interessante "{{ user_password | password_hash('sha512') }}". O problema é que o ansible não instala o usuário via user_add como se você fizesse isso manualmente. E salva todos os dados diretamente, por isso também devemos converter a senha em hash antecipadamente, que é o que este comando faz.

Vamos adicionar nosso usuário ao grupo sudo. Porém, antes disso precisamos ter certeza de que tal grupo existe porque ninguém fará isso por nós:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - 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"

Tudo é bastante simples, temos também um módulo group para criação de grupos, com uma sintaxe muito semelhante ao apt. Então basta cadastrar este grupo para o usuário (groups: "sudo").
Também é útil adicionar uma chave ssh a este usuário para que possamos fazer login sem senha:

---
- name: Simple playbook
  # ...
  tasks:
    # ...
    - 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

Neste caso, o design é interessante "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — copia o conteúdo do arquivo id_rsa.pub (seu nome pode ser diferente), ou seja, a parte pública da chave ssh e carrega-a na lista de chaves autorizadas do usuário no servidor.

Papéis

Todas as três tarefas para criação de uso podem ser facilmente classificadas em um grupo de tarefas, e seria uma boa ideia armazenar esse grupo separadamente do manual principal para que ele não cresça muito. Para isso, Ansible papéis.
De acordo com a estrutura de arquivos indicada no início, as funções devem ser colocadas em um diretório de funções separado, para cada função existe um diretório separado com o mesmo nome, dentro do diretório de tarefas, arquivos, modelos, etc.
Vamos criar uma estrutura de arquivos: ./ansible/roles/user/tasks/main.yml (main é o arquivo principal que será carregado e executado quando uma função for conectada ao playbook; outros arquivos de função podem ser conectados a ele). Agora você pode transferir todas as tarefas relacionadas ao usuário para este arquivo:

# 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

No manual principal, você deve especificar o uso da função de usuário:

---
- name: Simple playbook
  hosts: all
  remote_user: root
  gather_facts: no

  tasks:
    - name: Update system
      apt: update_cache=yes
    - name: Install system dependencies
      apt:
        name: git,nginx,redis,postgresql,postgresql-contrib
        state: present

  roles:
    - user

Além disso, pode fazer sentido atualizar o sistema antes de todas as outras tarefas; para fazer isso, você pode renomear o bloco tasks em que são definidos pre_tasks.

Configurando o nginx

Já devemos ter o Nginx instalado; precisamos configurá-lo e executá-lo. Vamos fazer isso imediatamente no papel. Vamos criar uma estrutura de arquivos:

- ansible
  - roles
    - nginx
      - files
      - tasks
        - main.yml
      - templates

Agora precisamos de arquivos e modelos. A diferença entre eles é que o ansible copia os arquivos diretamente, como estão. E os templates devem ter a extensão j2 e podem usar valores de variáveis ​​usando as mesmas chaves duplas.

Vamos habilitar o nginx em main.yml arquivo. Para isso temos um módulo systemd:

# Copy nginx configs and start it
- name: enable service nginx and start
  systemd:
    name: nginx
    state: started
    enabled: yes

Aqui não apenas dizemos que o nginx deve ser iniciado (ou seja, nós o iniciamos), mas dizemos imediatamente que ele deve ser habilitado.
Agora vamos copiar os arquivos de configuração:

# 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'

Criamos o arquivo de configuração principal do nginx (você pode obtê-lo diretamente do servidor ou escrevê-lo você mesmo). E também o arquivo de configuração da nossa aplicação no diretório sites_available (isso não é necessário, mas é útil). No primeiro caso, utilizamos o módulo copy para copiar arquivos (o arquivo deve estar em /ansible/roles/nginx/files/nginx.conf). Na segunda, copiamos o template, substituindo os valores das variáveis. O modelo deve estar em /ansible/roles/nginx/templates/my_app.j2). E pode ser algo assim:

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 }};
  ....
}

Preste atenção nas inserções {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — essas são todas as variáveis ​​cujos valores o Ansible substituirá no modelo antes de copiar. Isso é útil se você usar um manual para diferentes grupos de hosts. Por exemplo, podemos adicionar nosso arquivo de inventário:

[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_app

Se lançarmos agora nosso manual, ele executará as tarefas especificadas para ambos os hosts. Mas, ao mesmo tempo, para um host de teste, as variáveis ​​serão diferentes das de produção, e não apenas em funções e manuais, mas também nas configurações do nginx. {{ inventory_hostname }} não precisa ser especificado no arquivo de inventário - isso variável ansible especial e o host para o qual o manual está em execução é armazenado lá.
Se você deseja ter um arquivo de inventário para vários hosts, mas executado apenas para um grupo, isso pode ser feito com o seguinte comando:

ansible-playbook -i inventory ./playbook.yml -l "staging"

Outra opção é ter arquivos de inventário separados para grupos diferentes. Ou você pode combinar as duas abordagens se tiver muitos hosts diferentes.

Vamos voltar à configuração do nginx. Depois de copiar os arquivos de configuração, precisamos criar um link simbólico em sitest_enabled para my_app.conf em sites_available. E reinicie o nginx.

... # old code in 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: restarted

Tudo é simples aqui - novamente módulos ansible com uma sintaxe bastante padrão. Mas há um ponto. Não faz sentido reiniciar o nginx todas as vezes. Você notou que não escrevemos comandos como: “faça isso assim”, a sintaxe se parece mais com “isto deveria ter este estado”. E na maioria das vezes é exatamente assim que o ansible funciona. Se o grupo já existir ou o pacote do sistema já estiver instalado, o ansible verificará isso e pulará a tarefa. Além disso, os arquivos não serão copiados se corresponderem completamente ao que já está no servidor. Podemos tirar vantagem disso e reiniciar o nginx somente se os arquivos de configuração tiverem sido alterados. Existe uma diretiva de registro para isso:

# 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.changed

Se um dos arquivos de configuração for alterado, será feita uma cópia e a variável será registrada restart_nginx. E somente se esta variável estiver cadastrada o serviço será reiniciado.

E, claro, você precisa adicionar a função nginx ao manual principal.

Configurando o postgresql

Precisamos habilitar o postgresql usando o systemd da mesma forma que fizemos com o nginx, e também criar um usuário que usaremos para acessar o banco de dados e o próprio banco de dados.
Vamos criar uma função /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 }}"

Não vou descrever como adicionar variáveis ​​ao inventário, isso já foi feito diversas vezes, assim como a sintaxe dos módulos postgresql_db e postgresql_user. Mais informações podem ser encontradas na documentação. A directiva mais interessante aqui é become_user: postgres. O fato é que por padrão apenas o usuário postgres tem acesso ao banco de dados postgresql e apenas localmente. Esta diretiva nos permite executar comandos em nome deste usuário (se tivermos acesso, é claro).
Além disso, pode ser necessário adicionar uma linha ao pg_hba.conf para permitir que um novo usuário acesse o banco de dados. Isso pode ser feito da mesma forma que alteramos a configuração do nginx.

E, claro, você precisa adicionar a função postgresql ao manual principal.

Instalando Ruby via rbenv

Ansible não possui módulos para trabalhar com rbenv, mas é instalado clonando um repositório git. Portanto, esse problema se torna o mais fora do padrão. Vamos criar um papel para ela /ansible/roles/ruby_rbenv/main.yml e vamos começar a preencher:

# Install rbenv and ruby
- name: Install rbenv
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenv

Novamente usamos a diretiva Become_User para trabalhar com o usuário que criamos para esses fins. Como o rbenv está instalado em seu diretório inicial, e não globalmente. E também usamos o módulo git para clonar o repositório, especificando repo e dest.

Em seguida, precisamos registrar o rbenv init no bashrc e adicionar o rbenv ao PATH lá. Para isso temos o módulo lineinfile:

- name: Add rbenv to PATH
  become_user: "{{ user }}"
  lineinfile:
    path: ~/.bashrc
    state: present
    line: 'export PATH="${HOME}/.rbenv/bin:${PATH}"'

- name: Add rbenv init to bashrc
  become_user: "{{ user }}"
  lineinfile:
    path: ~/.bashrc
    state: present
    line: 'eval "$(rbenv init -)"'

Então você precisa instalar o ruby_build:

- name: Install ruby-build
  become_user: "{{ user }}"
  git: repo=https://github.com/rbenv/ruby-build.git dest=~/.rbenv/plugins/ruby-build

E finalmente instale o Ruby. Isso é feito através do rbenv, ou seja, simplesmente com o comando bash:

- name: Install ruby
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    rbenv install {{ ruby_version }}
  args:
    executable: /bin/bash

Dizemos qual comando executar e com quê. Porém, aqui nos deparamos com o fato de que o ansible não executa o código contido no bashrc antes de executar os comandos. Isso significa que o rbenv deverá ser definido diretamente no mesmo script.

O próximo problema se deve ao fato de que o comando shell não possui estado do ponto de vista ansible. Ou seja, não haverá verificação automática se esta versão do Ruby está instalada ou não. Podemos fazer isso sozinhos:

- name: Install 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

Tudo o que resta é instalar o bundler:

- name: Install bundler
  become_user: "{{ user }}"
  shell: |
    export PATH="${HOME}/.rbenv/bin:${PATH}"
    eval "$(rbenv init -)"
    gem install bundler

E novamente, adicione nossa função ruby_rbenv ao manual principal.

Arquivos compartilhados.

Em geral, a configuração pode ser concluída aqui. A seguir basta rodar o capistrano e ele mesmo copiará o código, criará os diretórios necessários e iniciará a aplicação (se tudo estiver configurado corretamente). No entanto, o capistrano geralmente requer arquivos de configuração adicionais, como database.yml ou .env Eles podem ser copiados da mesma forma que arquivos e modelos para nginx. Existe apenas uma sutileza. Antes de copiar os arquivos, você precisa criar uma estrutura de diretórios para eles, mais ou menos assim:

# Copy shared files for deploy
- name: Ensure shared dir
  become_user: "{{ user }}"
  file:
    path: "{{ app_path }}/shared/config"
    state: directory

especificamos apenas um diretório e o ansible criará automaticamente os pais, se necessário.

Cofre Ansible

Já descobrimos que variáveis ​​podem conter dados secretos como a senha do usuário. Se você criou .env arquivo para o aplicativo e database.yml então deve haver ainda mais dados críticos. Seria bom escondê-los de olhares indiscretos. Para isso é utilizado cofre ansible.

Vamos criar um arquivo para variáveis /ansible/vars/all.yml (aqui você pode criar arquivos diferentes para diferentes grupos de hosts, assim como no arquivo de inventário: production.yml, staging.yml, etc).
Todas as variáveis ​​que devem ser criptografadas devem ser transferidas para este arquivo usando a sintaxe yml padrão:

# 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_base

Depois disso, este arquivo pode ser criptografado com o comando:

ansible-vault encrypt ./vars/all.yml

Naturalmente, ao criptografar, você precisará definir uma senha para descriptografar. Você pode ver o que estará dentro do arquivo após chamar este comando.

Por meio de ansible-vault decrypt o arquivo pode ser descriptografado, modificado e criptografado novamente.

Você não precisa descriptografar o arquivo para funcionar. Você o armazena criptografado e executa o manual com o argumento --ask-vault-pass. Ansible solicitará a senha, recuperará as variáveis ​​e executará as tarefas. Todos os dados permanecerão criptografados.

O comando completo para vários grupos de hosts e cofre ansible será mais ou menos assim:

ansible-playbook -i inventory ./playbook.yml -l "staging" --ask-vault-pass

Mas não vou lhe dar o texto completo dos manuais e funções, escreva você mesmo. Porque ansible é assim: se você não entende o que precisa ser feito, ele não servirá para você.

Fonte: habr.com

Adicionar um comentário