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 beimdocker-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 Taglatest
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 daslatest
-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
Vielen Dank für deinen Kommentar! :-)
Da alle Kommentare von Hand bearbeitet werden,
gedulde dich bitte, bis der Kommentar freigeschaltet wird.
Formular nicht richtig ausgefüllt.
Oops... da ist was schiefgelaufen :-(
Dein Kommentar konnte nicht gespeichert werden.
Bitte probier es später nochmal.