Man stelle sich eine Build und Deployment-Pipeline in einem Software-Projekt vor. Es wird sehr viel Wert darauf gelegt die Anwendung entsprechend gut zu testen. Viel Aufwand wird investiert in Unit Tests, Integration Tests und so weiter. Wenn die Anwendung innerhalb einer Container Infrastruktur betrieben wird, ist es notwendig Docker-Images für die Anwendung zur Verfügung zu stellen. Dabei kann schnell vernachlässigt werden, dass im Enterprise Umfeld auch Anforderungen an das Docker-Image an sich gestellt werden. Dazu zählen vor allem Security und Compliance Anforderungen ausgehend von der Laufzeit-Infrastruktur wie Docker, Kubernetes, OpenShift usw..
Damit das Testen von Software-Lösungen nicht mit dem Bauen und Publizieren einer Jar-File aufhört, gibt es viele Möglichkeiten hier anzusetzen und eine CI/CD-Pipeline entsprechend zu ergänzen. In diesem Artikel stellen wir einige Erfahrungen dazu aus Kundenprojekten vor.
Einfacher gefragt: Ist durch die CI/CD-Pipeline überhaupt sichergestellt, dass das Docker-Image und die darin enthaltene Anwendung überhaupt startet? Wird geprüft, ob Konfigurationen, zum Beispiel in Form von Umgebungsvariablen wie JAVA_OPTS, definiert und im Betrieb auch aktiv sind?
Typische Anforderungen in diesem Umfeld variieren stark. Ausgehend von der Erfüllung bestimmter Konfigurationswerte bis hin zu komplexeren Überprüfungen am laufenden Container kann es ein weites Feld an zu erfüllenden Anforderungen geben.
Statische Tests
Möchten wir nun ein frisch erstelltes Docker-Image auf bestimmte Konfigurationswerte oder die Existenz von Dateien hin
testen, bietet sich zum Beispiel das Tool container-structure-test
1 der Google Container Tools
an. Das Tool ist dazu in der Lage Container zu überprüfen, ohne dass diese überhaupt gestartet sind. Ähnlich wie bei
Unit-Tests lassen sich damit schnell Fehler identifizieren bevor zeitaufwändigere Tests durchgeführt werden.
Das von uns nun verwendete Docker-Image für eine einfache Java-Anwendung auf Basis Spring-Boot könnte wie folgt aussehen:
FROM openjdk:11-jre-slim
RUN apt-get update && \
apt-get install -y procps && \
rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 appuser && \
useradd -r -u 1001 -g 1001 -M -l appuser && \
mkdir -p /app/ && \
chown 1001:1001 /app/
USER 1001
WORKDIR /app
COPY --chown=1001:1001 build/libs/java-service*all.jar /app/java-service.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/java-service.jar"]
Die Nutzung von container-structure-test
setzt auf Test-Definitionen in YAML und unterscheidet dabei verschiedene
Blöcke von Tests. Welche Tests die einzelnen Blöcke abbilden, ist selbstsprechend:
- metadataTest
- fileExistenceTests
- fileContentTests
- commandTests
Im Folgenden sind ein paar einfache Tests beschrieben. Dabei werden verschiedene Dinge getestet. Es wird über
einen commandTest
geprüft, ob eine Java Version 11 im Docker-Image verwendet wird. Weiterhin kann mit Hilfe von Tests
innerhalb des Blocks fileExistenceTest
überprüft werden, ob die Applikation an sich vorhanden ist und die korrekten
Berechtigungen für diese definiert sind. Im Block metadataTest
werden Tests der üblichen Angaben innerhalb eines
Dockerfile ermöglicht. Dazu gehören unter anderem exposed Ports, Umgebungsvariablen, Entrypoint- und
Cmd-Konfigurationen.
Insbesondere die Tests der Entrypoint- und Cmd-Definitionen haben sich als sehr hilfreich erwiesen, da sie große Auswirkung auf das Verhalten des Containers haben. Wenn man zum Beispiel sicherstellen möchte, dass eine Java-Anwendung stets mit der ProzessID 1 ausgeführt wird, können Änderungen an Entrypoint- und Cmd-Definitionen dies schnell und unbeabsichtigt verändern. Das kann mit einem entsprechenden Test sehr leicht verhindert werden. Hinweise dazu gibt die Docker-Dokumentation bzgl. Entrypoints3.
schemaVersion: '2.0.0'
commandTests:
- name: 'java'
command: 'java'
args: [ '-version' ]
expectedError: [ 'openjdk version \"11\..*' ]
exitCode: 0
fileExistenceTests:
- name: 'application'
path: '/app/java-service.jar'
permissions: '-rw-r--r--'
uid: 1001
gid: 1001
shouldExist: true
metadataTest:
workdir: "/app"
exposedPorts: [ "8080" ]
entrypoint: [ "java", "-jar", "/app/java-service.jar" ]
cmd: [ ]
Innerhalb eines Java-Projekts legen wir diese Test-Definition nun an eine passende Stelle, zum Beispiel
unter src/test/docker/container-structure-tests.yaml
. Sobald das Docker-Image gebaut ist, kann der Test mit Verweis
auf das Docker-Image und die Test-Definition ausgeführt werden.
docker build . -t java-service
container-structure-test test -i java-service -c src/test/docker/static/container-structure-tests.yaml
Als Ergebnis erhalten wir eine Konsolenausgabe der Testergebnisse. Diese können jedoch auch im JSON-Format zur späteren Verarbeitung gespeichert werden.
=======================================================
====== Test file: container-structure-tests.yaml ======
=======================================================
=== RUN: Command Test: java
--- PASS
stderr: openjdk version "11.0.4" 2019-07-16
OpenJDK Runtime Environment 18.9 (build 11.0.4+11)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.4+11, mixed mode)
=== RUN: File Existence Test: application
--- PASS
=== RUN: Metadata Test
--- PASS
=======================================================
======================= RESULTS =======================
=======================================================
Passes: 3
Failures: 0
Total tests: 3
PASS
Selbstverständlich lassen sich die Tests in mehrere Dateien aufteilen und entsprechend über ein Script gesammelt ausführen. Dabei muss jedoch beachtet werden, dass dann auch mehrere Reports zusammengeführt werden müssen. Container-structure-test bietet von Haus aus keine Möglichkeit JUnit-kompatible Reports zu erstellen. Das macht eine Integration in Jenkins, Gitlab CI o.ä. etwas aufwändiger. Eingesetzt in einer Pipeline bestimmt der Exit-Code der Testausführung darüber, ob alle Tests erfolgreich waren oder nicht. Einer Nutzung innerhalb einer Build-Pipeline steht also nichts im Weg.
Dynamische Tests
Da der Container zum Ausführen der Tests durch container-structure-test
nicht gestartet wird, gibt es einige
Einschränkungen. Ein Test des Verhaltens der laufenden Anwendung ist nicht möglich.
Wir gehen nun einen Schritt weiter und testen unsere Applikation im laufenden Betrieb. Dazu nutzen wir das Tool Inspec
von Chef4. Inspec ist konzipiert als Tool zur Beschreibung und Prüfung von Compliance-Regeln nach
dem Prinzip “Compliance as code”. Statt also eine Dokumentation in reiner Textform mit Compliance-Regeln zu erstellen
werden die Regeln bzw. Anforderungen in Code gegossen. Code ist im Fall von Inspec eine auf Ruby basierende DSL. Diese
ist gut lesbar, auch für Nicht-Entwickler. Im Folgenden gehen wir auf einige einfachere Beispiele ein. Inspec bietet für
alles darüber hinausgehende eine ausführliche Dokumentation mit Hilfe derer sich auch komplexere Test-Szenarien
erstellen lassen.
Mit Hilfe eines Bootstrap-Mechanismus lässt sich ein Rumpf für ein neues Inspec-Profil sehr leicht anlegen. Inspec
erstellt dabei eine definierte Verzeichnisstruktur mit einem Basis-Test in controls/example.rb
.
$ inspec init profile java-service-compliance
$ tree java-service-compliance
java-service-compliance/
├── README.md
├── controls
│ └── example.rb
├── inspec.yml
└── libraries
Den Test controls/example.rb
entfernen wir nun jedoch und erstellen eine Datei controls/service.rb
mit folgendem
Inhalt:
# copyright: 2019, cronn GmbH
title "Java service section"
control "svc-general-1.0" do
impact 0.7
title "Java Service compliance"
desc "Checks java process and process ids"
describe processes(Regexp.new("java -jar.*")) do
it { should exist }
its('users') { should_not eq ['root'] }
its('pids') { should cmp "1"}
end
end
Ein Muss für Container im Produktivbetrieb ist die Ausführung der Anwendung als unpriviligierter Benutzer. Auf keinen Fall sollten Anwendungen als root laufen. Unser Test prüft dies mit dem oben gezeigten Test. Mit Hilfe eines regulären Ausdrucks wird der Java-Prozess innerhalb des Containers identifiziert. Innerhalb dieses describe-Blocks können dann Eigenschaften des Prozesses getestet werden.
Dabei prüfen wir, ob der Prozess an sich vorhanden ist. Des Weiteren wird ausgewertet mit welchem User der Prozess
läuft. Dieser darf nicht root
sein. Letztlich prüfen wir ob die ProzessID der Anwendung 1 ist.
Nun starten wir unseren Container und überprüfen diesen anschließend mit unserem Inspec-Profil.
$ docker run -d java-service
abfd8dd0596edb92169a23585a40d9021aaf2d915187f56745aba7ecc88318fc
$ docker ps -a
abfd8dd0596e
$ inspec exec ./src/test/java-service-compliance/ -t docker://abfd8dd0596e
Inspec bietet verschiedene Möglichkeiten zum Ausführen von Profilen an. Ein laufender Docker-Container ist nur eine davon. Die Nutzung von SSH zum Ausführen der Profile auf einem beliebigen Linux-System ist ebenfalls möglich.
Als Ergebnis gibt Inspec auf der Kommandozeile eine schön formatierte Ausgabe der Testergebnisse. Sehr von Vorteil sind die Reporting-Features von Inspec. Neben HTML sind auch JSON, sowie Reports in Form von JUnit-XML möglich. Diese lassen sich sehr gut in gängige Pipelines integrieren.
Profile: InSpec Profile (java-service)
Version: 0.1.0
Target: docker://1aadd5b60f9be1aa55c3038e4d66641c6c257f81755130a897b47dc556dc937c
✔ svc-general-1.0: Java Service compliance
✔ Processes /java -jar.*/ should exist
✔ Processes /java -jar.*/ users should not eq ["root"]
✔ Processes /java -jar.*/ pids should cmp == "1"
Profile Summary: 1 successful control, 0 control failures, 0 controls skipped
Test Summary: 3 successful, 0 failures, 0 skipped
Dies ist natürlich nur ein sehr einfacher Test zur Veranschaulichung der Möglichkeiten. In unseren Projekten testen wir
unsere Anwendung noch detaillierter. Inspec bietet beispielsweise eine HTTP-Resource mit Hilfe derer sich Requests gegen
Anwendungen absetzen lassen um zu prüfen ob die Anwendung korrekt auf Anfragen antwortet oder ob nur bestimmte
HTTP-Methoden erlaubt sind. Vor allem prüfen wir jedoch HTTP-Responses auf Existenz bestimmter Header wie zum
Beispiel Strict-Transport-Security
und X-Frame-Options
.
Man muss jedoch nicht alles neu erfinden. Projekte wie Dev-Sec.io5 stellen sehr detaillierte
Profile zur Verfügung, darunter zum Beispiel linux-baseline
oder linux-patch-baseline
zur Prüfung von
Betriebssystem-Konfigurationen. Das Projekt stellt darüber hinaus aber auch Implementierungen der
CIS-Docker-Benchmark6 als Inspec-Profil zur Verfügung. Zum Testen einzelner Docker-Images ist
dieses Profil aber nur bedingt geeignet, da sie teilweise Tests für Eigenschaften des Docker-Host-Systems beinhalten auf
die man beim Erstellen des Docker-Images keinen Einfluss hat.
Weitere Prüfungsmöglichkeiten sowie die Integration in eine CI/CD-Pipeline werden wir in einem weiteren Blog-Artikel betrachten. Darin gehen wir unter anderem auch auf die Vorteile und Herausforderungen der Nutzung von Vulnerability-Scannern ein.
Links
- container-structure-test
- Don´t put Fat Jars in Docker-Images
- Docker Entrypoint Reference
- inspec - https://www.inspec.io
- dev-sec.io - https://dev-sec.io
- Center for Internet Security