设置服务器以使用 Ansible 部署 Rails 应用程序

不久前,我需要编写几个 Ansible playbook 来准备部署 Rails 应用程序的服务器。 而且,令人惊讶的是,我没有找到简单的分步手册。 我不想在不了解发生了什么的情况下复制别人的剧本,最后我不得不阅读文档,自己收集所有内容。 也许我可以在本文的帮助下帮助某人加快这个过程。

首先要了解的是,ansible 为您提供了一个方便的界面,可以通过 SSH 在远程服务器上执行预定义的操作列表。 这里没有魔法,您无法安装插件并使用 docker、监控和其他开箱即用的好东西来零停机部署您的应用程序。 为了写一本剧本,你必须知道你到底想做什么以及如何做。 这就是为什么我对 GitHub 上现成的剧本或诸如“复制并运行,它会起作用”之类的文章不满意。

我们需要什么?

正如我已经说过的,为了写一本剧本,你需要知道你想做什么以及如何做。 让我们决定我们需要什么。 对于 Rails 应用程序,我们需要几个系统包:nginx、postgresql(redis 等)。 此外,我们还需要特定版本的 ruby​​。 最好通过 rbenv(rvm、asdf...)安装。 以 root 用户身份运行所有这些总是一个坏主意,因此您需要创建一个单独的用户并配置他的权限。 之后,您需要将我们的代码上传到服务器,复制 nginx、postgres 等的配置并启动所有这些服务。

结果,操作顺序如下:

  1. 以 root 身份登录
  2. 安装系统包
  3. 创建新用户,配置权限,ssh密钥
  4. 配置系统包(nginx等)并运行它们
  5. 我们在数据库中创建一个用户(可以立即创建数据库)
  6. 以新用户身份登录
  7. 安装 rbenv 和 ruby
  8. 安装捆绑器
  9. 上传应用程序代码
  10. 启动 Puma 服务器

此外,最后阶段可以使用 capistrano 完成,至少开箱即用,它可以将代码复制到发布目录中,在成功部署后使用符号链接切换版本,从共享目录复制配置,重新启动 puma 等。 所有这些都可以使用 Ansible 来完成,但为什么呢?

文件结构

Ansible 有严格的 文件结构 对于所有文件,因此最好将其全部保存在单独的目录中。 此外,它是在 Rails 应用程序本身中还是单独存在并不那么重要。 您可以将文件存储在单独的 git 存储库中。 就我个人而言,我发现在 Rails 应用程序的 /config 目录中创建一个 ansible 目录并将所有内容存储在一个存储库中是最方便的。

简单的剧本

Playbook 是一个 yml 文件,它使用特殊语法描述 Ansible 应该做什么以及如何做。 让我们创建第一个不执行任何操作的剧本:

---
- name: Simple playbook
  hosts: all

这里我们简单地说我们的剧本叫做 Simple Playbook 并且它的内容应该为所有主机执行。 我们可以将其保存在 /ansible 目录中,名称为 playbook.yml 并尝试运行:

ansible-playbook ./playbook.yml

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

Ansible 表示它不知道任何与所有列表匹配的主机。 它们必须列在特殊的 库存文件.

让我们在同一个 ansible 目录中创建它:

123.123.123.123

这就是我们简单地指定主机(最好是我们用于测试的VPS的主机,或者你可以注册localhost)并将其保存在名称下 inventory.
您可以尝试使用清单文件运行 ansible:

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

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

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

如果您可以通过 ssh 访问指定主机,那么 ansible 将连接并收集有关远程系统的信息。 (默认任务[收集事实])之后它将给出有关执行情况的简短报告(重述)。

默认情况下,连接使用您登录系统时使用的用户名。 它很可能不在主机上。 在 playbook 文件中,您可以使用remote_user 指令指定用于连接的用户。 此外,有关远程系统的信息通常对您来说可能是不必要的,您不应该浪费时间收集它。 也可以禁用此任务:

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

尝试再次运行 playbook 并确保连接正常。 (如果您指定了root用户,那么您还需要指定become: true指令才能获得提升的权限。如文档中所述: become set to ‘true’/’yes’ to activate privilege escalation. 尽管尚不完全清楚原因)。

也许你会收到由于ansible无法确定Python解释器而导致的错误,那么你可以手动指定它:

ansible_python_interpreter: /usr/bin/python3 

您可以使用以下命令找出 python 的位置 whereis python.

安装系统包

Ansible 的标准发行版包含许多用于处理各种系统包的模块,因此我们不必出于任何原因编写 bash 脚本。 现在我们需要这些模块之一来更新系统并安装系统软件包。 我的 VPS 上有 Ubuntu Linux,因此要安装我使用的软件包 apt-get и 它的模块。 如果您使用不同的操作系统,那么您可能需要不同的模块(记住,我在一开始就说过,我们需要提前知道我们将做什么以及如何做)。 然而,语法很可能是相似的。

让我们用第一个任务来补充我们的剧本:

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

