Deploying Elixir releases with Ansible

22nd March 2022 from jc's blog

Elixir has been my go-to language for fun side projects for a while now. Most of the times I’m not using it, I start thinking “this would not have been a problem in Elixir”. But how do I deploy it?

As with everything else, I deploy my Elixir apps with Ansible, taking roughly the following steps:

The tricky part is building the release without overwriting the previous release, which we will get into in a moment. Let’s first look over step one:

- name: install the erlang solutions apt key
  apt_key:
    id: 434975BD900CCBE4F7EE1B1ED208507CA14F4FCA
    url: https://packages.erlang-solutions.com/debian/erlang_solutions.asc
    state: present

- name: install the erlang solutions apt repository
  apt_repository:
    repo: deb https://packages.erlang-solutions.com/debian {{ ansible_distribution_release }} contrib
    state: present

- name: install elixir and erlang dependencies
  apt:
    name:
      - elixir
      - erlang-asn1
      - erlang-base
      - erlang-crypto
      - erlang-dev  # yeccpre.hrl
      - erlang-eunit  # eunit_autoexport (prometheus)
      - erlang-inets
      - erlang-mnesia
      - erlang-parsetools
      - erlang-public-key
      - erlang-runtime-tools
      - erlang-ssl
      - erlang-syntax-tools
    state: present

Pretty standard stuff. Add Erlang application as you need them to the list of packages installed there.

Next up is cloning the source code, but here is where it gets special: I use the deploy_helper module to manage my build directory. The advantage is that I can build releases into separate directories to keep them isolated whilst maintaining dependencies and other compiled code in the same directory to keep deploy times low. I use the default deploy_helper configuration to simply create a new release directory on each deployment (see the myapp_release variable, you can combine this with your git task), which allows even easier (more human-readable) rollbacks in case things go south.

- name: set up the deployment directory
  become: true
  become_user: "{{ myapp_service_user }}"
  deploy_helper:
    release: "{{ myapp_version | default(omit) }}"
    path: "{{ app_directory }}"
    state: present

- name: checkout the source
  become: true
  become_user: "{{ myapp_service_user }}"
  git:
    dest: "{{ deploy_helper.shared_path }}/repository"
    repo: "{{ myapp_repository_url }}"
    version: "{{ myapp_version | default('master') }}"
    umask: '0077'
  notify:
    - restart myapp

So far, so good. Let’s grab dependencies, build a release, and run migrations. An effort is made here to ensure that we only grab dependencies for production, to only report “changed” when we got new dependencies, compiled code or ran migrations, and

- name: download dependencies
  become: true
  become_user: "{{ myapp_service_user }}"
  shell: umask 077 && exec /usr/bin/mix do local.hex --force, local.rebar --force, deps.get --only prod
  args:
    chdir: "{{ deploy_helper.shared_path }}/repository"
  register: myapp_command_mix_deps_get
  changed_when: "'All dependencies are up to date' not in myapp_command_mix_deps_get.stdout"
  environment:
    MIX_ENV: prod

- name: build a release
  become: true
  become_user: "{{ myapp_service_user }}"
  shell: umask 077 && exec /usr/bin/nice /usr/bin/mix release --overwrite
  args:
    chdir: "{{ deploy_helper.shared_path }}/repository"
  register: myapp_command_mix_release
  changed_when: "'Compiling' in myapp_command_mix_release.stdout"
  environment:
    MIX_ENV: prod

- name: run migrations
  become: true
  become_user: "{{ myapp_service_user }}"
  command: /usr/bin/mix ecto.migrate --all
  args:
    chdir: "{{ deploy_helper.shared_path }}/repository"
  register: myapp_command_ecto_migrate
  environment:
    MIX_ENV: prod
    PGSQL_URL: "{{ myapp_pgsql_url }}"
  changed_when: "'Migrated' in myapp_command_ecto_migrate.stdout"

- name: copy release to current release directory
  become: true
  become_user: "{{ myapp_service_user }}"
  copy:
    src: "{{ deploy_helper.shared_path }}/repository/_build/prod/rel/myapp/"
    remote_src: true
    dest: "{{ deploy_helper.new_release_path }}"

- name: finalize the release
  become: true
  become_user: "{{ myapp_service_user }}"
  deploy_helper:
    path: "{{ myapp_directory }}"
    release: "{{ deploy_helper.new_release }}"
    state: finalize

Note that I use mix release --overwrite here: after “overwriting” the previous release, the new release is copied over into its own isolated directory.

The rest of my deployment role performs nothing special - it configures environment variables for the app, sets up the systemd service to run it, and starts & enables it (note handlers will restart it). If you want to read the full thing, you can check it out on my GitHub.

Keep on BEAMing!

reply via email