theHacker's Blog
– It's just a glitch in the Matrix –

Continuous Delivery mittels GitLab-CI und Docker

Im letzten Artikel habe ich euch gezeigt, wie man eine Docker-Anwendung automatisch mit Docker Compose und GitLab-CI bauen kann.

In diesem Beitrag zeige ich euch, wie ihr eine Docker-Umgebung aufbaut und mit GitLab-CI eure Docker-Anwendungen automatisch in diese deployen lassen könnt.

Vorüberlegung zur Verwaltung der Docker-Images

Um später Docker-Container mit den Anwendungen zu starten, müssen wir die Anwendungen in Form von Docker-Images zur Verfügung stellen. Die Frage, die sich uns hierbei stellt: Von wohin kommen diese Images?

Docker-Images werden aus einer Docker-Registry gezogen. Standardmäßig nutzt Docker den Docker Hub. Dies ist die öffentliche Registry, von der wir normalerweise unsere Images beziehen. Da wir aber private Software entwickeln, die wir nicht auf den Docker Hub pushen wollen, brauchen wir auch eine private Registry. Für unseren Docker-Host werde ich die Registry später ganz einfach als Container direkt auf diesem Host laufen lassen.

Installation von Docker

Wir beginnen mit einer frischen Installation von Ubuntu 18.04.

Ich aktualisiere zuerst das System:

apt update
apt upgrade

Installation der Docker Engine

Danach installiere ich Docker gemäß der Installationsanleitung von Docker:

apt-get install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

add-apt-repository \
   "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

apt update
apt-get install docker-ce docker-ce-cli containerd.io

Das wars auch schon. Wir prüfen die Installation:

root@docker:~# docker version
Client: Docker Engine - Community
 Version:           19.03.5
 API version:       1.40
 Go version:        go1.12.12
 Git commit:        633a0ea838
 Built:             Wed Nov 13 07:29:52 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.5
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.12
  Git commit:       633a0ea838
  Built:            Wed Nov 13 07:28:22 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.10
  GitCommit:        b34a5c8af56e510852c35414db4c1f4fa6172339
 runc:
  Version:          1.0.0-rc8+dev
  GitCommit:        3e425f80a8c931f88e6d94a8c831b9d5aa481657
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

Installation von Docker Compose

Um später die Anwendungen bequem mit einer docker-compose.yml verwalten zu können, installieren wir nun auch noch Docker Compose. Auch hier halte ich mich wieder an die Installationsanleitung von Docker. Eine kleine Anpassung: Statt 1.25.3 verwende ich die aktuelle Version 1.25.4, die noch gar nicht im Handbuch steht.

curl -L "https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

Auch hier wieder zum Schluss ein Funktionstest:

root@docker:~# docker-compose version
docker-compose version 1.25.4, build 8d51620a
docker-py version: 4.1.0
CPython version: 3.7.5
OpenSSL version: OpenSSL 1.1.0l  10 Sep 2019

Vorbereiten der Anwendungen

Mein Ziel ist es, auf dem Docker-Host im Verzeichnis /srv/docker für alle Anwendungen, die dieser Host ausführt, entsprechende docker-compose.yml-Dateien vorzuhalten, um die Anwendungen bequem starten, aktualisieren und beenden zu können. Ich möchte keine Befehlszeilen zu docker selber schreiben. Später wird dies für komplexere Anwendungen mit mehreren Containern, Volumes und Netzwerken sehr von Vorteil sein.

Pro Anwendung lege ich ein Unterverzeichnis mit einer docker-compose.yml an. Theoretisch könnte man alle Dateien unterschiedlich benennen und im selben Verzeichnis lassen, das hat aber zwei Nachteile:

  • Docker Compose nimmt den Namen des aktuellen Verzeichnisses als Projektname. Würden alle Dateien in der Form docker-compose-project-x.yml im Verzeichnis /srv/docker liegen, würde der Projektname immer "docker" sein. Das wäre sehr verwirrend.
  • Heißen die YAML-Dateien nicht docker-compose.yml, so müssen wir sie immer mit -f explizit beim docker-compose-Aufruf mitübergeben.

Eine Anmerkung hierzu: Ob diese Vorgehensweise gut oder schlecht ist, kann ich zu diesem Zeitpunkt noch nicht sagen, da ich mit Docker noch ein wenig in den Startlöchern steh und hier noch keine konkreten Empfehlungen abgeben kann.

Für die nachfolgenden Schritte lege ich also ein Verzeichnis an:

mkdir /srv/docker

Installation von Portainer

Info: Dieser Schritt ist optional, ist aber extrem hilfreich, um sich nicht mit zu vielen Docker-Befehlen rumquälen zu müssen, wenn mal was doch nicht so klappt oder ihr etwas ausprobieren wollt. Ihr könnt ihn gerne überspringen.

