pixel
Select Page

NETWAYS Blog

Ansible – Testing roles with Molecule

Ansible is a widely used and a powerful open-source configuration and deployment management tool. It can be used for simple repetitive daily tasks or complex application deployments, therefore Ansible is able to cover mostly any situation.

If used in complex or heterogene environments it is necessary to test the code to reduce time to fix code in production. To test Ansible code it is suggested to use Molecule.

Molecule is a useful tool to run automated tests on Ansible roles or collections. It helps with unit tests to ensure properly working code on different systems. Whether using the role internally or provide it to the public, it is useful to test many cases your role can be used. In addition Molecule is easily integrated into known CI/CD tools, like Github Actions or Gitlab CI/CD.

In this short introduction I’ll try get your first Molecule tests configured and running!

Please make sure you installed Molecule beforehand. On most distributions it’s easily installed via PIP.
The fastest and most common way to test roles would be in container. Due to a version problem with systemd currently it’s not possible to start services over systemd in containers. For this reason you can easily start with a vagrant instance and later migrate to docker or podman easily.


pip install molecule molecule-vagrant

If you have a role you can change into the role directory and create a default scenario.


cd ~/Documents/netways/git/thilo.my_config/
molecule init scenario -r thilo.my_config default
INFO     Initializing new scenario default...
INFO     Initialized scenario in /Users/thilo/Documents/netways/git/thilo.my_config/molecule/default successfully.

Below the molecule folder all scenarios are listed. Edit the default/molecule.yml to add the vagrant options.

Add a dependency file with your collections as with newer Ansible versions only the core is available. If needed you can add sudo privileges to your tests.

molecule/default/molecule.yml


---
dependency:
  name: galaxy
  options:
    requirements-file: collections.yml
driver:
  name: vagrant
platforms:
  - name: instance
    box: bento/centos-7
provisioner:
  name: ansible
verifier:
  name: testinfra
  options:
    sudo: true

The converge.yml is basically the playbook to run on your instance. In the playbook you define which variables should be used or if some pre-tasks should be run.

molecule/default/converge.yml


---
- name: Converge
  hosts: all
  become: true
  tasks:
    - name: "Include thilo.my_config"
      include_role:
        name: "thilo.my_config"

Now you can run your playbook with molecule. If you want to deploy and not delete your instance use converge. Otherwise you can use test, then the instance will be created, tested and destroyed afterwards.


python3 -m molecule converge -s default
or 
python3 -m molecule test -s default

Finally we can define some tests, the right tool is testinfra. Testinfra provides different modules to gather informations and check them if they have specific attributes.

Your scenario creates a tests folder with the following file: molecule/default/tests/test_default.py

In this example I’ll test the resources my role should create.


"""Role testing files using testinfra."""


def test_user(host):
    """Validate created user"""
    u = host.user("thilo")

    assert u.exists

def test_authorized_keys(host):
    """Validate pub key deployment"""
    f = host.file("/home/thilo/.ssh/authorized_keys")

    assert f.exists
    assert f.content_string == "ssh-rsa AAAA[...] \n"

And if we already converged our instance, we can verify these definitions against our deployment.


python3 -m molecule verify
INFO     default scenario test matrix: verify
INFO     Performing prerun with role_name_check=0...
[...]
INFO     Running default > verify
INFO     Executing Testinfra tests found in /Users/thilo/Documents/netways/git/thilo.my_config/molecule/default/tests/...
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-6.2.5, py-1.11.0, pluggy-0.13.1
rootdir: /
plugins: testinfra-6.4.0
collected 2 items

molecule/default/tests/test_default.py ..                                [100%]

============================== 2 passed in 1.79s ===============================
INFO     Verifier completed successfully.

With those easy steps you can easily test your roles for any scenario and your deployments can run without any hassle or at least you will be more relaxed during it 😉

Check out our Blog for more awesome posts and if you need help with Ansible send us a message or sign up for one of our trainings!

Thilo Wening
Thilo Wening
Senior Consultant

Thilo hat bei NETWAYS mit der Ausbildung zum Fachinformatiker, Schwerpunkt Systemadministration begonnen und unterstützt nun nach erfolgreich bestandener Prüfung tatkräftig die Kollegen im Consulting. In seiner Freizeit ist er athletisch in der Senkrechten unterwegs und stählt seine Muskeln beim Bouldern. Als richtiger Profi macht er das natürlich am liebsten in der Natur und geht nur noch in Ausnahmefällen in die Kletterhalle.

NETWAYS Support Collector Roadmap

