Ansible: Parallelisierung von Tasks auf einem Host

Jetzt mit Freunden teilen

Ansible bietet keine eingebaute Möglichkeit, Aufgaben auf einem einzelnen Host parallel auszuführen. Wir zeigen einen Workaround.

Eine der Stärken von Ansible ist das einfache Parallelisieren von Aufgaben über mehrere Systeme. Über verschiedene Ausführungsstrategien, Stellschrauben wie 'forks', und Steuerungsmechanismen wie 'serial' und 'throttle' (Dokumentation) kann viel Kontrolle über die parallelen Prozesse ausgeübt werden.

Wenn viele gleichartige Tasks auf einem einzigen Host ausgeführt werden sollen – etwa API-Aufrufe zum Management einer Anwendung – stößt man schnell an Grenzen: Tasks mit Loops werden auf demselben System immer seriell abgearbeitet. Im Folgenden werden Workarounds beschrieben, um solche Abläufe zu beschleunigen.

Hintergrund

Ein Ansible-Play ist eine Liste von Tasks, die für eine gegebene Menge an Hosts abgearbeitet wird. Grundsätzlich arbeitet Ansible so, dass es den Code vom auszuführenden Modul auf das Zielsystem kopiert und mit den im Task beschriebenen Parametern ausführt. Anschließend räumt es dort wieder auf.

Mit diesem Verständnis lässt sich die Grundidee der folgenden Workarounds besser nachvollziehen: man gaukelt Ansible vor, dass es verschiedene Hosts bearbeiten soll. Das geschieht dann wie üblich parallel.

Beispielaufgabe

Ziel dieses Playbooks soll es sein, mehrere Container auf dem gleichen System zu starten. Zur Simulation einer realistischen Startdauer wird pro Schleifendurchlauf kurz pausiert.

```sh
$ cat inventory_baseline.ini
[container_hosts]
my_host_0 ansible_host=127.0.0.1

$ cat baseline.yml
- hosts: container_hosts
  gather_facts: false
  tasks:
    - ansible.builtin.debug:
        msg: "start container {{ item }} on {{ inventory_hostname }}"
      loop: "{{ range(0, 20) }}"
      loop_control:
        pause: 1

$ time ansible-playbook -i inventory_baseline.ini baseline.yml
...
TASK [ansible.builtin.debug]
ok: [my_host_0] => (item=0) => {
    "msg": "start container 0 on my_host_0"
}
...
ok: [my_host_0] => (item=19) => {
    "msg": "start container 19 on my_host_0"
}
...
real    0m20.888s
```

20 Sekunden Laufzeit sind hier also unser Vergleichswert.

Variante 1: Host mehrfach im Inventar mit statischer Verteilung

Es reicht nicht, den Zielhost mehrfach im Inventar einzutragen, da alle Tasks eines Plays pro Host ausgeführt werden – im Beispiel würden die 20 Aufrufe effektiv multipliziert werden.

Stattdessen müssen die Aufgaben quasi lastverteilt werden: wenn man bspw. 5 Hosts – genauer: 5 Duplikate des Zielhosts – hat, muss jeder 4 Container starten.

```
$ cat inventory_variante1.ini
[container_hosts]
my_host_[0:4] ansible_host=127.0.0.1

$ cat variante1.yml
- hosts: container_hosts
  gather_facts: false
  tasks:
    - ansible.builtin.debug:
        msg: "start container {{ item }} on {{ inventory_hostname }}"
      loop: "{{ range(0, 5) }}"
      loop_control:
        pause: 1

$ time ansible-playbook -i inventory_variante1.ini variante1.yml
...
TASK [ansible.builtin.debug]
ok: [my_host_0] => (item=0) => {
    "msg": "start container 0 on my_host_0"
}
ok: [my_host_1] => (item=0) => {
    "msg": "start container 0 on my_host_1"
}
...
ok: [my_host_3] => (item=4) => {
    "msg": "start container 4 on my_host_3"
}
ok: [my_host_4] => (item=4) => {
    "msg": "start container 4 on my_host_4"
}
...
real    0m4.727s
```

Wenig überraschend wurde die Laufzeit bei diesem naiven Ansatz effektiv gefünftelt. Ein offensichtlicher Nachteil: die Anzahl der Hosts muss die Gesamtzahl der Aufgaben exakt teilen.

Variante 2: Host mehrfach im Inventar mit dynamischer Verteilung

Die vorige Variante lässt sich für n Aufgaben mit etwas Ansible- und Jinja-Magie verallgemeinern:

```sh
$ cat variante2.yml
- hosts: container_hosts
  gather_facts: false
  vars:
    nr_containers_to_start: 17
    m: "{{ groups['container_hosts'] | length }}"
  tasks:
    - ansible.builtin.debug:
        msg: "start container {{ item }} on {{ inventory_hostname }} with idx {{ idx }}"
      vars:
        idx: "{{ groups['container_hosts'].index(inventory_hostname) }}"
      loop: "{{ (range(0, nr_containers_to_start + 1) | slice(m | int))[idx | int] }}"
      loop_control:
        pause: 1
```