Portainer ist eine Web-Oberfläche, mit der man seine Container, Images, u. v. m. rundum Docker bequem im Browser steuern kann, statt auf der Konsole Docker-Kommandozeilen tippen zu müssen.

Mit mkdir /srv/docker/portainer lege ich mir für die Anwendung ein neues Unterverzeichnis an. Innerhalb des Verzeichnis erstelle ich eine docker-compose.yml mit folgendem Inhalt:

version: "2"
services:
  portainer:
    image: portainer/portainer
    command: --no-analytics
    ports:
      - "9000:9000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - data:/data

volumes:
  data:

Nun kann ich Portainer ganz bequem starten:

cd /srv/docker/portainer
docker-compose up -d

Docker Compose lädt nun die benötigten Images aus dem Internet, legt ein Volume und ein Netzwerk an und startet damit den Container. Im Browser kann ich unter Port 9000 die Weboberfläche auf dem Docker-Host erreichen.

Vorsicht: Portainer sammelt normalerweise Statistiken mittels Google Analytics! Da ich finde, dass Google das nichts angeht, verwende ich den Switch --no-analytics, um die Einbindung von Google Analytics zu verhindern.

Einmalig beim ersten Start legt man Zugangsdaten für einen Administrator an und wählt die Verbindung zum Docker-Daemon aus ("Local", da wir das Socket gebunden haben).

Installation der Docker-Registry

Nun zur Registry, in der wir unsere Images ablegen. Ich lege wieder ein neues Verzeichnis an, erstelle eine docker-compose.yml und starte dann anschließend den Container.

mkdir /srv/docker/registry
cd /srv/docker/registry
vim docker-compose.yml
docker-compose up -d

Der Inhalt der docker-compose.yml:

version: "2"
services:
  registry:
    image: registry:2.7.1
    ports:
      - "5000:5000"
    volumes:
      - data:/var/lib/registry

volumes:
  data:

Demo-Projekt aufsetzen

Um den Docker-Host und das Deployment zu testen, erstelle ich nun ein Demo-Projekt. Zur Demonstration genügt, einfach nur eine statische Webseite anzuzeigen.

Phase 1: lokale Entwicklung

Ich lege im GitLab ein neues Projekt an, öffne es in meinem IntelliJ und erstelle lediglich drei Dateien:

Eine index.html, die wir später im Browser sehen werden:

<!DOCTYPE HTML>
<html lang="de">
    <head>
        <meta charset="UTF-8" />
        <title>Hello Continuous Delivery mit GitLab-CI und Docker</title>
    </head>
    <body>
        <h1>Hello World</h1>
        <p>Ich wurde mit GitLab-CI gebaut und automatisch deployt.</p>
    </body>
</html>

Ein Dockerfile, mit wir uns einen Apache-Webserver holen und unsere HTML-Datei ausliefern werden:

FROM httpd:2.4.41-alpine
COPY *.html /usr/local/apache2/htdocs/

Und eine docker-compose.yml, mit der wir unsere Anwendung beschreiben. Wir vergeben hier einen Image-Namen und definieren, dass der Port 80, den uns der Webserver gibt, nach außen auf Port 8080 geschaltet wird:

version: "2"
services:
  app:
    build: .
    image: cd-mit-docker
    ports:
      - "8080:80"

Ich kann nun mit einem Einzeiler lokal die Anwendung starten. Docker Compose wird sogar das Image automatisch bauen, weil er beim ersten Mal merkt, dass es noch nicht existiert.

docker-compose up -d

Danach ist unsere Anwendung mit einem Browser aufrufbar:

Zwischenschritt: Unsichere Registry freischalten

Um diese Anleitung möglichst einfach zu halten, verzichte ich auf die nötige TLS-Konfiguration für die Registry. Docker ist in dieser Hinsicht sehr strikt und erlaubt nur Zugriff auf Registries über HTTPS und mit ordentlichen Zertifikaten.

Normalerweise müssten wir uns entsprechende Zertifikate besorgen, diese in den Registry-Container mounten und entsprechende Einstellungen diesbezüglich machen.

Um mir das zu sparen, werde ich die Registry als "insecure registry" in Docker konfigurieren. Infos hierzu gibt es hier.

Wichtig: Die nachfolgenden Einstellungen bitte niemals im Produktivbetrieb machen! Sie erlauben jedem, beliebige Images auf die Registry zu pushen.

Ich gehe davon aus, dass wir einen GitLab-Runner mit Shell-Executor und installiertem Docker-Daemon haben, wie im Artikel GitLab-Runner mit Docker-Executor installieren beschrieben.

