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.

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, damitdelegate_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.