Den Support Collector konnte ich bereits in meinem letzten Blogpost vorstellen. Für alle die den Beitrag verpasst haben, hier kurz umrissen was es ist:
Bei dem Tool handelt es sich um einen von uns geschriebenen Datensammler, welche alle möglichen Support relevanten Daten von einem System sammelt und als ZIP verpackt. Das ZIP kann in Support Fällen an uns geschickt werden, damit wir uns einen Überblick über das System machen können.

Letzte Woche konnte mit Verzögerungen die Version 0.7.0 veröffentlich werden, welche nun auch Daten über die IcingaDB und Redis sammelt. Von Versionen bis hin zur Konfiguration und Service Status wird alles mit gesammelt.

Im Rahmen dieses Blogposts möchte ich euch einen kleinen Ausblick geben, welche möglichen Erweiterungen wir mit dem Support Collector noch abbilden möchten.

Systemweiter Datensammler

Zum aktuellen Stand sammelt der Support Collector Daten ein, speichert sie in eine Datei und verpackt dass alles zu einem großem ZIP. Das ganze passiert aber nur auf dem System auf welchen das Tool ausgeführt wird. Jetzt stehen wir natürlich vor dem “Problem” dass Icinga 2 Umgebungen über mehrere Systeme verteilt sein können. So kann es sein dass einfach nur die Datenbank auf einem anderen Host läuft oder dass sich irgendwo noch ein zweiter Master bzw. Satelliten befindet. Aus Sicht des Supports wäre es natürlich schön auch diese Daten mit abzufragen.
Die Umsetzung des eben beschriebenen Vorhabens ist noch nicht ganz klar, da es hier neben vielen Kleinigkeiten vor allem die Security zu beachten gilt. Da wir uns auch vorstellen können, dass nicht ein jeder es gut findet, wenn wir komplette Systeme scannen, wird diese Funktion auch nur optional. Unser Augenmerk liegt darauf, dass der Benutzer frei entscheiden kann, was er gesammelt haben möchte.

Statistiken

Mit den gesammelten Daten lassen Sich natürlich auch aussagekräftige Statistiken erstellen. Anhand von diesen Daten könnten wir von den einfachsten Statistiken wie “Welche Versionen werden wie oft genutzt”, bis hin zu komplexen Themen wie “Durchschnittliche Größe eines Systems” oder “Welche Hardware Specs für welche Icinga 2 Größe” erstellen. Allerdings ist auch hier noch nicht zu eindeutig wie die Umsetzung aussehen soll, da hier ebenfalls die Security und Anonymität eine große und wichtige Rolle spielen.

Mit den zwei Punkten welche ich hier angesprochen habe, konnte ich euch nur einen kleinen Einblick gegeben, was an Feature Ideen noch in Planung sind. Sollte euch etwas einfallen, was aus eurer Sicht sinnvoll wäre umzusetzen, könnt ihr gerne ein Feature Request im Git Repository eröffnen.

Tobias Bauriedel
Tobias Bauriedel
Systems Engineer

Tobias ist ein offener und gelassener Mensch, dem vor allem der Spaß an der Arbeit wichtig ist. Bei uns hat er seine Ausbildung zum Fachinformatiker für Systemintegration abgeschlossen und arbeitet nun im NETWAYS Professional Services - Team Operations und entwickelt nebenbei Projekte für die NPS. In seiner Freizeit engagiert er sich ehrenamtlich aktiv bei der Freiwilligen Feuerwehr als Atemschutzgerätetrager und Maschinist, bereist die Welt und unternimmt gerne etwas mit Freunden.

Divide and Conquer – Verteilte Git-Konfiguration

In meinem zehnten Monat als Consultant bei NETWAYS angekommen, bin ich inzwischen gut in verschiedenste Kundenprojekte integriert. Das sorgt einerseits für einen abwechslungsreichen Alltag mit immer neuen Herausforderungen, andererseits stellte sich irgendwann ein grundlegendes Problem heraus: Ich kann nicht jonglieren!

Dabei wäre das bei der Vielzahl an geschäftlichen Email-Adressen, SSH- und GPG-Schlüsseln und anderen Kleinigkeiten, die sich von Kunde zu Kunde, Plattform zu Plattform und Projekt zu Projekt unterscheiden, dringend notwendig. Ein unsignierter Commit mit falscher Email auf Github, Gitlab oder sonstiger Plattform ist da bei meinem Talent für Schusseligkeit nur eine Frage der Zeit. Glücklicherweise gibt es ein Git-Feature, das hier unterstützen kann: Die hierarchische Einbindung mehrerer .gitconfig Dateien.

