DB-유

Ansible Playbook for a Web Server

· linux, ansible, nginx, automation

The third time I set up the same nginx server by hand, I noticed that each one was slightly different: a directive here, a firewall rule there. Ansible fixes that by describing the desired end state in YAML and applying it over SSH, with nothing to install on the managed hosts beyond Python. The same playbook that builds server one builds server ten identically, and running it again against an existing host changes only what has drifted.

This playbook takes a fresh Ubuntu 20.04 host and turns it into an nginx web server with a firewall, a templated site config and a deploy user. It's written for Ansible 2.9, which is what Ubuntu 20.04 packages, so nothing here needs the newer collections layout. I install it from the distribution (sudo apt install ansible) or with pip3 install --user 'ansible<2.10'. Targets need SSH and a user that can sudo; Python 3 is already on 20.04 and that's all Ansible needs.

Project layout

mkdir -p ~/ansible/webserver/{templates,files,group_vars}
cd ~/ansible/webserver

An inventory file listing the hosts, inventory.ini:

[web]
web01 ansible_host=203.0.113.71
web02 ansible_host=203.0.113.72

[web:vars]
ansible_user=deploy
ansible_become=true
ansible_python_interpreter=/usr/bin/python3

And ansible.cfg in the same directory so you do not have to pass the inventory every time:

[defaults]
inventory = inventory.ini
host_key_checking = False
retry_files_enabled = False

Confirm connectivity:

ansible web -m ping

Each host should answer "ping": "pong".

Variables

group_vars/web.yml holds the values the playbook and templates use, so the same play can serve different sites:

site_domain: example.com
site_root: /var/www/example.com/public_html
deploy_user: deploy
nginx_worker_connections: 1024

The nginx template

Templates use Jinja2. templates/site.conf.j2:

server {
    listen 80;
    listen [::]:80;
    server_name {{ site_domain }} www.{{ site_domain }};
    root {{ site_root }};
    index index.html;

    access_log /var/log/nginx/{{ site_domain }}-access.log;
    error_log  /var/log/nginx/{{ site_domain }}-error.log;

    location / {
        try_files $uri $uri/ =404;
    }
}

The playbook

site.yml:

---
- name: Configure nginx web servers
  hosts: web
  become: true

  handlers:
    - name: reload nginx
      service:
        name: nginx
        state: reloaded

  tasks:
    - name: Install packages
      apt:
        name:
          - nginx
          - ufw
          - unattended-upgrades
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Ensure deploy user exists
      user:
        name: "{{ deploy_user }}"
        groups: sudo
        append: true
        shell: /bin/bash

    - name: Create web root
      file:
        path: "{{ site_root }}"
        state: directory
        owner: "{{ deploy_user }}"
        group: www-data
        mode: "0755"

    - name: Deploy site configuration
      template:
        src: site.conf.j2
        dest: "/etc/nginx/sites-available/{{ site_domain }}"
        owner: root
        group: root
        mode: "0644"
        validate: "nginx -t -c /etc/nginx/nginx.conf"
      notify: reload nginx

    - name: Enable site
      file:
        src: "/etc/nginx/sites-available/{{ site_domain }}"
        dest: "/etc/nginx/sites-enabled/{{ site_domain }}"
        state: link
      notify: reload nginx

    - name: Disable default site
      file:
        path: /etc/nginx/sites-enabled/default
        state: absent
      notify: reload nginx

    - name: Allow SSH and HTTP through ufw
      ufw:
        rule: allow
        name: "{{ item }}"
      loop:
        - OpenSSH
        - "Nginx Full"

    - name: Enable ufw
      ufw:
        state: enabled
        policy: deny

    - name: Ensure nginx is running and enabled
      service:
        name: nginx
        state: started
        enabled: true

Two things I like about this. The validate option on template runs nginx -t against the rendered file before it replaces the old one, so a broken template never reaches a live server. Handlers run once at the end of the play regardless of how many tasks notified them, which is why the reload is a handler rather than a task.

Dry run first, always

I never point a new playbook at a live host without --check --diff.

ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --check --diff
ansible-playbook site.yml

--check is a dry run, and --diff shows the changes it would make to files. The real run prints a recap; on the first run every task should be changed, on the second run everything should be ok and changed=0.

Idempotence check

ansible web -m shell -a "systemctl is-active nginx && ufw status | head -1"
curl -H 'Host: example.com' http://203.0.113.71/

Run the playbook a third time to confirm idempotence: the recap must show changed=0 for every host. Then break something on purpose, for example sudo rm /etc/nginx/sites-enabled/example.com on web01, rerun, and watch Ansible put it back and reload nginx.

Errors I've seen

  • Failed to connect to the host via ssh: Permission denied (publickey). Ansible is using the wrong user or key. Set ansible_user in the inventory and, if needed, ansible_ssh_private_key_file. ansible web -m ping -vvv shows the exact ssh command.
  • Missing sudo password. The deploy user needs a password for sudo. Pass -K (--ask-become-pass) or configure NOPASSWD for that user in /etc/sudoers.d/.
  • /usr/bin/python: not found. Ansible defaulted to Python 2 on a host that only has Python 3. The ansible_python_interpreter line in the inventory prevents it.
  • failed to validate: rc:1 error: nginx: [emerg] .... The template rendered an invalid config and validate caught it before deployment, which is the point. Fix the template and rerun.

Roles and ansible-vault are the next things I'd add. A flat playbook is fine for now, since a replacement web server is one command away.