任务正是 Ansible 将在远程服务器上执行的任务。 我们为任务命名,以便我们可以在日志中跟踪其执行情况。 我们使用特定模块的语法来描述它需要做什么。 在这种情况下 apt: update_cache=yes - 表示使用 apt 模块更新系统软件包。 第二个命令稍微复杂一些。 我们将包列表传递给 apt 模块并说它们是 state 应该成为 present,也就是我们说的安装这些包。 以类似的方式,我们可以告诉他们删除它们,或者通过简单地更改来更新它们 state。 请注意,为了让 Rails 能够与 postgresql 一起工作,我们需要 postgresql-contrib 包,我们现在正在安装它。 再次强调,您需要知道并做到这一点;ansible 本身无法做到这一点。

尝试再次运行 playbook 并检查软件包是否已安装。

创建新用户。

为了与用户合作,Ansible 还有一个模块 - 用户。 让我们再添加一项任务(我将剧本中已知的部分隐藏在注释后面,以免每次都完全复制):

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

我们创建一个新用户,为其设置一个 schell 和密码。 然后我们遇到了几个问题。 如果不同主机的用户名需要不同怎么办? 并且以明文形式将密码存储在剧本中是一个非常糟糕的主意。 首先,让我们将用户名和密码放入变量中,在文章末尾,我将展示如何加密密码。

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

变量在剧本中使用双花括号设置。

我们将在库存文件中指示变量的值:

123.123.123.123

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

请注意指令 [all:vars] - 它表示下一个文本块是变量(vars)并且它们适用于所有主机(all)。

设计也很有趣 "{{ user_password | password_hash('sha512') }}"。 问题是 ansible 不通过安装用户 user_add 就像你手动做的一样。 而且它直接保存了所有数据,这就是为什么我们还必须提前将密码转换为哈希值,这就是这个命令的作用。

让我们将用户添加到 sudo 组。 然而,在此之前我们需要确保这样一个组的存在,因为没有人会为我们做这件事:

---
- 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"

一切都很简单,我们还有一个用于创建组的组模块,其语法与 apt 非常相似。 然后将这个组注册给用户就足够了(groups: "sudo").
向该用户添加 ssh 密钥也很有用,这样我们就可以使用它而无需密码登录:

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

在这种情况下,设计很有趣 "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — 它复制 id_rsa.pub 文件的内容(您的名字可能不同),即 ssh 密钥的公共部分,并将其上传到服务器上用户的授权密钥列表。

用户身份

用于创建用途的所有三个任务都可以轻松地分为一组任务,最好将这组任务与主剧本分开存储,这样它就不会变得太大。 为此,Ansible 已 角色.
根据一开始所示的文件结构,角色必须放置在单独的角色目录中,每个角色都有一个单独的同名目录,里面有任务、文件、模板等目录
让我们创建一个文件结构: ./ansible/roles/user/tasks/main.yml (main 是角色连接到 playbook 时将加载并执行的主文件;其他角色文件可以连接到它)。 现在您可以将与用户相关的所有任务传输到此文件:

# 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

在主剧本中,您必须指定使用用户角色:

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

此外,在所有其他任务之前更新系统可能是有意义的;为此,您可以重命名该块 tasks 其中它们被定义在 pre_tasks.

设置 nginx

我们应该已经安装了 Nginx;我们需要配置它并运行它。 让我们立即在角色中进行操作。 让我们创建一个文件结构:

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

现在我们需要文件和模板。 它们之间的区别在于,ansible 直接按原样复制文件。 并且模板必须具有 j2 扩展名,并且它们可以使用相同的双花括号来使用变量值。

让我们启用 nginx main.yml 文件。 为此,我们有一个 systemd 模块:

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

这里我们不仅说nginx必须启动(即我们启动它),而且我们立即说它必须启用。
现在让我们复制配置文件:

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

我们创建主要的 nginx 配置文件(您可以直接从服务器获取,也可以自己编写)。 还有 site_available 目录中我们应用程序的配置文件(这不是必需的,但很有用)。 第一种情况,我们使用copy模块来复制文件(文件必须在 /ansible/roles/nginx/files/nginx.conf)。 在第二个中,我们复制模板,替换变量的值。 模板应该在 /ansible/roles/nginx/templates/my_app.j2)。 它可能看起来像这样:

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

