Automate everything!

Bei Puzzle setzen wir in vielen Bereichen auf Automatisierung. In einer Blogserie erfahren wir in den nächsten Wochen von unserem Systemengineering-Team, was man alles automatisieren könnte und wie unsere Sysler dabei vorgehen. Hier folgt der erste Teil.

Roboter, der für Automatisierung steht

Was in der Softwareentwicklung schon seit Jahren gepredigt, jedoch immer noch viel zu wenig gelebt wird, haben wir uns bei /sys zu Herzen genommen: Möglichst alle unsere Infrastrukturkomponenten werden im Code beschrieben (Infrastructure as Code). Wir haben uns darauf geeinigt, dass alle Änderungen an unserer produktiven Infrastruktur vor dem Rollout nach dem Vier-Augenprinzip reviewed werden. Und wir haben Pipelines geschrieben, die uns so schnell wie möglich Feedback über die Qualität der Änderungen informiert. Bei uns herrscht eine Fehlerkultur, in der Fehler erwünscht sind. Denn daraus können wir bei Retros und Dailies lernen und auch diejenigen vom Team informieren, die gerade nicht involviert waren. Damit haben wir eine Arbeitsmethode gefunden, mit der wir in kurzer Zeit und höchster Qualität komplexe Änderungen und Erneuerungen in unsere Produktion bringen.

Um unsere Infrastruktur als Code abzulegen, verwenden wir primär Ansible. Aber wir nutzen auch andere Software wie octoDNS und Renovate Bot. Zum Teil haben wir auch eigene Skripte und Codeschnipsel entwickelt. All diese Tools, die uns in unserer täglichen Arbeit unterstützen, werden wir in dieser Blogserie beschreiben.

Ansible

Als wir 2019 vom eigenen Rack im RZ in die Cloud migrierten, vollzogen wir auch gleich den Switch von Puppet zu Ansible. Dies brachte den grossen Vorteil, alle unsere Deployments neu zu hinterfragen und zu überarbeiten. Seither haben wir ca. 120 Rollen für verschiedenste Technologien geschrieben, die wir bei Puzzle und für unsere Kund*innen einsetzen.

Aktuell nutzen wir ein Monorepo, das sowohl alle Variablen als auch die Rollen beinhaltet. Dies vereinfacht den Arbeitsalltag in diesem Repo. Gleichzeitig hat es aber auch den grossen Nachteil, dass wir Rollen teilweise doppelt pflegen müssen, wenn wir sie in einem anderen Repo verwenden. Ob und wann wir von einem Monorepo zu Rollen-Repos wechseln und dann evtl. sogar gewisse Rollen opensourcen können, ist im Moment noch intern in Abklärung.

Ordnerstruktur

Unser Aktuelles Setup sieht wie folgt aus:

.
├── ansible.cfg
├── ansible-navigator.yml
├── Dockerfile
├── docs
│   ├── ansible-puzzle-conventions.md
│   ├── [...]
├── inventories
│  ├── [..]
│  ├── be3_production
│  ├── cloud_cleaning
│  ├── cloud_production
│  ├── cloud_rhcos_production
│  ├── cloud_staging
│  ├── customer_production
│  ├── [..]
│  ├── group_vars
│  │  ├── all
│  │  ├── example
│  │  ├── [..]
│  ├── host_vars
│  │  ├──example01.cloud-staging.puzzle.ch.yml
│  │  ├── [..]
│  ├── [..]
├── Pipfile
├── Pipfile.lock
├── plays
│  ├── example.yml
│  ├── [...]
│  ├── site.yml
│  ├── [...]
│  ├── wartungsfenster
│  │  ├── ansible.cfg
│  │  ├── README.md
│  │  ├── update.sh
│  │  ├── wf-updater.retry
│  │  ├── wf-updater.yml
│  │  ├── win_updates.retry
│  │  └── win_updates.yml
│  ├── [...]
├── README.md
├── renovate.json
├── requirements.yml
└── roles
│  ├── base
│  ├── [...]

ansible.cfg