Wir loggen uns nun auf dem GitLab-Runner ein und bearbeiten als root-Nutzer die Datei /etc/docker/daemon.json. Wenn sie noch nicht existiert, legen wir sie an. Ich füge den folgenden Inhalt ein:

{
  "insecure-registries": [
    "docker:5000"
  ]
}

docker ist hierbei der Name des Docker-Hosts. Die Portnummer 5000 ist die, die wir für die Registry vergeben haben

Danach müssen wir den Docker-Daemon neustarten, damit die Einstellung greift:

service docker restart

Dieselbe Prozedur machen wir danach auch nochmal auf dem Docker-Host. Nach dem Neustart des Docker-Diensts auf dem Docker-Host müssen wir unsere bestehenden Anwendungen (Portainer und Registry) mit docker-compose up -d wieder starten.

Phase 2: Bauen mit GitLab

Ich füge nun die Konfigurationsdatei für GitLab-CI .gitlab-ci.yml ins Projekt hinzu:

stages:
  - build

build:
  stage: build
  tags:
    - shell
    - docker-daemon
  script:
    - docker-compose build
    - docker tag cd-mit-docker docker:5000/cd-mit-docker
    - docker push docker:5000/cd-mit-docker

Nachdem wir diese Datei in die Versionsverwaltung eingecheckt und zum GitLab gepusht haben, wird GitLab-CI die Anwendung bauen, ein Docker-Image erzeugen und in unsere Registry pushen.

Wir können dies verifizieren, indem wir die Registry im Browser öffnen: http://docker:5000/v2/_catalog

Zwischenschritt: SSH-Zugang zum Docker-Host freischalten

Um am Ende das Image aus der Registry als Container ausführen zu können, müssen wir dem GitLab-Runner erlauben, den entsprechenden docker-compose-Befehl auszuführen.

Wir kopieren hierzu den SSH-Key vom GitLab-Runner auf den Docker-Host, damit dieser sich einloggen kann. Auf dem GitLab-Runner führen wir folgende Befehlszeilen aus:

su gitlab-runner
ssh-copy-id root@docker

Wir müssen daraufhin bestätigen, dass wir den korrekten Server haben und das Passwort eingeben. Nach dieser Prozedur kann sich GitLab ohne Passwort auf dem Docker-Host einloggen.

Wichtig ist es, vorher auf den gitlab-runner-Nutzer zu wechseln, da dieser Nutzer später derjenige sein wird, den GitLab-CI zum Deployment verwendet.

Wie üblich testen wir diesen Zwischenschritt, bevor wir fortfahren. Als gitlab-runner-User tippen wir die Befehlszeile ssh root@docker. Wir müssen danach ohne Passwortabfrage auf dem Docker-Host eingeloggt sein.

Phase 3: Deployment mit GitLab

Nun richten wir unsere Anwendung auf dem Docker-Host ein. Zum dritten Mal erstellen wir eine eigene docker-compose-Konfiguration:

mkdir /srv/docker/cd-mit-docker
cd /srv/docker/cd-mit-docker
vim docker-compose.yml

In der docker-compose.yml verwenden wir diesmal direkt den Port 80, um die Demo-Seite im Browser auch ohne Portangabe aufrufen zu können. Das Image beziehen wir aus unserer Registry:

version: "2"
services:
  cd-mit-docker:
    image: docker:5000/cd-mit-docker
    ports:
      - "80:80"

Als letzten Schritt ergänzen wir unsere CI-Konfiguration um einen Deploy-Job, der sich per SSH einloggt und die docker-compose-Zeile ausführt:

stages:
  - build
  - deploy

build:
  stage: build
  tags:
    - shell
    - docker-daemon
  script:
    - docker-compose build
    - docker tag cd-mit-docker docker:5000/cd-mit-docker
    - docker push docker:5000/cd-mit-docker

deploy:
  stage: deploy
  tags:
    - shell # Shell-Executor fordern, weil nur der mit SSH-Key auf den Server kommt
  script:
    - ssh root@docker "cd /srv/docker/cd-mit-docker && docker-compose pull"
    - ssh root@docker "cd /srv/docker/cd-mit-docker && docker-compose up -d"
    - ssh root@docker "docker image prune -f"