Theorie und Möglichkeiten

Standardmäßig liest git die hinterlegte Konfiguration laut offizieller Dokumentation aus vier Verzeichnissen/Dateien aus:

  • $(prefix)/etc/gitconfig – der systemweiten Git-Konfiguration
  • $XDG_CONFIG_HOME – einer userspezifischen Git-Konfiguration, oftmals unter $HOME/.config/git
  • ~/.gitconfig – einer weiteren userspezifischen Git-Konfiguration, auch “globale Git-Konfiguration” genannt
  • $GIT_DIR/config – einer Git-Konfiguration auf Repositoryebene

Die Dateien werden hierbei von jeder Routine, die auf git-Konfiguration zurückgreift, in obiger Reihenfolge ausgelesen, bei mehrfach definierten Werten greift hierbei der letzte, sodass ein “Feintuning” von globaler Konfiguration hin zu projektspezifischen Einstellungen im Repository schon einmal möglich ist. Für noch bessere Verteilung und optionale Einbindung von erweiternder Konfiguration gibt Git Dir eine praktische Direktive an die Hand: includeIf.

Sie erlaubt es Dir, optional Konfiguration von beliebigen Stellen in die gerade betrachtete Datei einzubinden – die dort hinterlegten Einstellungen werden also zeitgleich evaluiert. Für eine bessere Kontrolle darüber, wann zusätzliche Einstellungen importiert werden sollen, können wir uns erneut die Dokumentation von git, genauer gesagt den Abschnitt zu Conditional Includes anschauen, denn hier gibt es verschiedene Optionen:

  • gitdir – die Einbindung erfolgt, wenn die Auswertung (via Befehl, Script, etc.) der Git-Konfiguration aus einem der Angabe entsprechenden (Unter-)Verzeichnis erfolgt. gitdir unterstützt Wildcards und Case(in)sensitivity.
  • onbranch – die Einbindung erfolgt aus einem momentan ausgecheckten Branch erfolgt, der der Angabe entspricht. Wildcards und Case(in)sensitivity werden auch hier unterstützt
  • hasconfig:remote.*.url – die Schreibweise ist etwas gewöhnungsbedürftig und auch die Anwendungsmöglichkeiten nicht so geradlinig wie bei den anderen Optionen: Hierbei werden optional Einstellungen eingebunden, wenn sich irgendwo in der lokalen Git-Konfiguration (nicht zwingend in der gleichen betrachteten Datei) eine remote URL findet, die der Angabe entspricht. Das ist bspw. nützlich, um mehrere Repositories mit bestimmter, immer gleicher Konfiguration zu versehen  (z.B. alle Repositories, die Icingaweb2-Module bereitstellen)

Praxis

Doch wie kann so etwas in der Praxis aussehen? Wie eingangs erwähnt, war mir vor Allem die Handhabe mehrerer kundenbezogener Email-Adressen und damit verknüpfter GPG-Schlüssel zu lästig, sodass ich hier für die Konfiguration zu einem hierarchischen Ansatz zurückgriff. Meine globale Git-Konfiguration unter ~/.gitconfig gestaltete ich also wie folgt:

Screenshot einer globalen Gitkonfiguration

Globale Gitkonfiguration unter ~/.gitconfig

Neben “Standardeinstellungen” wie meinem im Commit zu setzenden Namen, der Defaultbranch bei der Erstellung neuer Repositories, die Anweisung, Commits immer mittels GPG zu signieren und das dafür zu nutzende Programm finden sich hier vier includeIf-Abschnitte.

Diese decken die verschiedenen Unterverzeichnisse meines Entwicklungsverzeichnisses ~/repositories/ ab – die trailing Slashes am Ende der jeweiligen Pfaddefinition sorgen dafür, dass die jeweiligen Unterverzeichnisse rekursiv gematcht werden. Im Beispiel hieße das, dass sowohl ein Repository unter ~/repositories/netways/blogpost_dbodky als auch ein Repository unter ~/repositories/netways/icinga-related/director-patches die zusätzlichen Git-Einstellungen unter ~/repositories/netways/.gitconfig einbinden würde.

In den in meiner globalen Git-Konfiguration referenzierten optional einzubindenden Dateien finden sich dann die Kunden- oder projektspezifischen Einstellungen für Git – das können mal mehr, mal weniger sein, um das Beispiel einfach zu halten hier die minimalistische Version für meine privaten Repositories, in der ich nur meine private Emailadresse sowie den damit verknüpften GPG-Schlüssel für die Signierung angebe:

