Deploying venv based Python services with Ansible and systemd

Today I deployed my first venv based python system service on my home server. This is a short post describing how I did it.

One of the hardest problems in Computer Science is naming :) In this particular case, where to put the code. Initially I thought of something like /usr/local/lib/python<version>/site-packages/something/<app>, but frankly only because I have completely forgotten about /opt, even when I was dealing with it daily no more than 3 months ago. So /opt/<app> it is.

Next came automating all the right steps. I install everything on that server with Ansible. It's weird to write down all the steps when one's used to do them by hand. This includes:

  • Create the dir
  • Copy the files
  • Create the venv
  • Install deps
  • Install the systemd service file

The way Ansible works, you can tell it not to repeat tasks if you tell it which files it creates. The way venvs work in python, they're created on a dir that contains the major and minor python version: <venv_dir>/lib/python<version>. To avoid creating the venv on every Ansible run, I need the Python version, but just those two components. After some testing and sharing and being corrected, I got this:

python3 -c 'import sys; print(".".join(map(str, sys.version_info[0:2])))'

map(str, ...) is needed because join() expects a list of strings.

Another thing you can do with Ansible is to emit events that can make handlers (another type of tasks) run. In this case this is useful to restart the service if the code or the config changes, but also to refresh systemd if the service file changes.

This is the first time I used a feature of venvs. venvs contain in their bin directory several scripts. The most important for me so far was the bin/activate environment file. If you source it, it will set up PATHs properly so Python can find the deps within the venv. But it also creates a python3 and pip3 scripts that run both tools also with the PATHs modified. This simplifies many things when manipulating venvs with Ansible, and also systemd.

The last link is on the systemd side. I didn't really want to install everything neatly in /opt and then put the service file outside it. systmctl link exists exactly for that. It creates a link in /etc/systemd/system to the service file in /opt. It's still not 100% clean, but at least it's managed by systemd, and you can clearly see who really owns it. You run this command instead of systemd enable, and it's ready for starting it.

Here's the whole play:

- name: Apache Log Exporter  # this one has its own play because it's more complex than the rest
  hosts: servers
  tasks:
  - name: Create dirs
    file:
      path:  /opt/apache-log-exporter
      state: directory
      owner: root
      group: root
      mode:  0755
    tags: install, monitoring

  - name: Install PALE bin and conf
    template:
      src:   "roles/server/files/{{ item }}"
      dest:  "{{ item }}"
      owner: prometheus
      group: root
      mode:  0600
    loop:
      - /opt/apache-log-exporter/apache-log-exporter.py
      - /opt/apache-log-exporter/apache-log-exporter.yaml
    tags: install, config, monitoring
    notify:
      - Reload Apache Log Exporter

  - name: Install PALE service
    template:
      src:   "roles/server/files/{{ item }}"
      dest:  "{{ item }}"
      owner: prometheus
      group: root
      mode:  0644
    register: pale_service
    loop:
      - /opt/apache-log-exporter/apache-log-exporter.service
    tags: install, config, monitoring

  - name: Get Python Version
    # thanks @diji@mastodon.social
    command: python3 -c 'import sys; print(".".join(map(str, sys.version_info[0:2])))'
    register: python3_version
    tags: install, monitoring

  - name: Create PALE VEnv
    command: python3 -m venv --prompt pale --system-site-packages venv
    args:
      chdir:   /opt/apache-log-exporter
      creates: "/opt/apache-log-exporter/venv/lib/python{{ python3_version.stdout_lines[0] }}"
    tags: install, monitoring

  - name: Install PALE Deps
    command: "/opt/apache-log-exporter/venv/bin/pip3 install {{ item }}"
    args:
      creates: "/opt/apache-log-exporter/venv/lib/python{{ python3_version.stdout_lines[0] }}/site-packages/{{ item }}"
    loop:
      - apachelogs
      - prometheus_client
    tags: install, monitoring

  - name: Install PALE service
    command: systemctl link /opt/apache-log-exporter/apache-log-exporter.service
    when: pale_service.changed
    tags: install, monitoring
    # systemctl link already tells systemd about the new file, but we need to restart the service
    # notify:
    #   - Reload Systemd

  handlers:
  - name: Reload Apache Log Exporter
    service:
      name:  apache-log-exporter
      state: restarted

  - name: Reload Systemd
    command: systemctl daemon-reload

The systemd unit file also uses the venv scripts:

[Unit]
Description=Apache-Log-Exporter
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=prometheus
Group=prometheus
ExecStart=/opt/apache-log-exporter/venv/bin/python3 -u /opt/apache-log-exporter/apache-log-exporter.py --config-file /opt/apache-log-exporter/apache-log-exporter.yaml
Restart=always
RestartSec=10s
NotifyAccess=all

[Install]
WantedBy=multi-user.target