In unserer ansible.cfg geben wir für die collections_paths und roles_path zusätzlich noch einen Pfad im /opt an. Mit diesem installieren wir die Collections (und evtl. in Zukunft auch Rollen) in unserem Docker Image.

ansible-navigator.yml

Die ansible-navigator.yml ermöglicht unseren Engineers, Ansible via ansible-navigator mit dem selben Image auszuführen wie unsere Gitlab-Pipeline. Auch hier ist der Inhalt nichts Aussergewöhnliches:

ansible-navigator:
  ansible-lint:
    config: .ansible-lint
  color:
    enable: True
  execution-environment:
    container-engine: podman
    image: registry.puzzle.ch/puzzle/ansible:7.1.1
    pull:
      policy: missing

Dockerfile

FROM registry.puzzle.ch/docker.io/python:3.9-slim-bullseye

RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends openssh-client sshpass rsync curl git wget jq build-essential libldap2-dev libsasl2-dev libcurl4-openssl-dev libssl-dev libxml2-dev && \
  pip install pipenv

COPY Pipfile .

RUN pipenv lock && pipenv install --system

# remove python-ldap build dependencies
RUN apt-get purge -y build-essential libldap2-dev libsasl2-dev

# install ansible galaxy and role dependencies
COPY requirements.yml .
RUN mkdir -p /opt/puzzle/ansible/roles && \
    mkdir -p /opt/puzzle/ansible/collections && \
    ansible-galaxy role install -r requirements.yml -p /opt/puzzle/ansible/roles && \
    ansible-galaxy collection install -r requirements.yml -p /opt/puzzle/ansible/collections

CMD ["/bin/sh"]

Das Dockerfile, resp. das daraus resultierende Image, wird vor allem von unserer Gitlab-Ci Pipeline verwendet. Aber auch unsere Engineers nutzen es, wenn sie Ansible via ansible-navigator starten. Momentan verwenden wir ein Python 3.9 Image, da gewisse Collections neuere Python-Versionen noch nicht offiziell unterstützen (zum Beispiel ovirt.ovirt). In einem ersten Schritt werden die Abhängigkeiten für Ansible und die verwendeten Ansible-Collections installiert sowie pipenv. Anschliessend werden das Pipfile und das requirements.yml in den Container kopiert und die Pythondependencies sowie die Ansible-Collections installiert.

Docs

Zusätzlich zum Readme haben wir in unserem Ansible-Repo ein paar weitere Dokumentationen hinterlegt. Diese beinhalten unter anderem unsere Ansible-Convetions, Git-Conventions, Prometheus-Runbooks und Disasterrecovery-Anleitungen, die auch alle Handynummern wichtiger Personen für den Disasterfall beinhalten. Damit kann jeder Engineer mit seinem lokal ausgecheckten Ansiblerepo den Disasterrecovery-Prozess unabhängig von Puzzle-Systemen starten.

Ansible-Conventions