Hier wird die Aufgabenliste in m Unterlisten aufgeteilt, wobei m die Anzahl der Hosts ist. Jeder Host verarbeitet dabei nur den ihm zugewiesenen Abschnitt der Liste, entsprechend seiner Position in der Hostgruppe. Der Parallelisierungsgrad ist also m.

 

```sh
$ cat inventory_variante2.ini
[container_hosts]
my_host_[0:24] ansible_host=127.0.0.1

$ time ansible-playbook -i inventory_variante2.ini variante2.yml
...
TASK [ansible.builtin.debug]
ok: [my_host_1] => (item=1) => {
    "msg": "start container 1 on my_host_1 with idx 1"
}
ok: [my_host_3] => (item=3) => {
    "msg": "start container 3 on my_host_3 with idx 3"
}
...
PLAY RECAP
...
my_host_16                 : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
my_host_17                 : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
my_host_18                 : ok=0    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
...
real    0m0.982s
```

Im Gegensatz zu Variante 1 lassen sich damit beliebig Aufgaben verteilen. Über m lässt sich "Last" steuern, was bei teuren API-Aufrufen o.ä. notwendig sein kann.

 

Variante 3: Dynamisch erzeugte Hosts mit dynamischer Verteilung

Die bisherige Lösung baut darauf, dass das Inventar als statisch und editierbar angenommen wird, das ist nicht immer der Fall. Durch das 'add_host'-Modul kann diese Einschränkung umgangen werden, auf Kosten der Komplexität des Playbooks:

```sh
$ cat inventory_variante3.ini
[container_hosts]
my_host_0 ansible_host=127.0.0.1

$ cat variante3.yml
- hosts: container_hosts
  gather_facts: false
  tasks:
    - ansible.builtin.add_host:
        groups: container_hosts_extended
        hostname: "copy_of_container_host_{{ item }}"
        ansible_host: "{{ ansible_host }}"
      loop: "{{ range(0, 5) }}"

- hosts: container_hosts_extended
  gather_facts: false
  vars:
    nr_containers_to_start: 17
    m: "{{ groups['container_hosts_extended'] | length }}"
  tasks:
    - ansible.builtin.debug:
        msg: "start container {{ item }} on {{ inventory_hostname }} with idx {{ idx }}"
      vars:
        idx: "{{ groups['container_hosts_extended'].index(inventory_hostname) }}"
      loop: "{{ (range(0, nr_containers_to_start + 1) | slice(m | int))[idx | int] }}"
      loop_control:
        pause: 1
```

Hier wird dynamisch eine Hostgruppe mit beliebig vielen "Kopien" des tatsächlich zu bespielenden Hosts erzeugt. Die Anzahl bzw. der Parallelisierungsgrad könnte noch variabilisiert werden. Der Rest ist analog zu Variante 2 - unter Berücksichtigung des neuen Gruppennamens.

In echten Playbooks sollte zusätzlich beachtet werden dass Variablen für 'my_host_0' nicht für die "Kopien" gelten, entsprechend also über die üblichen Mechanismen anderweitig gesetzt werden müssen.

Variante 4: Dynamisch erzeugte Hosts pro Aufgabe

Dieser Ansatz dreht die Idee auf den Kopf: statt Aufgaben auf mehrere Kopien des ausführenden Hosts zu verteilen, wird pro Aufgabe jeweils ein Pseudo-Host erzeugt. So entstehen aus Sicht von Ansible wieder mehrere Hosts, die parallel verarbeitet werden können.

```sh
$ cat inventory_variante4.ini
[container_hosts]
my_host_0 ansible_host=127.0.0.1

$ cat variante4.yml
- hosts: container_hosts
  gather_facts: false
  vars:
    containers:
      - name: foo
        image: latest
      - name: bar
        image: 0.0.42
  tasks:
    - ansible.builtin.add_host:
        hostname: "{{ item.name }}"
        groups: container_pseudohosts
        container_item: "{{ item }}"
        ansible_host: "{{ ansible_host }}"
      loop: "{{ containers }}"

- hosts: container_pseudohosts
  gather_facts: false
  tasks:
    - ansible.builtin.debug:
        msg: "start container {{ inventory_hostname }} with image {{ container_item.image }}"

$ ansible-playbook -i inventory_variante4.ini variante4.yml

PLAY [container_hosts]

TASK [ansible.builtin.add_host]
ok: [my_host_0] => (item={'name': 'foo', 'image': 'latest'})
ok: [my_host_0] => (item={'name': 'bar', 'image': '0.0.42'})

PLAY [container_pseudohosts]

TASK [ansible.builtin.debug]
ok: [foo] => {
    "msg": "start container foo with image latest"
}
ok: [bar] => {
    "msg": "start container bar with image 0.0.42"
}
```

Diese Variante ist besonders elegant, da sie ohne komplizierte Jinja-Logik auskommt und die Parallelität implizit durch die Anzahl der Einträge bestimmt wird.

Zusammenfassung

Ansible bietet keine eingebaute Möglichkeit, Aufgaben auf einem einzelnen Host parallel auszuführen. Mit einem Verständnis der Arbeitsweise können aber diverse Workarounds entwickelt werden, die das Parallelisieren ermöglichen.

Jetzt mit Freunden teilen