注意插入物 {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — 这些是 Ansible 在复制之前将其值替换到模板中的所有变量。 如果您对不同的主机组使用 playbook,这非常有用。 例如,我们可以添加库存文件:

[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

如果我们现在启动我们的剧本,它将为两台主机执行指定的任务。 但同时,对于登台主机,变量将与生产主机不同,不仅在角色和剧本中,而且在 nginx 配置中也是如此。 {{ inventory_hostname }} 不需要在库存文件中指定 - 这 特殊的 ansible 变量 当前正在运行剧本的主机存储在那里。
如果您想要为多个主机创建一个清单文件,但只为一组运行,可以使用以下命令来完成:

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

另一种选择是为不同的组提供单独的清单文件。 或者,如果您有许多不同的主机,您可以结合使用这两种方法。

让我们回到设置 nginx。 复制配置文件后,我们需要在sitest_enabled 中创建一个从sites_available 到my_app.conf 的符号链接。 并重启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

这里一切都很简单——同样具有相当标准语法的ansible模块。 但有一点。 每次都重新启动 nginx 是没有意义的。 你有没有注意到,我们不会写这样的命令:“像这样做”,语法看起来更像是“这应该有这个状态”。 大多数情况下,这正是 ansible 的工作原理。 如果该组已经存在,或者系统软件包已经安装,那么 ansible 将检查这一点并跳过该任务。 此外,如果文件与服务器上已有的文件完全匹配,则不会复制文件。 我们可以利用这一点,仅在配置文件发生更改时重新启动 nginx。 为此有一个寄存器指令:

# 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

如果其中一个配置文件发生更改,则会制作一份副本并注册该变量 restart_nginx。 并且只有当这个变量被注册后,服务才会重新启动。

当然,您需要将 nginx 角色添加到主 playbook 中。

设置 postgresql

我们需要像使用 nginx 一样使用 systemd 启用 postgresql,并创建一个用于访问数据库和数据库本身的用户。
让我们创建一个角色 /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 }}"

我不会描述如何将变量添加到清单中,这已经做过很多次了,以及 postgresql_db 和 postgresql_user 模块的语法。 更多信息可以在文档中找到。 这里最有趣的指令是 become_user: postgres。 事实上,默认情况下,只有 postgres 用户有权访问 postgresql 数据库,而且只能在本地访问。 该指令允许我们代表该用户执行命令(当然,如果我们有访问权限的话)。
另外,您可能需要向 pg_hba.conf 添加一行以允许新用户访问数据库。 这可以通过与更改 nginx 配置相同的方式来完成。

当然,您需要将 postgresql 角色添加到主 playbook 中。

通过rbenv安装ruby

Ansible 没有用于使用 rbenv 的模块,但它是通过克隆 git 存储库来安装的。 因此,这个问题就成为最不规范的一个。 让我们为她创建一个角色 /ansible/roles/ruby_rbenv/main.yml 让我们开始填写:

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

我们再次使用become_user指令在我们为此目的创建的用户下工作。 由于 rbenv 安装在其主目录中,而不是全局安装。 我们还使用 git 模块来克隆存储库,指定 repo 和 dest。

接下来,我们需要在 bashrc 中注册 rbenv init 并将 rbenv 添加到 PATH 中。 为此,我们有 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 -)"'

然后你需要安装 ruby​​_build:

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

最后安装红宝石。 这是通过 rbenv 完成的,即简单地使用 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

我们说要执行哪个命令以及执行什么命令。 然而,在这里我们发现 ansible 在运行命令之前不会运行 bashrc 中包含的代码。 这意味着 rbenv 必须直接在同一脚本中定义。

下一个问题是由于从 ansible 的角度来看 shell 命令没有状态。 即不会自动检查是否安装了该版本的ruby。 我们可以自己做:

- 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

剩下的就是安装捆绑器:

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

再次将我们的角色 ruby​​_rbenv 添加到主剧本中。

共享文件。

一般来说,到这里就可以完成设置了。 接下来,剩下的就是运行 capistrano,它将复制代码本身,创建必要的目录并启动应用程序(如果一切配置正确)。 然而,capistrano经常需要额外的配置文件,例如 database.yml или .env 它们可以像 nginx 的文件和模板一样被复制。 只有一处微妙之处。 在复制文件之前,您需要为它们创建一个目录结构,如下所示:

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

我们只指定一个目录,如果需要,ansible 会自动创建父目录。

Ansible金库

我们已经认识到变量可以包含秘密数据,例如用户的密码。 如果您已经创建了 .env 申请文件,以及 database.yml 那么一定还有更多这样的关键数据。 最好将它们隐藏起来,以免被窥探。 为此目的,使用 安塞布尔保险库.

让我们为变量创建一个文件 /ansible/vars/all.yml (在这里,您可以为不同的主机组创建不同的文件,就像在库存文件中一样:生产.yml、staging.yml 等)。
所有必须加密的变量必须使用标准 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_base

之后可以使用以下命令加密该文件:

ansible-vault encrypt ./vars/all.yml

当然,在加密的时候,你需要设置一个解密的密码。 调用此命令后,您可以看到文件中的内容。

通过 ansible-vault decrypt 该文件可以被解密、修改,然后再次加密。

您无需解密文件即可工作。 您将其加密存储并使用参数运行剧本 --ask-vault-pass。 Ansible 将询问密码、检索变量并执行任务。 所有数据将保持加密状态。

多组主机和 ansibleVault 的完整命令如下所示:

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

但我不会给你剧本和角色的全文,你自己写吧。 因为 ansible 就是这样——如果你不明白需要做什么,那么它就不会为你做。

来源: habr.com

添加评论