In den Ansible-Conventions halten wir fest, wie wir Ansible bei uns im /sys-Team einsetzen. Diese Conventions sind aus Diskussionen rund um die Einführung von Ansible entstanden und beschreiben unseren gemeinsamen Nenner. Es sind generelle Themen wie beispielsweise, dass jeder Change auf der Produktionsumgebung zuerst in der Staging-Umgebung getestet werden muss oder dass ein Merge Request immer von jemandem reviewed wird. Die Conventions beinhalten auch eher spezifische Themen, wie beispielsweise, dass wir in unserem Repo keine leeren vars, defaults handlers or meta Ordner oder Boilerplate meta/main.yml anlegen. Eine Auswahl weiterer Conventions:

  • Alle YAML-Files haben eine .yml Fileextension
  • Alle INI-Files haben keine Fileextension
  • Pro Environement gibt es ein Inventar, welches (falls statisch) im INI-Format geschrieben ist
  • Alle Listen sind alphanumerisch sortiert
  • Gruppennamen haben keine Versionsnummern
  • Alle Plays, die regelmässig auf unsere Infra angewendet werden sollen, sind im plays/site.yml importiert worden und sich im selbigen Ordner befinden
  • Alle Plays sollten auf eine Hostgruppe angewendet werden und nicht auf die Gruppe all damit sich auch Hosts im Inventory befinden können, welche nicht oder nur zu Teilen von Ansible Managed sind oder ihre Gruppenvariablen über ein anderes Inventar bekommen. (Bsp.: Unser Backupserver ist in allen Inventaren eingetragen, damit delegate_to Tasks in der Backup-Client Rolle ausgeführt werden können, effektiv wird er aber nur von einem Inventory selber aufgesetzt und konfiguriert)
  • Alle Rollen werden nach der Software die sie konfigurieren benannt und befinden sich im Ordner roles
  • Die Rollennamen müssen in Kleinbuchstaben und snake_case gehalten werden
  • Wir wollen Grundsätzlich keine Rollen von Ansible Galaxy oder Github verwenden, sondern Rollen so einfach wie möglich und so kompliziert wie nötig für unseren Use-Case halten. Externe Quellen sollen beim Schreiben als Inspirationsquellen dienen. Aber man übernimmt nur, was man wirklich braucht
  • Alle Rollen müssen Idempotent geschrieben werden
  • yaml muss valide sein (dafür haben wir unterdessen auch einen Linter in der Pipeline)
  • Wir verwenden kein Baby-JSON
  • Variablen werden mit dem Rollennamen prefixed
  • Alle Variablen sind im defaults/main.yml aufgeführt
  • Alle Secrets befinden sich im Hashicorp Vault
  • Alle Secrets die den Hashicorp Vault und die Firewall erstellen sind im Ansible-Vault
  • Wir encrypten nur strings mir Ansible-Vault und keine Files.

renovate.json

Die nächste Konfigurationsdatei, welche ich hier genauer beschreiben möchte, ist die Renovate Konfiguration. Wer Renovate noch nicht kennt, sollte sich den Bot mal anschauen. Renovate hilft uns, unsere Abhängigkeiten aktuell zu halten und macht dies auf die für uns angenehmste Art und Weise via Mergerequest gleich dort, wo wir dies benötigen. Renovate erkennt bereits viele Dinge automatisch. So benötigen wir beispielsweise für unser Dockerfile, Pipfile oder auch requirments.yml mit den Ansible Collections keine spezielle Konfiguration und erhalten die Updates gleich direkt. Da wir aber auch Updates für Software, die wir via Ansible auf den Systemen deployen (aber nicht aus RPM-Repos kommen), erhalten möchten, mussten wir dies Renovate zuerst noch beibringen. Da unsere Renovate-Config ca. 200 Zeilen lang ist, werde ich hier nur Ausschnitte davon rauspicken:

{
  "$schema":"https://docs.renovatebot.com/renovate-schema.json",
  "labels":[
    "renovate"
  ],

Als Erstes definieren wir das $schema damit Renovate x-verschiedene Dependencie-Files selber erkennt und diese aktualisiert. Als Nächstes definieren wir ein Label, damit alle von Renovate erstellten Mergerequests gelabled werden. Dies hilft unserem Tagesverantwortlichen, solche Mergerequests zu erkennen.

  "branchConcurrentLimit":0,
  "prHourlyLimit":0,
  "prConcurrentLimit":0,
  "schedule":[
    "on the 20th through 26th day of the month on Wed"
  ],

Anschliessend setzen wir ein paar Limits auf 0 (und somit unendlich), da wir alle Updates reinbekommen wollen und es uns egal ist, wenn Renovate in einer Stunde x Mergerequests öffnet. Mit dem schedule definieren wir jedoch für unser Ansible-Repo, dass die Mergerequests nur am (potenziell) zweitletzten Mittwoch des Monats eröffnet werden, da dies in der Regel der Tag vor dem ordentlichen Wartungsfenster ist. Wieso hier nicht normaler Cron Syntax verwendet werden kann, ist mir ein Rätsel. Aber das liegt wohl daran, dass Renovate in Javascript geschrieben ist ¯\_(ツ)_/¯

  "regex":{
    "enabled":true
  },
  "regexManagers":[
    {
      "fileMatch":[
        "^roles/(?:prometheus|.+_exporter|thanos|ipxe|pyrra|snipeit)/defaults/main.ya?ml"
      ],
      "matchStrings":[
        "(prometheus_)?(?[_a-z]+)_version:\\s\"?v?(?[\\d\\.]+)\"?(?:.+git-author=(?[^\\s]+))?(?:.+depName=(?[^\\s]+))?(?:.+datasource=(?[^\\s]+))?"
      ],
      "datasourceTemplate":"{{#if datasourceOverwrite}}{{{datasourceOverwrite}}}{{else}}github-releases{{/if}}",
      "lookupNameTemplate":"{{#if gitAuthor}}{{{gitAuthor}}}{{else}}prometheus{{/if}}/{{#if depNameOverwrite}}{{{depNameOverwrite}}}{{else}}{{{depName}}}{{/if}}",
      "depNameTemplate":"{{#if depNameOverwrite}}{{{depNameOverwrite}}}{{else}}{{{depName}}}{{/if}}"
    },

And now the Magic begins…

Wir aktivieren den RegexManager und lassen ihn in verschiedensten Rollen auf das defaults/main.yml los. Dort sucht er nach einem String der _version beinhaltet und evtl. noch einen Kommentar mit git-author, depName und datasource hinter der Versionsnummer enthält. Mit diesen Angaben zeigen wir auf den Upstream-Release. Somit weiss Renovate, wo er die aktuell eingetragene Versionsnummer überprüfen soll. Ein defaults.yml sieht dann zum Beispiel folgendermassen aus:

---
cloudscale_exporter_version: "0.0.9" # renovate: git-author=pitc_systems/applications/go depName=cloudscale_exporter datasource=gitlab-tags

Weiter definieren wir ein paar packageRules. Hier wiederum eine Auswahl:

  "packageRules":[
    {
      "matchPaths":[
        "roles/foswiki/**"
      ],
      "extractVersion":"^Foswiki-(?[\\d\\.]+)",
      "prBodyNotes":[
        ":warning: This is just a hint and does nothing upon merge. Read up on how to apply the update (probably manually unpacking a tar.gz) and do not forget the fallback instance! :warning:"
      ]
    },

Dies ist ein Beispiel für ein Paket, welches sich nicht automatisch updaten lässt. Damit wir das Update trotzdem nicht verpassen, lassen wir den Renovate-Bot eine Versionsnummer aktualisieren, der in einem Kommentar steht und schreiben den Task für unseren Engineer in den Merge Request.

    {
      "matchPackageNames":[
        "cloudscale_bucketbackup",
        "cloudscale_exporter",
        "ipxe",
        "nginx-modsec"
      ],
      "registryUrls":[
        "https://gitlab.puzzle.ch"
      ]
    },

Bei diesem Beispiel zeigen wir für gewisse Pakete auf unseren eigenen Git-Server anstelle von GitHub als Default. Somit aktualisiert uns Renovate auch Software, die wir selber geschrieben oder für uns angepasst haben. Idealerweise aktualisiert der Renovate-Bot in diesen Projekten wiederum die Dependencies.

    {
      "matchPaths":[
        "Pipfile",
        "requirements.yml",
        "Dockerfile"
      ],
      "groupName": "rebuildDocker"
    },

Hier fassen wir alle Abhängigkeiten, die einen Rebuild unseres Dockercontainers triggeren, zusammen. Damit können wir diese Updates zusammen in der Pipeline testen.

    {
      "matchPaths":[
        "roles/snipeit/**"
      ],
      "extractVersion":"^v(?\\d+\\.?\\d+?\\.?\\d+?)"
    }
  ]
}

Beim letzten Beispiel haben wir ein Durcheinander mit dem v vor der Versionsnummer. Die Releases Upstream werden mit v1.1.1 veröffentlicht, unsere Ansiblerolle erwartet jedoch kein v in der Versionsnummer. Damit Renovate dies trotzdem richtig updated, verwenden wir den obigen Regex.

Ausblick

In den nächsten beiden Teilen unserer Blogserie geht es um Pipelines, Iinter, DNS Automatisierung und Scrum.

Kommentare sind geschlossen.