Screenshot einer spezifischen Gitkonfiguration unter ~/repositories/private/.gitconfig

Spezifische Gitkonfiguration unter ~/repositories/private/.gitconfig

Was ihr jetzt in eure Gitkonfigurationen einbauen möchtet und wie ihr sie strukturiert, ist komplett euch überlassen – Git macht hierbei keine Vorschriften sondern arbeitet stur die vier eingangs erwähnten Dateien und alle optional einzubindenden Verzeichnisse und Dateien ab. Wichtig ist lediglich die Reihenfolge der Angaben, da sich diese gegebenenfalls überschreiben können.

Falls Du diesen Blogpost interessant fandest, aber bei Git noch Steigerungsbedarf siehst oder bei der Erwähnung von remote URLs ausgestiegen bist, kann ich Dir an dieser Stelle die NETWAYS Gitlab Schulung ans Herz legen, wo Du in Sachen Versionsverwaltung und DevOps voll durchstarten kannst.

Daniel Bodky
Daniel Bodky
Consultant

Daniel kam nach Abschluss seines Studiums im Oktober 2021 zu NETWAYS und berät nun Kunden zu den Themen Icinga2 und Kubernetes. Nebenher schreibt er in seiner Freizeit kleinere Tools für verschiedenste Einsatzgebiete, nimmt öfters mal ein Buch in die Hand oder widmet sich seinem viel zu großen Berg Lego. In der wärmeren Jahreszeit findet man ihn außerdem oft auf dem Fahrrad oder beim Wandern.

DockerCon 2022: Wie geht Containersecurity?

Buzzwords wie Software Supply Chain, Container Security Scanning oder Software Bill of Materials (SBOM) sind in den vergangenen zwei Jahren vermehrt in aller Munde, nicht zuletzt aufgrund des anhaltenden Trends zur Containerisierung vormals monolithischer Anwendungen und deren Betrieb als sog. Microservices. Allerdings kann nach wie vor nicht jeder, der auf die ein oder andere Weise mit Docker oder Containern im Allgemeinen zu tun hat, etwas mit diesen Begriffen anfangen. Aus diesem Grund habe ich den Fokus meines virtuellen Besuchs der diesjährigen DockerCon auf genau diesen Themebereich – Containersecurity – gelegt und möglichst viele Best Practices für Dich zusammengefasst.

 

Die Ausgangslage

Die großen Sicherheitslücken der vergangenen zwei Jahre, sei es der Solarwinds-Breach oder die Log4J/Log4Shell-Exploits haben einmal mehr schmerzlich bewusst gemacht: In fast jedem Softwareprodukt, egal ob proprietär oder Open Source, befinden sich zahlreiche mehr oder weniger gut gepflegte Abhängigkeiten verschiedenster Maintainer – in vielen Applikationen stecken bis zu 80% Open Source Code, die es “auf dem Schirm” zu behalten gilt. Dies gilt natürlich auch für Container(-images), die letzten Endes nichts anderes tun, als die Applikation zu bündeln und (weitestgehend) losgelöst vom ausführenden Hostsystem ausführbar zu machen.

Dennoch gelten Container paradoxerweise oft als besonders sicher, sei es aufgrund der von außen wahrgenommenen “Kapselung” oder der geringen Größe – soviel potentiell vulnerable oder bösartige Software kann da doch gar nicht drinstecken, oder? Mögen diese Wahrnehmungen in der Theorie und im Bestfall auch stimmen, sieht die Praxis in den allermeisten Fällen anders aus, wie die ein oder andere Keynote im Rahmen der DockerCon gezeigt hat.

Laut SysDig, einem Spezialisten für Cloudsecurity, laufen 58% der in den einschlägigen Containerregistries (DockerHub, Google, Github, etc.) verfügbaren Containerimages als root, anstatt einen geeigneteren, unprivilegierten Nutzer zu verwenden. Außerdem beinhalten selbst die offiziellen, von den Registries kuratierten Containerimages beliebter Frameworks oder Distributionen dutzende detektierbare Schwachstellen.

Offensichtlich gibt es also zum Einen ein falsches Gefühl von Sicherheit innerhalb der Nutzergemeinschaft von Containern, zum Anderen aber schlichtweg keine Blaupause oder “OneFitsAll”-Lösung, die für beliebige Container(-images) absolute Sicherheit verspricht. Nur eine Sache ist klar: “Hinten” anzufangen, ist nicht rentabel – Container einfach zu deployen und dann im Betrieb nach Sicherheitslücken zu suchen, ist teuer, erzeugt vermeidbaren zusätzlichen Aufwand und nimmt Flexibilität. Die Absicherung der Container muss “nach links” geschoben werden, möglichst an den Beginn der Containerisierung. Doch wo ansetzen?

 

