services

Ansible

Make it so

Manchmal merkt man es gar nicht.

Am Anfang braucht man irgendwo irgendeine Linuxumgebung, um ein paar Dienste zu betreiben und dann noch einen und noch einen und dann reicht eine Instanz pro Dienst nicht mehr aus und dann sind es plötzlich zwei oder drei und man braucht ein Monitoring und dann ist es passiert. Man hat den einen Service irgendwo vergessen, der nachts sang und klaglos abgeschmiert ist oder schlimmer, jemand findet eine Sicherheitslücke oder Kunden wundern sich, weil ihre Dienste nicht mehr erreichbar sind.

Es geht so schnell. Technische Infrastrukturen haben die Angewohnheit schnell richtig komplex zu werden. Selbst ein einfaches Szenario mit einem Root-Server und ein paar VMs oder Docker Diensten darauf und schwubs ist die Firewall mal nicht richtig konfiguriert, wenn man IPv6 nutzen will. Man ist nur noch am Switchen zwischen den Konsolen, testet eine Konfiguration hier und überträgt sie auf die anderen Umgebungen und zwei Tage später weiß man nicht mehr genau, was man gemacht hat.

Kommt das bekannt vor? Mir ja - ich hab das bestimmt schon dutzende Male erlebt und jedesmal sag ich mir - beim nächsten Mal baue ich gleich einen Script. Oder besser noch - ich nutze irgendein Tool, dass das für mich macht.

Und dann kam Ansible.

Es gibt noch einige andere Alternativen zu Ansible - ich will hier aber keinen Vergleich machen, denn auch die haben ihre Daseinsberechtigung und am Ende belebt Konkurrenz das Geschäft - oder zumindest profitieren alle Tools von guten Ideen der anderen Tools. Ich hab mich für Ansible entschieden, weil es im Alphabet ganz vorne steht. Dagegen kann man nichts sagen.

Ich nutze Ansible, weil man absolut dezentral arbeiten kann. Es braucht keine zentrale Kommandozentrale (auch wenn es sie gibt), man braucht nur einen Python-fähigen Rechner. Und das wars.

Nun ist Python sicher nicht die Beste aller Programmiersprachen. Das Deisngkonzept mit festen Einrückungen erhöht die Lesbarkeit - aber auch die Fehleranfälligkeit. Zudem ist der unglückliche Bruch zwischen Python 2 und Python 3 eine Herausforderung. Diese zu meistern, wird in den kommenden 1-2 Jahren sicher sehr interessant, denn Python 2 wird 2019 eingestellt. Und man glaubt gar nicht, wie viele Scripte für Ansible dann doch nicht wirklich Python3 kompatibel sind, obwohl sie es sein sollten. Die Reihe von Warnings im Laufe meiner Tests mit fremden Rollen sagt mir aber, dass da noch einiges an Arbeit zu erledigen ist und Python2 noch lange gebraucht wird.

Aber wir sind schon sehr tief in den Details. Was ist Ansible. Ansible ist eigentlich nur eine Hilfe zur Selbsthilfe. Es ist ein Sammelsorium von Tools für die Verwaltung von Unix-basierten Rechnern. Ich beschränke mich jetzt hier konkret auf Debian - aber es läuft eigentlich überall und man kann auch ziemlich gut Scripte für alle Umgebungen bauen.

Dabei bietet Ansible ein kleines Universum von Tools zur Verwaltung fast aller Dienste, die es so gibt und wenn doch nicht, kann man sie recht einfach selber bauen und erweitern. Das Prinzip ist grundsätzlich einfach zu beschreiben. Die meisten Arbeiten bei der Verwaltung von Systemen besteht darin, Programme mit bestimmten Parametern zu starten oder zu beenden und Konfigurationsdateien aller Art verändern oder zu verschieben. Das macht man jeden Tag und hier helfen Tools wie Ansible. Das Problem ist nämlich, wenn man es selber macht, dass man es idempotent macht - sprich, wenn man ein Programm gestartet hat und es läuft, muss man es nicht noch mal starten. Hat man eine Datei verändert, muss man die Datei nicht noch mal ändern. Die Lösung für diese Herausforderung unterstützt Ansible sehr gut - es gibt aber immer noch Restarbeiten, die von einem selber gelöst werden müssen.