Ihr seht hier nun drei Zeilen, die ich auf dem Docker-Host ausführe und nicht – wie wohl erwartet – nur eine Zeile. Zur Erklärung:

  • Wir führen zuerst ein docker-compose pull aus. Dies weist Docker an, das Image nochmal von der Registry zu holen, selbst wenn es schon lokal da ist. Da wir uns in diesem Artikel überhaupt nicht um irgendwelche Versionsnummer gekümmert haben, sind alle Docker-Images immer mit dem Tag latest gekennzeichnet. Würden wir eine Änderung am Quellcode machen, würde zwar das neue Image an die Registry gepusht werden, beim Deployment würde Docker aber sagen "Hey, cd-mit-docker:latest hab ich doch schon" und nicht die neue Version holen, sondern nochmal die alte Version deployen.
  • docker-compose up -d startet bzw. aktualisiert den Container. Diese Zeile ist die Magic, die wir schon die ganze Zeit verwendet haben. Nichts Neues hier.
  • docker image prune -f weist Docker an, alle Images zu löschen, die nicht mehr in Verwendung sind. Jedes Mal, wenn wir eine neue Version beim Deployment aus der Registry holen, bleibt die alte Version auf der Festplatte liegen, da Docker intern nur Hashes benutzt, nicht die Tags. Irgendwann läuft uns dann die Festplatte voll. Docker hat normalerweise eine Sicherheitsabfrage drin, die wir mit -f übergehen.

Das wars! Wir können nun die Anwendung im Browser unter http://docker/ aufrufen und sehen unsere Demo-Seite.

Hier Bilder, wie das Projekt am Ende aussieht und wie Portainer unsere Container auflistet, die alle laufen:

Weitere Entwicklung am Quellcode

Ich ändere kurz die index.html, um zu zeigen, dass wir mit unseren Setup nun nichts mehr tun müssen. Nachdem ich die Änderung eingecheckt und gepusht habe, läuft wieder alles automatisch ab. Wenige Minuten später können wir die Änderung auf dem Docker-Host sehen.

Im Docker-Job sehen wir nun auch die Freigabe von Speicher aus dem vorherigen Image, das nun nicht mehr benötigt wird. Man erkennt, dass Docker die Images sehr performant ablegt und durch das Layering wirklich nur die Dateien innerhalb eines Images anfallen, die sich wirklich geändert haben. Es wurden 293 Bytes freigegeben, was genau der Dateigröße der ersten Version der index.html entspricht:

Zusammenfassung

Zum Schluss nochmal in Stichpunkten, was wir in diesem Artikel nun alles geschafft haben:

  • neuen Host mit Ubuntu, Docker und Docker Compose installiert
  • Portainer als Container gestartet
  • eine Docker-Registry als Container gestartet
  • ein Demo-Projekt mit einer statischen Webseite aufgesetzt
  • GitLab-CI so konfiguriert, dass automatisch
    • ein Docker-Image gebaut wird,
    • das Image in unsere Registry auf dem Docker-Host gepusht wird,
    • mittels Docker Compose das Image als Container auf dem Docker-Host gestartet/aktualisiert wird und
    • die Demo-Webseite unter Port 80 auf dem Docker-Host zur Verfügung gestellt wird.

Ausblick

Wie man schon an der obigen Zusammenfassung sieht, waren in diesem Artikel jede Menge Aufgaben zu erledigen, um schlussendlich das automatische Deployment auf dem Docker-Host einzurichten.

Es gibt aber auch noch viele Punkte, die ich bewusst offen gelassen habe und die sicher Bestandteil weiterer Artikel werden:

  • Wir haben uns mit einer unsicheren Docker-Registry begnügt, die weder TLS anbietet, noch Authentifizierung anbietet.
  • Ich bin mir mit meinem Ansatz, auf den jeweiligen Zielhosts separate docker-compose.yml-Dateien zu halten, noch nicht sicher, ob dieser Ansatz gut ist. Das muss ich in der Praxis auch erst noch erproben.
  • Wir brauchen ein zusätzliches docker-compose pull, da wir keinerlei Versionsnummern haben und immer das latest-Tag am Image verwenden.
  • In diesem Artikel hatten wir nur eine einzige Web-Anwendung für den Endnutzer auf dem Docker-Host, weshalb wir am Ende einfach Port 80 nutzen konnten. Wollen wir weitere Anwendungen, müssten wir aktuell immer einen anderen Port nutzen.
  • Statt SSH-Zugang zum Docker-Host könnten wir auch direkt den Docker-Daemon kontaktieren, wenn wir diese Option freischalten.
  • Nach dem Neustart des Docker-Daemons oder gar des ganzen Hosts müssen wir unsere Anwendungen alle nochmal erneut starten.

War dieser Artikel hilfreich? Verwendest du auch Docker? Wie bringst du deine Anwendungen an den Start? Welchen Workflow verwendest du zum Bauen und Ausliefern?

Hinterlass gerne einen Kommentar.

Kommentare zu diesem Artikel

Schreib einen Kommentar zum Artikel

CAPTCHA Das Internet ist leider voller Bots. 🙁 Bitte gib den obenstehenden Code ein.
Falls du den Code nicht lesen kannst oder dir unsicher bist, klick einfach hier, um einen neuen Code zu generieren.

Mit Abschicken des Formulars bestätigst du,
die Datenschutz-Infos gelesen zu haben.