Die Möglichkeiten

Um den Ursprung für Schwachstellen in Container(-images) und damit einhergehend mögliche Ansatzpunkte zur Absicherung zu identifizieren, muss man die “Lebensphasen” eines Container(-images) verstehen. Diese lassen sich kurz und bündig wie folgt darstellen:

 

  • Bau des Images (lokal oder via CI/CD)
  • Distribution des Images – Push in eine Registry (Self-hosted oder bei einem Anbieter), Pull von Usern/Programmen
  • Deployment des Images (Openshift, (Managed) Kubernetes, etc.) und Betrieb des Containers

 

An dieser Stelle wird hoffentlich klar, warum ich permanent “Container(-image)” schreibe – abhängig von der betrachteten Lebensphase haben wir es beim Thema Containersicherheit entweder mit einem erstellten Image oder mit einem laufenden Container, quasi einer Instanziierung dieses Images zu tun. Mit dieser Aufschlüsselung in verschiedene Abschnitte können wir uns nun mögliche Schritte zur Absicherung unserer Container anschauen.

Containerimages werden entweder lokal von Entwickler:innen, DevOps-Engineers, etc. auf Grundlage eines sog. Dockerfiles gebaut – alternativ lässt sich dieser Vorgang aber auch von CI/CD Pipelines umsetzen, wenn man den Dockerfile und andere benötigten Ressourcen bspw. in GitLab eincheckt. Die meisten Möglichkeiten zur Absicherung des späteren Container(-images) ergeben sich bereits in diesem ersten Schritt – der Quellcode der zu containerisierenden Applikation liegt vor, ebenso die Definition des Containers selbst, und auch möglicherweise vulnerable oder bösartige Abhängigkeiten wurden noch nicht in das spätere Containerimage eingebettet.

Ist das Containerimage lokal oder in einer Pipeline gebaut worden, muss man es zur späteren Nutzung in Produktion in eine Containerregistry übertragen. Diese kann entweder selbst gehosted werden, als private, aber gemanagte Instanz in der Cloud laufen oder öffentlich von jedem beliebigen Nutzer einsehbar sein. Auch bei diesem Vorgang gibt es Möglichkeiten, die Supply Chain zwischen Entwickler und Endnutzer abzusichern.

Auch wenn im Dockerfile gewisse Best Practices befolgt werden, kann der Container diese in vielen Fällen beim Deployment überschreiben – das letzte Wort haben hierbei immer Kubernetes, Openshift, Docker Desktop und Konsorten. Aus diesem Grund müssen einige der Überlegungen, die in der Build-Phase des Containerimages stattgefunden haben, auch hier noch einmal herangezogen, betrachtet und evaluiert werden, um die für die konkrete Nutzung besten Einstellungen und Kompromisse zu finden.

Ist der Container erst einmal deployed, gibt es nicht mehr viele Möglichkeiten, ihn “von innen” weiter abzusichern. Dennoch kann und sollte man sich fortlaufend Gedanken bspw. um die Erreichbarkeit innerhalb des Clusters, des Netzwerkes, der Cloud etc. machen, Regeln nachziehen wo nötig und natürlich auch Update- und Backupstrategien im Hinterkopf behalten. Am Ende des Tages merkt man spätestens jetzt: Nach dem Deployment eines Containers seine Sicherheit zu überprüfen und zu garantieren, ist nicht sinnvoll – wir brauchen einen Left Shift.

 

Die Umsetzung

Buildphase

Nach viel Theorie und Konjunktiv können wir uns jetzt konkrete Umsetzungsmöglichkeiten der besprochenen Ansätze anschauen. Beginnen werden wir “ganz links” beim Bau der Containerimages. Vieles lässt sich hier über verschiedene Direktiven im genutzten Dockerfile festlegen – Docker listet alle möglichen Direktiven sowie sinnvolle Best Practices und Caveats in der Docker Dokumentation auf. Für uns besonders interessant sind die Direktiven ADD, COPY und USER. Wie eingangs erwähnt, nutzen mehr als die Hälfte aller Containerimages per Default den root Nutzer innerhalb des Containers, obwohl das in vielen Fällen gar nicht notwendig wäre – auf einem klassischen Server läuft ein Apache2 Webserver schließlich auch als User apache, warum sollte/müsste das innerhalb eines Containers bspw. auf Debian-Basis anders sein?