Installation

Ansible kann man eigentlich überall installieren. Die Rechner, die damit verwaltet werden sollen, brauchen keine spezielle Installation. Aber irgendwo muss man es ausführen. Und das ist in erster Linie der eigene Rechner oder je nach Sicherheitsanforderung ein speziell preparierter Server. Dazu später mehr.

Wir sind ja alle cool, deshalb bauen wir im folgenden Beispiel auf MacOS. Ist aber eigentlich egal - denn wir nutzen einen Terminal und einen Editor nicht viel mehr.

curl https://bootstrap.pypa.io/get-pip.py | sudo python3
sudo pip3 install ansible netaddr passlib

Die erste Zeile installiert via Python3 den Python Paketmanager pip. Die zweite Zeile installiert via pip dann die Python Pakete von Ansible und ein paar Hilfsbibliotheken (dazu später mehr). In Zukunft wird es vermutlich gar kein Python mehr standardmäßig auf MacOS geben, zumindest hat Apple das angekündigt, aber bis dahin schließen andere Paketmanager wie homebrew die Lücke.

Anschließend müsste durch Eingabe des Befehls

ansible --version

eine Ausgabe folgender Art erfolgen

ansible 2.8.0
  config file = None
  configured module search path = ['/Users/me/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/local/lib/python3.7/site-packages/ansible
  executable location = /usr/local/bin/ansible
  python version = 3.7.3 (default, Mar 27 2019, 09:23:15) [Clang 10.0.1 (clang-1001.0.46.3)]

Konfiguration

Das war bisher sehr einfach. Jetzt beginnt der Spaß. Bevor wir richtig in die Vollen gehen, eine kleine Randnotiz, wie Ansible arbeitet.

Ansible arbeitet Datei-basiert. Es erwartet 1-n Dateien an ganz konkreten Plätzen. Diese Plätze kann man ändern oder konkret benennen - denn die Defaultwerte sind eher unüblich - ich verstehe zum Beispiel nicht, warum man per Default als nicht priviligierter User im Verzeichns /etc/ Dateien ablegen sollte, die dann der User-space-Prozess nutzt und vor allem Dateien wie /etc/hosts dafür mißbrauchen sollte, Ansible zu konfigurieren. Aber dafür gibt es Best-Practice Beispiele.

Also - wenn man Ansible startet - und das tut man mit einigen Terminalbefehlen, dann liest Ansible mindestens eine Datei (das Inventory) und mindestens eine Aufgabendatei (Playbook). Im Inventory steht, welche Rechner für Ansible relevant sind und wie es diese erreicht. Im Playbook stehen die eingangs erwähnten Befehle, die man auf allen gleichartigen Rechnern ausführen will. Ansible loggt sich dafür in jeden Rechner via SSH ein und führt auf diesem dann den Befehl aus. Das ist sehr einfach ausgedrückt, aber entspricht in etwa dem, was man zu erwarten hat.

Nun wäre es einfach, selber einen Shellscript zu schreiben und ihn via SSH einfach auf allen Remote-Rechnerns auszuführen - aber Ansible kann mehr. Führt man den Script nämlich einmal aus - wird alles im Script wie erwartet abgearbeitet. Interessant wird die zweite Ausführung. Denn Ansible ist so gebaut, dass man einen bestehenden Script immer mehr erweitert und nicht jedesmal einen neuen baut - die idempotente Arbeitsweise verhindert, dass Dinge mehrfach ausgeführt werden, die lieber nicht mehrfach ausgeführt werden sollen. Auf diese Weise hat man einen Ort, an dem man schauen muss und an den man Änderungen machen muss und damit beginnt es einfach und kompliziert zugleich zu werden.

Einfach - weil halt nur ein Playbook für alles - kompliziert, weil ein Webserver und ein Datenbankserver halt andere Pakete brauchen. Man muss sie trennen und dafür nutzt man das Inventory File. Dort definiert man die Rechner (Hosts) und teilt sie in Gruppen ein. Diese Gruppeninformation nutzt man im Playbook, um bestimmte Rollen auf bestimmte Gruppen anzuwenden.

Kommen wir zurück zur Konfiguration. Ein einfaches Beispiel für eine Inventory Datei ist:

server1 ansible_host=1.2.4.8  ansible_port=22 ansible_user=root
server2 ansible_host=1.2.4.16 ansible_port=22 ansible_user=root

[web]
server1

[db]
server2

[ssh]
server1
server2

Dies ist eine Inventory Datei im INI-Format. Für kleine Umgebungen empfehle ich dieses Format, weil man schnell einen Überblick bekommt und schnell Sachen ändern kann. Wenn die Inventory Datei säter mehr Informationen enthält, dann macht es Sinn, ein YAML Format zu wählen, ist die Umgebung hochgradig variable (Cloud-Maschinen) macht es Sinn sich über dynamische Inventories Gedanken zu machen.

Ein YAML Format für das Inventory ist dann empfehlenswert, wenn man sich langsam im Übergang von einfachen in komplexere Umgebungen befindet und diverse Variablen pro Host oder Gruppe konfigurieren will.

all:
  hosts:
    server1:
      ansible_host: 1.2.4.8
    server2:
      ansible_host: 1.2.4.16
  vars:
    ansible_port: 22
    ansible_user: root
  children:
    web:
      hosts:
        server1:
    db:
      hosts:
        server2:
      vars:
        customer_variable: value
    ssh:
      hosts:
        server1:
        server2:

Ist auch hübsch. Der einzige Unterschied zur obigen INI Datei ist die Variable customer_variable, die man im Fall des INI Formats in einer bestimmten Datei ablegt und Ansible diese importiert. Das ist am Anfang aber jetzt zu kompliziert.

Ein Playbook kann so aussehen.

- name: Install Docker on VMs
  hosts: vms
  roles:
    - common
    - docker

Hier wird im YAML Format geschrieben. Das ist die übliche Sprache in Ansible, um Playbooks zu schreiben. Konkret wird einer Gruppe vms die Rollen common und docker zugewiesen. Die in diesen Rollen abgelegten Befehle werden damit ausgeführt. (man sieht schon - man kann das hinreichend komplex verschachteln). Alles mit einem Bindestrich ist ein Task. Ihnen gibt man mit name einen Namen. Dann grenzt man mit hosts die Gruppe ein. Mit roles werden über Unterpunkte (Bindestriche ist hier eine Liste). Man kann das sehr gut mit Markdown Listen vergleichen.

An dieser Stelle eine weitere Einschränkung zur Aussage, dass Ansible keine Bedingungen an die Umgebung hat. Man muss zwar keinen zusätzlichen Agent auf den Rechnern laufen lassen - und sich damit weiter herumschlagen, wie man den Port dicht macht und das updatet. Aber man braucht zwingend SSH und damit Ansible richtig rockt - muss auf allen Rechnern Python installiert sein. Dies tue ich im Bootstrap in einer ROlle namens common immer mit.

- name: Bootstrap all hosts
  hosts: all
  gather_facts: no
  pre_tasks:
    - name: install python 3
      raw: test -e {{ ansible_python_interpreter }} || (apt -y update && apt install -y python3 python3-minimal python3-pip python3-dev python3-apt)
      register: output
      changed_when: output.stdout != ""
    - setup: # aka gather facts
  roles:
    - common
    - geerlingguy.ntp

In diesem Task kommen mehrere Dinge zusammen. Er gilt für alle Hosts. gather_facts: no weißt Ansible an, beim Aufbau der SSH Verbindung keine Informationen über den Zielhost zu sammeln. Normalerweise tut Ansible dies automatisch bei jeder Ausführung. Und nutzt dafür Python auf dem Zielhost - nur, wenn es nicht da ist, kommt es zu einem Fehler - also keine Fakten sammeln.

Dann wird vor allem anderen ein pre_task ausgeführt. Und zwar die Installation von Python. Da wir gleich up2date sein wollen, installieren wir gleich Python3. Hier kommt dann wieder die Eigenschaft von Ansible zum Tragen. Ansible möchte Idempotent sein. Das bedeutet leihenhaft - wenn ein Zustand bereits erreicht ist, muss man nicht noch mal alles machen, der Zustand ist erreicht. Dafür dient changed_when. Das register speichert die Ausgabe des Installationsprozesses. Am Ende des Pretasks wird dann setup ausgeführt. Das ist dann nichts anderes als gather_facts: yes. Man kann das alles weglassen - muss dann aber immer daran denken, wenn man einen neuen Rechner anlegt, dass dieser Python braucht - das ist wieder handische Arbeit - und das wollen ja vermeiden.

Das obige Beispiel mit den Rollen ist jetzt wenig hilfreich. Würde man das ausführen, klappt es nicht, weil in dem Szenario mit zwei Dateien, kennt Ansible keine Rolle common oder docker oder geerlingguy.ntp. Ein Standalone Playbook wäre also eher sowas:

- name: Bootstrap all hosts
  hosts: all
  gather_facts: no
  pre_tasks:
    - name: install python 3
      raw: test -e {{ ansible_python_interpreter }} || (apt -y update && apt install -y python3 python3-minimal python3-pip python3-dev python3-apt)
      register: output
      changed_when: output.stdout != ""
    - setup: # aka gather facts
- name: Update System
  apt:
    upgrade: dist 
    update-cache: yes
  tags:
    - common
    - basepackages-install

- name: Clean Apt Packages on System
  apt:
    autoremove: yes
    autoclean: yes
  tags:
    - common

In diese Beispiel connected sich Ansible zu allen Hosts, führt keine Sammlung der System-Eigenschaften aus (gather_facts: no) und installiert Python3 in einem sogenanten Pretask (vor allen anderen). Anschließend macht es ein Upgrade des Systems und entfernt automatisch alle Pakete, die nicht mehr gebraucht werden. Das kann man immer wieder ausführen und hätte ein Script zum regelmäßigen Update des Systems.

Idealerweise könnte man Prüfen, ob ein Kernelupdate dabei war und automatisch Neustarten:

- name: Check for kernel update
  collect_kernel_info:
    lookup_packages: false
  register: kernel_update

- block:
  - name: Reboot for kernel update
    shell: "sleep 5 && shutdown -r now 'Kernel update detected by Ansible'"
    async: 1
    poll: 0

  - name: Wait for server to come back online
    wait_for_connection:
      delay: 60
  when: "kernel_update.new_kernel_exists"

- name: Collect kernel package information
  collect_kernel_info:
  register: kernel

- name: Remove old Debian/PVE kernels
  apt:
    name: "{{ ['linux-image-amd64'] + kernel.old_packages }}"
    state: absent
    purge: yes

Das fügt man ans Ende des Playbooks zusammen. Man sieht schnell, es gibt viele Wege. Relevant ist immer folgende Struktur.

Es gibt ein Playbook als Startpunkt. Dieses enthält Tasks und/oder verweißt auf Rollen. Diese Tasks und oder Rollen werden auf Gruppen von Hosts angewandt. Rollen können wieder verwendet werden. Tasks in Playbooks sind eher spezifisch für ein Playbook. Rollen nutzt man dann gerne, wenn man mehrere Playbooks hat und die in Teilen immer das gleiche ausführen sollen (commons halt). Diese Rollen sind immer lokal auf die eigene Umgebung zu betrachten.

Es gibt darüber hinaus noch Ansible Galaxy. Hier werden Rollen abgelegt von Entwicklern, die denken, dass die gebaute Rolle auch für andere interessant sein kann. Und es lohnt sich hier ein Blick. Entweder um Teile zu nutzen, zu lernen oder um sich die Arbeit zu erleichtern.

Und darüber hinaus gibt es in Ansible selber viele hundert Scripte, die zu Befehlen zusammengeführt werden, um komplexe Aufgaben nicht immer nur über command: oder shell: auszuführen (denk an die Idempotenz, die Mehrfachausführung). In dem Moment hilft ein Blick in die Liste der Befehle von Ansible selbst. Von Dateien teilweise ersetzen über Docker Verwaltung, Kernel und Packagemanager bis hin zu VM Verwaltung und Partitionierung ist wirklich alles irgendwo in irgendeinem Ansible Befehl zu finden.

Dynamisches Inventory

Spätestens wenn man mit Ansible Scripten in virtuellen Umgebungen anfängt, neue Instanzen von Hosts oder VMs anzulegen, braucht man ein dynamisches Inventory. Ich habe mir eine Datenbank gebaut, die alle Informationen über mein “Inventar” enthält. Lege ich einen neuen Server an, versuche ich mir vor dem ersten Start genug Informationen zusammenzusammeln, um die Maschine mit Ansible zu erreichen. Bei Cloud-Diensten bekommt man die IP und Zugangsdaten per API. Bei VMs berechnet man die nächste freie IP aus einem Pool etc.

Die Daten in der Datenbank werden von einem Script zusammen gesammelt und in JSON Form ausgegeben, so wie Ansible es braucht. Das Inventory ist passwortgeschützt hinter einer URL zu erreichen. Für die CLI Umgebung von Ansible genügt dann ein Script (Bash oder Python), der die Daten der URL lädt und ausgibt. Damit man es in Ansible AWX benutzen kann, braucht man ein paar Sachen mehr.

Custom Credential Type anlegen

In AWX unter Administration legt man einen neuen Credential Type an. Das ist zwar auch nur Benutzername+Passwort, aber der Script erwartet diese Zugangsdaten später in Umgebungsvariablen. Wir geben dem Type einen Namen und müssen die beiden YAML/JSON Felder mit Werten füllen.

Input Configuration

Hier wird die Maske definiert für die Eingabefelder, die im AWX angezeigt werden, welchen Typ sie haben und wie sie heißen. Im Beispiel sind es Username+Password. In einem richtig guten Szenario könnte man noch die URL hinzufügen. Da die bei mir statisch im Script steht, kann man das erweitern - muss man aber nicht.

fields:
  - id: username
    type: string
    label: Username
  - id: password
    type: string
    label: Password
    secret: true
required:
  - username
  - password
Injector Configuration

Die Injektor Konfiguration ist dann die Ausgabeseite. Wir brauchen Umgebungsvariablen, deswegen geben wir sie dort mit einem konkreten Namen aus.

env:
  INVENTORY_CREDENTIALS: '{{ username }}:{{ password }}'
  INVENTORY_PASSWORD: '{{ password }}'
  INVENTORY_USERNAME: '{{ username }}'

Custom Credential anlegen

Dann legt man unter Resources/Credentials einen neuen Credentials Eintrag an.

Custom Script anlegen

Und legt den folgenden Script unter Resources/Inventory Scripts ab. Diesen kann man dann später unter Resources/Inventories bei einem Inventar als Quelle angeben - zusammen mit den oben angegebenen Credentials.

#!/usr/bin/env python

import os
import sys
import argparse
import json
import requests

class AcobyInventory(object):

  def __init__(self):
    self.inventory = {}
    self.read_cli_args()

    # Called with `--list`.
    if self.args.list:
      self.inventory = self.acoby_inventory()

    # Called with `--host [hostname]`.
    elif self.args.host:
      # Not implemented, since we return _meta info `--list`.
      self.inventory = self.host_inventory()

    # If no groups or vars are present, return an empty inventory.
    else:
      self.inventory = self.empty_inventory()
        
    print(json.dumps(self.inventory, indent=2, sort_keys=True))

  def acoby_inventory(self):
    try:
      url = 'https://server/v1/inventory'
      headers = {"Accept": 'application/json'}
      
      if "INVENTORY_USERNAME" in os.environ:
        username = os.environ.get('INVENTORY_USERNAME')
      else:
        return self.empty_inventory()

      if "INVENTORY_PASSWORD" in os.environ:
        password = os.environ.get('INVENTORY_PASSWORD')
      else:
        return self.empty_inventory()
      
      result = requests.get(url, auth=requests.auth.HTTPBasicAuth(username,password))
      return json.loads(result.text)        
    except:
      return {}      

  # Host inventory for testing.
  def host_inventory(self, hostname):
    return {
      '_meta': {
        'hostvars': {}
      }
    }

  # Empty inventory for testing.
  def empty_inventory(self):
    return {
      '_meta': {
        'hostvars': {}
      }
    }

  # Read the command line args passed to the script.
  def read_cli_args(self):
    parser = argparse.ArgumentParser()
    parser.add_argument('--list', action = 'store_true')
    parser.add_argument('--host', action = 'store')
    self.args = parser.parse_args()

# Get the inventory.
AcobyInventory()

Benutzung des Repositories

Installation auf MacOS

curl https://bootstrap.pypa.io/get-pip.py | sudo python3
sudo pip3 install ansible netaddr passlib
ansible-galaxy install -r roles/requirements.yml --force

Beim Einfügen eines neuen Hosts muss der Bootstrapprozess dort ausgeführt werden.

ansible-playbook -i inventory bootstrap.yml -e 'ansible_port=22' --limit <host>

Dadurch wird die Maschine für Ansible vorbereitet und die common Role installiert.

Im folgenden kann man dann auch wieder das gesamte Playbook ausführen. Da wir darin Passwörter ablegen, brauchen wir ein Vault-Kennwort. Dieses liegt im Keystore und nicht im Repository.

ansible-playbook -i inventory --vault-password-file vault.pass site.yml 

Man kann sich ein paar gesammelte Details mit dem Setup Modul anschauen

ansible -i inventory -m setup <host>

Ansonsten hier mal eine Auswahl von Playbooks.

ansible-playbook -i inventory --vault-password-file vault.pass vms.yml 
ansible-playbook -i inventory --vault-password-file vault.pass hosts.yml 
ansible-playbook -i inventory --vault-password-file vault.pass reverseproxies.yml 

Hinzufügen einer neuen VM geht z.B. mit

ansible-playbook -i inventory --vault-password-file vault.pass create_proxmox_kvm.yml -e "pve_node=proxmox pve_guest=vm pve_guest_ipv4=ipv4 pve_guest_ipv6=ipv6" --diff

Linksammlung

Hier finden sich einige nützliche und interessante Links an, die sich im Laufe der Recherche angesammelt haben.

Best Pracices

Inventory

Ansible-Vault

Wir müssen im Inventory-File Passwörter hinterlegen und die sollen verschlüsselt sein. Diese kann Ansible transparent entschlüsseln. Das Erzeugen eines Schlüssels erfolgt mit:

ansible-vault encrypt_string --vault-password-file vault.pass 'password' --name 'the.secret'

Das Ergebnis wird auf dem Bildschirm geschrieben und kann so in die Passwortdatei überführt werden. Da das ein ziemlich langer Text ist, kann man den wie folgt im JSON Format kürzen:

"the.password": {
  "__ansible_vault": "$ANSIBLE_VAULT;1.1;AES256\n36663736376262....3730\n"
},

Linksammlung: