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-test1 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"]
Anmerkung: Der Einfachheit halber setzen wir Fat-Jars ein. Auf eine Optimierung des Docker-Builds hinsichtlich der Layer haben wir bewusst verzichtet. Dies ist jedoch empfehlenswert[2].

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.

  1. container-structure-test - https://github.com/GoogleContainerTools/container-structure-test
  2. Don´t put Fat Jars in Docker-Images - https://phauer.com/2019/no-fat-jar-in-Docker-Image/
  3. Docker Entrypoint Reference - https://docs.docker.com/engine/reference/builder/#entrypoint
  4. inspec - https://www.inspec.io
  5. dev-sec.io - https://dev-sec.io
  6. Center for Internet Security - https://www.cisecurity.org/cis-benchmarks/