Die anderen beiden erwähnten Direktiven beziehen sich auf die Abwägung, ob man gewisse Verzeichnisse und Dateien aus seiner Entwicklungsumgebung denn tatsächlich im endgültigen Container braucht – oder ob man die Applikation direkt lokal baut, und lediglich die fertige Binary via COPY in das Containerimage überträgt. In diesem Fall muss man natürlich darauf achten, dass etwaige zur Laufzeit benötigte Tools (ich meine dich, curl) und Bibliotheken sich auch im endgültigen Containerimage befinden. Alternativ kann man innerhalb eines Dockerfiles eine build und eine run Umgebung schaffen – auf diese Weise kann man die Applikation innerhalb des Containers bauen und im Anschluss lediglich die benötigten binären Artefakte und andere benötigten Ressourcen in das Lauzeitimage kopieren. Für diese Vorgehensweise würde es sich anbieten, das Entwicklungsrepository via ADD in die build Umgebung des Containerimages zu übertragen.

Diese Vorgehensweise bringt uns direkt zur nächsten “beliebten” Unsicherheit in Container(-images): Lokal hinterlegte Credentials, Entwicklertokens, Cloudzugänge etc. Es gibt vermutlich keine Art von geheimen Daten, die nicht bereits versehentlich in Containerimages “vergessen” wurde. Das kann schnell passieren – ein Entwickler nutzt ein Shellscript mit Zugangsdaten, um von seiner Entwicklungsumgebung in ein Testcluster zu deployen, eine .yaml-Datei, um Daten aus einer Entwicklerdatenbank zu lesen etc. Im “schlimmsten Fall” überträgt er wissentlich eine Datei mit Zugangsdaten, die die containerisierte Applikation später in Produktion nutzen soll, oder hinterlegt sensible Daten im Containerimage als Umgebungsvariable mittels ENV.

Zur Bewältigung dieser Problematik gibt es die Möglichkeit, ähnlich wie eine .gitignore Datei für Git-Repositories eine .dockerignore Datei für den Buildvorgang eines Containerimages zu hinterlegen. Alle in dieser Datei aufgeführten Dateien und Verzeichnisse werden vom Dockerdaemon bei der Verarbeitung des Build-Contexts, von ADD und von COPY Direktiven ignoriert und finden sich somit zu keinem Zeitpunkt im Containerimage wieder. Dringend benötigte Umgebungsvariablen zur Konfiguration der containerisierten Applikation können auch zum Zeitpunkt des Deployments noch übergeben werden, bspw. mittels des Parameters -e via Docker-CLI oder dem Einlesen von Secrets als Umgebungsvariablen in Kubernetes.

Grundlegende Maßnahmen wie die Nutzung eines passenden Base-Images in der FROM Direktive (es muss nicht immer debian:buster oder ubuntu:20.04 sein), das Vermeiden der Öffnung unbenötigter Ports via EXPOSE sowie der Nutzung sog. Kannibalen-Tags (latest zeigt nach jedem Imageupdate auf eine neue Version) sollten darüber hinaus natürlich immer befolgt und beachtet werden.

Distributionsphase

Haben wir nun lokal oder in der Pipeline ein in sich möglichst sicheres Containerimage gebaut, muss es auf die eine oder andere Art und Weise seinen Weg in eine Containerregistry finden, um von anderen Nutzern heruntergeladen und deployed werden zu können. Für diese Vorgänge werden einem von den meisten Containerregistries Werkzeuge an die Hand gegeben, mit deren Hilfe wir den Prozess des Up-/Downloads von Containerimages absichern können.

Letzten Endes sind Containerregistries nichts anderes als APIs mit der Fähigkeit, Nutzer zu authentifizieren und zu authorisieren, Containerimages in Empfang zu nehmen, zu speichern und deren Versionierung im Blick zu behalten. Wie immer, wenn man über das Internet mit einer API spricht, gilt: HTTPS ist Pflicht! Darüber hinaus bieten Registries verschiedene Möglichkeiten, zusätzliche Maßnahmen gegen die Verbreitung unsicherer Images oder die Manipulation vorhandener Containerimages zu treffen. So können Imageregistries oftmals alle verwalteten Containerimages auf bekannte Schwachstellen scannen und den Download solcher Images durch Endnutzer untersagen. Auch die digitale Signierung von verwalteten Containerimages ist oftmals möglich, z.B. mittels sigstore und cosign.

Deploymentphase

Wird unser Containerimage nun im Rahmen eines Kubernetes-Deployments oder Docker-CLI-Befehls aus der Registry gepulled und deployed, haben wir einmal mehr die Möglichkeit, Sicherheit zu forcieren: Wie bereits erwähnt, können wir zum Einen die Voreinstellungen in Hinblick auf Umgebungsvariablen, User- und Gruppenkontext uvm. überschreiben, zum Anderen bietet natürlich auch die Absicherung der ausführenden Infrastruktur selbst die Möglichkeit, dass Deployment so sicher wie möglich zu gestalten.

Hierzu zählen z.B. die Nutzung sog. rootless Container, die von einem Container Runtime Interface (CRI) wie Docker oder containerd ausgeführt werden, die selbst nicht im root Kontext laufen. Auch die Nutzung restriktiver Netzwerk- und Firewallpolicies kann dabei helfen, die Risiken durch möglicherweise vulnerable oder bösartige Container zu minimieren. Konfiguration und Forcierung dieser Maßnahmen innerhalb eines Clusters können schnell zur Sisyphusarbeit werden – hier kann ein gemanagtes Kubernetes-Cluster von Vorteil sein, bspw. Managed Kubernetes von NETWAYS Web Services. Darüber hinaus sollte man eine nachhaltige Update-Strategie für seine Containerimages verfolgen: Es gilt, einen guten Kompromiss zwischen regelmäßigen Updates zu finden, aber nicht sofort jede neue Version (und deren evtl. neu eingeführte Schwachstellen) in Produktion zu deployen.

 

Das Fazit

Container(-images) sicher zu erstellen, zu verwalten und zu deployen ist ein langer Weg voller Stolpersteine. Wie so oft beim Thema Sicherheit wird man die 100% aller Voraussicht nach nicht erreichen – Nichtexistenz von Sicherheitslücken lässt sich nun einmal nicht beweisen. Dennoch habe ich Dich hoffentlich für das Thema und die teils falschen, gängigen Annahmen und Praktiken sensibilisieren können und nebenbei einige Best Practices an die Hand gegeben, die das Risiko durch vulnerable Container deutlich verringern.

Solltest Du nun mehr über den Vorgang des Imagebaus, den Betrieb von Containern in Kubernetes oder Containerisierung im Allgemeinen erfahren wollen, schau Dir doch einmal unser Kubernetes Schulungsangebot von NETWAYS an. In diesem eintägigen Workshop vermittle ich Dir praxisnah und einsteigerfreundlich alles, was Du für die ersten eigenen Schritte mit Docker und Kubernetes wissen musst. Ich freue mich auf Dich!

Daniel Bodky
Daniel Bodky
Consultant

Daniel kam nach Abschluss seines Studiums im Oktober 2021 zu NETWAYS und berät nun Kunden zu den Themen Icinga2 und Kubernetes. Nebenher schreibt er in seiner Freizeit kleinere Tools für verschiedenste Einsatzgebiete, nimmt öfters mal ein Buch in die Hand oder widmet sich seinem viel zu großen Berg Lego. In der wärmeren Jahreszeit findet man ihn außerdem oft auf dem Fahrrad oder beim Wandern.

Python – Generator

Wer schon einmal eine etwas größere Datei, bspw. 1Gb, mit dem Editor VIM geöffnet hat, der weis, wie lange dies dauern kann. Das kommt daher, dass diese Datei zunächst komplett in den Arbeitsspeicher geladen werden muss. So ähnlich verhält es sich, wenn in Python eine Variable “befüllt” wird, welche anschließend Speicherplatz im Arbeitsspeicher belegt. Bei der heutigen Hardware stellen 1 Gb große Variablen kein Problem dar, aber was passiert wenn diese deutlich größer sind und zusätzlich eine gute Performance benötigt wird? In diesem Fall empfiehlt es sich, auf einen Generator zurückzugreifen. Ein Generator liefert lapidar gesagt die Ergebnisse einer Funktion “häppchenweise” und nicht als riesiges Stück zurück. Syntaktisch unterscheidet sich ein Generator von einer “normalen” Funktion nicht großartig, wie hier zu sehen:

def a_function():
  yield x
  yield y
  yield z

Bei der Funktionsdefinition wird lediglich das Schlüsselwort return durch yield ersetzt. Der große Unterschied zu einer “normalen” Funktion dabei ist, dass sich die Funktion mit yield nicht sofort beendet. Somit erhält man, vereinfacht gesagt, ein Objekt (Iterator) als Rückgabewert, über das man iterieren kann.

Zur besseren Veranschaulichung eines Generators werde ich eine exemplarische Datenbankabfrage aufzeigen:

Hinweis: Ich habe eine Beispieldatenbank genommen.

“normale” Funktion
SELECT-Statement und anschließende Rückgabe des gesamten Ergebnisses:

def select_list():
    result = []

    db = mysql.connector.connect(host='127.0.0.1',
                                 user='thomas',
                                 password="4nD3r5on",
                                 db='employees')

    cursor = db.cursor()

    cursor.execute('select * from employees')

    for row in cursor:
        result.append(row)

    return result

Die Funktion “select_list()” liefert eine Liste zurück:

print(select_list())

[...]
(10016,'1961-05-02','Kazuhito','Cappelletti','M','1995-01-27'),
(10017,'1958-07-06','Cristinel','Bouloucos','F','1993-08-03'),
(10018,'1954-06-19','Kazuhide','Peha','F','1987-04-03'),
[...]

“generator” Funktion
Im Gegensatz zum obigen Beispiel wird keine Liste mit den Werten befüllt, sondern jeder Wert einzeln durch yield zurückgegeben:

def select_generator():
    db = mysql.connector.connect(host='127.0.0.1',
                                 user='thomas',
                                 password="4nD3r5on",
                                 db='employees')

    cursor = db.cursor()

    cursor.execute('select * from employees')

    for row in cursor.fetchmany(1000):
        yield row

Die Funktion “select_generator()” liefert keine Liste zurück, sondern einen Generator:

print(select_generator())

<generator object select_generator at 0x10ba165d0>

Durch anschließendes Iterieren des Generatorsobjektes werden die gewünschten Werte ausgegeben:

for row in select_generator():
        print(row)

[...]
(10016,'1961-05-02','Kazuhito','Cappelletti','M','1995-01-27'),
(10017,'1958-07-06','Cristinel','Bouloucos','F','1993-08-03'),
(10018,'1954-06-19','Kazuhide','Peha','F','1987-04-03'),
[...]

Wo liegt nun der Vorteil eines Generators? Das zeigt sich erst bei Benchmark-Tests, welche im folgenden Beispiel die Performance der jeweiligen Funktion aufzeigt. Die SELECT-Statements sind bei beiden Beispielen dieselbigen, werden aber nicht ausgegeben, es wird nur eine Variable initialisiert:
select_list()

print('Memory (Before): {} Mb'.format(
    psutil.Process(os.getpid()).memory_info().rss / 1000000))
    
t1_start = perf_counter()
test_list = select_list()
t1_stop = perf_counter()
    
print('Memory (After) : {} Mb'.format(
    psutil.Process(os.getpid()).memory_info().rss / 1000000))

print("Elapsed time: {0} seconds".format(t1_stop-t1_start))

select_generator()

print('Memory (Before): {} Mb'.format(
    psutil.Process(os.getpid()).memory_info().rss / 1000000))

t1_start = perf_counter()
test_generator = select_generator()
t1_stop = perf_counter()

print('Memory (After) : {} Mb'.format(
    psutil.Process(os.getpid()).memory_info().rss / 1000000))

print("Elapsed time: {0} seconds".format(t1_stop-t1_start))

select_list():

Memory (Before): 11.776 Mb
Memory (After) : 124.960768 Mb
Elapsed time: 8.311030800000001 seconds

 

select_generator():

Memory (Before): 11.698176 Mb
Memory (After) : 11.718656 Mb
Elapsed time: 4.869999999934649e-07 seconds

Die Zahlen sprechen für sich. Beim Generator bleibt der Verbrauch des Arbeitsspeichers so gut wie unverändert, da dieser beim Ausführen des Codes nicht “alle Werte speichert”, sondern jeden einzelnen Wert ab dem Schlüsselwort yield zurückgibt. Dadurch ergibt sich auch die immense Geschwindigkeit.
An dieser Stelle ist noch zu erwähnen, dass alle Vorteile eines Generators verloren gehen, wenn man diesen in ein Liste umwandelt.

Quelle: https://media.giphy.com/media/yUrUb9fYz6x7a/giphy.gif

Philipp Dorschner
Philipp Dorschner
Technical Service Manager

Nach seiner Ausbildung zum Fachinformatiker bei der NETWAYS Professional Services GmbH wuchs das Interesse an Development und organisatorischen Themen. Heute unterstützt Philipp die Kollegen aus PS-Services als hybrider Mitarbeiter. Fünfzig Prozent seiner Zeit als Technical Service Manager und die anderen fünfzig Prozent als interner Entwickler. Als Ausgleich zu seiner Arbeit im Büro verbringt er seine Freizeit meistens beim Sport oder trifft sich mit Freunden.