GitLab Instanz mit CI/CD, Registry und Runner erstellen

GitLab ist mittlerweile unverzichtbar geworden, wenn man seine eigene CI/CD Pipeline aufbauen möchte und mit AWS, Azure oder anderen All-in-One-Cloud-Services nichts zu tun haben möchte.

Als allererstes sollte man aber vielleicht wissen, was der Vorteil von Continuous Integration/Continuous Delivery überhaupt ist und warum man sich den Aufwand machen sollte.

Wie deploye ich meine Projekte?

Deployment, oder die Online-Bereitstellung eines Projekts, kann mitunter sehr kompliziert sein. Früher, als das Internet noch in seinen Kinderschuhen steckte – also im 18. Jahrhundert – kopierte man seine HTML-Dateien einfach per Pferdekutsche oder FTP-Client auf den Server.

So stellt sich die KI DALL-E 2 ein Pferd vor, das Dateien kopiert.

Mit neuen Technolgien wie PHP, NodeJS oder Python war das aber nicht mehr so einfach. Hier und da mussten plötzlich JavaScript-Dateien minimiert, SCSS kompiliert, Daten migriert und Pommes frittiert werden. Kurzum: Ein Haufen Arbeit, nur um mal eben einen kleinen Bug zu fixen.

Einfaches Online-Bringen ist das A und O

Wenn man sich als Entwickler viel Kopfzerbrechen und abgekaute Fußnägel ersparen will, sollte man sich auch Mühe mit seiner Deployment-Pipeline geben. Denn eines kann ich versprechen: Wenn die einmal steht, macht der nächste Deploy keine Mühe mehr und kann ohne Probleme täglich stattfinden.

Es gibt viele Arten, sein Projekt zu deployen (dazu in einem anderen Beitrag später mehr), aber eines benötigt man immer: Ein Tool, das Zugriff auf den gesamten Code hat und in der Lage ist, diesen per Knopfdruck in ein produktives Produkt umzuwandeln. Das ist genau das, was der Runner macht.

Vorhang auf: GitLab

GitLab ist ein Open-Source-Tool (inkl. Premiumversion), das neben einer Quelldateiversionierung auch viele weitere Tools bereitstellt, mit denen man sicherlich, da bin ich fester Überzeugung, auch Pommes frittieren kann.

So stellt sich die KI Midjourney einen Laptop vor, der Pommes frittiert.

Um GitLab zu benutzen, sollte man sich also bereits mit Git auskennen. Und wenn man sich schon mit Git auskennt, kennt man vermutlich schon GitLab und alles, was ich oben geschrieben habe, ist nichts Neues mehr. Also schwafeln wir nicht weiter rum und kommen endlich zum Punkt!

Alles schön verpackt

Die Idee ist, eine GitLab-Instanz schnell aufzubauen, die nicht nur per SSL abgesichert, sondern auch mit eingebautem Runner jede Art von Projekten aufbauen kann.

In diesem Beispiel erstellen wir zwei Runner:

  • Runner-Custom: Wird über ein Dockerfile selbst gebaut, um individuelle Anforderungen zu ermöglichen. Dabei war es wichtig, dass auch der Runner einfach konfigurierbar ist. Ein PHP-Projekt hat z.B. andere Anforderungen als NodeJS und am besten kommt am Ende ein fertiges Docker-Image heraus, das man auf seine Server deployen kann.
  • Runner-Docker: Kann nur andere Docker-Images ausführen, die dann wiederum Docker-Images erstellen. Das gibt ein bisschen Gehirnknoten, macht aber irgendwann Sinn.

Hier geht es zum Docker-Compose-Git-Repository »

Das Docker-Compose File

In der docker-compose.yml finden wir folgende Container:

  • Traefik: Hiermit wird die GitLab-Instanz und die Registry automatisch per SSL nach außen abgesichert.
  • GitLab: Die eigentliche GitLab-Instanz.
  • Runner-Custom: Ein Beispiel-Runner, der aus dem /runner-custom/Dockerfile erstellt wird – anpassbar nach deinen eigenen Bedürfnissen.
  • Runner-Docker: Ein flexibler Runner, der fertige Docker-Images ausführen kann.

Die Zutaten

Neben dem o.g. Docker-Gedöns benötigt man natürlich einen Server, auf dem man o.g. Dateien auch ausführen kann. Hier empfielt sich z.B. CPX31 von Hetzner mit dem Image Docker CE.

Achtung: Der Server sollte am besten 8GB Ram und 4 Cores haben. Alles andere macht einem das Leben schwer; tatsächlich läuft GitLab gar nicht erst unter 2GB Ram.

Außerdem sollte man sich zwei Domains reservieren: Eine für GitLab und eine für die Registry, die später erstellter Docker-Images bereitstellt. Man kann auch alles in einer Domain laufen lassen, muss dann aber für die Registry einen anderen Port nehmen, was die URL unschön macht.

Unter Umständen (also nur, wenn man schwanger ist), ist eine Firewall empfehlenswert, damit nicht jeder Schindluder damit betreibt.

Dies ist ein Astronautenhund. Hat nichts mit dem Thema zu tun und dient nur als Auflockerung.

Konfiguration von Traefik

Traefik wird dazu benutzt, um als Eingangstor von außen zum Server zu fungieren. Traefik leitet den Traffic von außen als nach innen weiter und hört normalerweise auf die Ports 80 und 443.

Idealerweise kümmert es sich auch um SSL, d.h. man hat mit der Einrichtung von Let´s Encrypt nichts am Kopf, solange man mindestens einen der beiden Ports nach außen öffnet. Ja, das ist blöde. Wenn man schon ein internes Netzwerk hat, das man per VPN absichert, ist das natürlich nicht das Gelbe vom Ei.

Wenn man seine Sicherheitsansprüche herunterschraubt, kann man Port 80 nach außen freigeben. Man sorgt dann dafür, dass dieser Port nur für die Zertifikatsverifizierung genutzt wird. Will man das aber nicht, so kann man GitLab natürlich auch mit seinem eigenen SSL-Zertifikat füttern.

Hier die Konfiguration:

  traefik:
    # Name des Containers
    container_name: "${COMPOSE_PROJECT_NAME}_traefik"

    # Traefik docker image
    image: traefik:v2.8

    command: 
      # WebUI starten und auf Docker hören
      - "--api.insecure=true" 
      - "--providers.docker"
      - "--providers.docker.exposedbydefault=false"

      # Entrypoints definieren; dies sind die offenen Port-Tore nach außen.
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.address=:80"

      # FÜR HTTP Challenge über 80
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"  

      # FÜR TLS Challenge über 443
      # - "--certificatesresolvers.le.acme.email=${RESOLVER_EMAIL}"
      # - "--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json"
      # - "--certificatesresolvers.le.acme.tlschallenge=true"  

      # HTTP zu HTTPS weiterleiten (geht nicht, wenn man Port 443 geschützt hat)
      # - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
      # - "--entrypoints.web.http.redirections.entrypoint.scheme=https"

     ports:
      # HTTP-Port
      - "80:80"
      # HTTPS-Port
      - "443:443"
      # Die Web UI (aktiviert durch --api.insecure=true)
      - "8080:8080"
      
    volumes:
      # Traefik benötigt Zugriff auf Docker des Hosts
      - ${DOCKER_SOCK}:/var/run/docker.sock
      # Zertifikate in ein Volume speichern
      - traefik-le-volume:/letsencrypt

Zeile 9-12: Traefik soll auf dem Server auf andere Docker-Container hören. Wie man Traefik Bescheid gibt, dass es bestimmte Domains absichern soll, steht weiter unten.

Zeile 14-16: Traefik benötigt Namen für die sogenannten Entrypoints. Das sind Namen für die Ports, die nach außen freigegeben sind. In unserem Fall 80 (HTTP) und 443 (HTTPS).

Zeile 18-19: Wir müssen Let`s Encrypt mitteilen, wie es die Domain authentifizieren soll. Das geht über mehrere Arten. Hier entscheiden wir uns für den Port 80, weil Port 443 später über eine Firewall abgesichert ist. Port 80 ist auf dem Server für nichts anderes als die Domain-Authentifizierung zuständig.

Zeile 21-24: Als Beispiel auch noch mal die Variante über Port 443.

Zeile 26-28: Zur Vollständigkeit eine Möglichkeit, wie man Traefik anweist, eine Weiterleitung von HTTP zu HTTPS zu ermöglichen. Unnötig für diesen Anwendungsfall, fand ich aber wichtig zu wissen.

Zeile 30-36: Traefik hört auf dem Server auf die Ports 80, 443 und – für die WebUI – 8080. Letzteres kann man aber deaktivieren, wenn man möchte.

Zeile 38-42: Traefik benötigt Zugriff auf Docker des Hosts; außerdem macht es Sinn, die Zertifikate in ein Volume zu speichern, damit diese nicht immer bei einem Neustart neu erstellt werden müssen.

Konfiguration von GitLab

  gitlab:
    # Name des Containers
    container_name: "${COMPOSE_PROJECT_NAME}_gitlab"

    # Feste Version
    image: 'gitlab/gitlab-ee:15.2.2-ee.0'

    # Der Name von GitLab
    hostname: "${GITLAB_HOSTNAME}"

    # GitLab Einstellungen
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # Externe URL festlegen
        external_url 'https://${GITLAB_HOSTNAME}'

        # Gitlab soll nur über HTTP zuhören
        nginx['listen_https'] = false
        nginx['listen_port'] = 80     
        nginx['proxy_set_headers'] = {
          "X-Forwarded-Proto" => "https",
          "X-Forwarded-Ssl" => "on"
        }           

        # Probleme mit Traefik beheben
        # https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5560
        registry_external_url 'https://${GITLAB_REGISTRY}'
        gitlab_rails['registry_enabled'] = true
        gitlab_rails['registry_api_url'] = 'https://${GITLAB_REGISTRY}'
        registry['enable'] = true
        registry_nginx['enable'] = false
        registry['registry_http_addr'] = "0.0.0.0:5000"


        # SSH Port anpassen
        gitlab_shell_ssh_port=${GITLAB_SSH_PORT}

        # Email-Einstellungen
        gitlab_rails['smtp_enable'] = true
        gitlab_rails['smtp_address'] = "${SMTP_ADDRESS}"
        gitlab_rails['smtp_port'] = ${SMTP_PORT}
        gitlab_rails['smtp_user_name'] = "${SMTP_USER_NAME}"
        gitlab_rails['smtp_password'] = "${SMTP_PASSWORD}"
        gitlab_rails['smtp_domain'] = "${SMTP_DOMAIN}"
        gitlab_rails['smtp_authentication'] = "${SMTP_AUTHENTICATION}"
        gitlab_rails['smtp_enable_starttls_auto'] = true
        gitlab_rails['smtp_tls'] = false
        gitlab_rails['gitlab_email_from'] = "${SMTP_EMAIL_FROM}"
        gitlab_rails['gitlab_email_reply_to'] = "${SMTP_EMAIL_REPLYTO}"

    labels:
      # Traefik aktivieren
      - "traefik.enable=true"

      # GitLab-Domain konfigurieren
      - "traefik.http.routers.gitlab.rule=Host(`${GITLAB_HOSTNAME}`)"
      - "traefik.http.routers.gitlab.entrypoints=websecure"
      - "traefik.http.routers.gitlab.tls=true"
      - "traefik.http.routers.gitlab.tls.certresolver=le"
      - "traefik.http.routers.gitlab.service=gitlab"
      - "traefik.http.services.gitlab.loadbalancer.server.port=80"

      # Registry-Domain konfigurieren
      - "traefik.http.routers.gitlab-registry.rule=Host(`${GITLAB_REGISTRY}`)"
      - "traefik.http.routers.gitlab-registry.entrypoints=websecure"
      - "traefik.http.routers.gitlab-registry.tls=true"
      - "traefik.http.routers.gitlab-registry.tls.certresolver=le"
      - "traefik.http.routers.gitlab-registry.service=gitlab-registry"
      - "traefik.http.services.gitlab-registry.loadbalancer.server.port=5000"

    ports:
      - '${GITLAB_SSH_PORT}:22'

    volumes:
      - 'gitlab-config-volume:/etc/gitlab'
      - 'gitlab-logs-volume:/var/log/gitlab'
      - 'gitlab-data-volume:/var/opt/gitlab'

    depends_on:
      - "traefik"

OK… Diese Einstellungen haben mich Stunden meines Lebens gekostet. Es gibt viele Tutorials da draußen, aber anscheinend bin ich der Erste, der GitLab und die Registry hinter Traefik versteckt, ohne eine eigene Registry-Instanz aufzumachen. Ich wäre lieber der Erste, der ein Heilmittel gegen Krebs entdeckt, aber das hier ist doch auch ganz schön.

Zeile 13-49: Diverse Einstellungen für GitLab selbst. Man kann auch eine Config-Datei benutzen, aber die Omnibus-Config fasst alles sehr schön direkt im Dockerfile zusammen. Eine Herausforderung war es, dass GitLab und die Registry auf Port 80 hören, aber trotzdem nach außen über HTTPS erreichbar sind. STUNDEN MEINES LEBENS!!

Zeile 51-69: So wird Traefik klar gemacht, dass dieser Container mit einer Domain von außen erreichbar sein soll. Als Entrypoint fungiert websecure (siehe Traefik-Config oben), also Port 443. Da wir mehrere Ports anschließen (Registry hat eigentlich Port 5000) nutzen wir die Funktion loadbalancer.server.port, damit Traefik weiß, welchen Port es bei diesem Container bei welcher Domain ansprechen soll.

Anmerkung: Wir installieren hier die ältere Registry. Es gibt bereits eine neue “Next-Generation Container Registry“, die einfacher zu verwalten ist.

Konfiguration der Runner

“Lauf, GitLab, lauf!”

Der Runner ist ein eigenständiger Container, der unseren Code einsatzbereit macht. Das kann ALLES mögliche sein (mehr Infos in einem eigenen Artikel). Wichtig ist, folgendes zu wissen:

  • Der Runner kann Docker-Images erstellen. Dazu muss er als privileged gestartet werden (Docker-in-Docker).
  • Was der Runner kann, ist in einem eigenen Buildfile konfiguriert (/runner/Dockerfile). In unserem Fall kann er etwas PHP und NodeJS. Der große Vorteil vom Dockerfile ist, dass du die Anforderungen für die Pipeline immer direkt dokumentiert hast und so anpassen kannst, wie es das Projekt benötigt.
  runner-custom:
    # Name des Containers
    container_name: "${COMPOSE_PROJECT_NAME}_runner-custom"

    # Hostname vom Runner
    hostname: "${GITLAB_RUNNER_CUSTOM_HOSTNAME}"

    # Build-File
    build:
      context: ./runner-custom

    # Der Runner hat selbst Docker installiert
    privileged: true

    # Ein paar Volumes
    volumes:
      - gitlab-runner-custom-volume:/etc/gitlab-runner

    # Damit der Runner den Host erreichen kann
    extra_hosts:
      - "host.docker.internal:host-gateway"

    depends_on:
      - "traefik"
      - "gitlab"

    # Runner muss in eigenem Netwerk laufen, denn sonst
    # würde er die Registry IP intern auflösen und automatisch
    # auf HTTPS gehen, welches nicht konfiguriert ist.
    networks:
      - runner

Zeile 8-10: Der Runner wird aus dem Dockerfile gebaut.

Zeile 12-13: Da der Runner auch selbst Images erstellen kann, die in die Registry geladen werden können, muss er als privileged laufen.

Zeile 27-32: Auch wieder so eine Sache, die viel Nerven gekostet hat. Wenn du später über den Runner ein Image in die Registry pushen möchtest, musst du eine HTTPS-Adresse nehmen; das ist Voraussetzung der GitLab-Registry. Problem ist nur, dass diese Anfrage direkt in der Registry landet (weil interner Traffic) und diese nicht über HTTPS konfiguriert ist. Wenn der Runner aber in seinem eigenen Netzwerk steckt, nimmt er den Umweg über Traefik und alles ist superduper.

Dockerfile für den individuellen Runner

Unter /runner-custom findest du alles, was du benötigst, um ein angepasstes Runner-Image zu erstellen.

  • Dockerfile: Hier sollten alle Module installiert werden, die für das Vorbereiten deines Projekts nötig sind. In diesem Beispiel ist das ein bisschen PHP, aber auch Yarn. Ganz wichtig ist die Installation von Docker, wenn du Images erstellen möchtest.
  • entrypoint: Damit Docker auch startet, ist hier ein eigener Entrypoint erstellt worden. Das kannst du weglassen, wenn der Runner kein Image erstellen muss.
  • keys und daemon.json: Alles Dinge, die auch mit Docker in Zusammenhang stehen. Gelernt aus Fehlern. Und nein, diesen Key benutze ich nicht – und du solltest das auch nicht 🙂

Runner Default für Docker-in-Docker

Sehr flexibel bist du mit einem Runner, der selbst Docker-Images ausführen kann. Du benötigst für eine Erstellung deines Projekts also ein Docker-Image, bzw. ein Docker-File, das dein Image erstellt. Nutze dafür das Standard-Image gitlab/gitlab-runner:alpine3.19. Wie du deine Pipeline damit und kaniko erstellst, folgt in einem anderen Artikel.

All dieses ganze Zeugs auf den Host bringen

Wenn du die ganzen Dateien lokal auf deinem Rechner liegen hast, musst du nur noch eine .env-Datei erstellen. Kopiere diese aus .env.template und passe die Werte an (das sollte selbsterklärend sein).

GitLab kannst du lokal oder auch auf einem externen Host starten.

Beginnen wir lokal

Wenn du auch in Windows mit Docker arbeitest, achte darauf, dass DOCKER_SOCK in der .env richtig konfiguriert ist!

Um das Projekt lokal zu testen, starte es ganz einfach mit:

docker compose up -d

Das war es! Schau dir die Logs von GitLab an; es dauert eine Weile bis alles erreichbar ist.

Unter Windows wird vielleicht nicht alles funktionieren. Ich habe ab einer Stelle aufgegeben, es lokal zu testen… Tu es dir also gar nicht erst an!

Deploy von GitLab auf externen Host

Ich deploye Docker-Images gerne von meinem lokalen Rechner aus. Dazu lege ich eine eigene .env-Datei an, z.B. unter /env/git.meinedomain.de/.env.

Anschließend muss nur ein Context erstellt werden. Wenn man den Private Key des externen Hosts aktiviert hat (unter Windows muss die Key-Datei in C:\Users\<USER>\.ssh vorhanden sein – ein Agent wie Pageant funktioniert nicht), geht das ganz einfach:

# Context einmalig erstellen
docker context create meingitlab --docker "host=ssh://root@host.meinedomain.com"

# Alles deployen
docker --context meingitlab compose --env-file env/git.meinedomain.de/.env up -d

Etwas updaten

Wenn du eine neue Version von GitLab installierst oder andere Einstellungen vornehmen willst, benutze folgende Befehle.

# Nur GitLab updaten
docker --context meingitlab compose --env-file env/git.meinedomain.de/.env up -d --build gitLab

# Nur Runner updaten
docker --context meingitlab compose --env-file env/git.meinedomain.de/.env up -d --build runner-custom

Passwort für GitLab

Jetzt hast du dein GitLab-Haus gebaut, kommst aber nicht rein.

“Horse in front of his house, can not get in, lost keys”

Um das einmalige Installationspasswort zu erhalten, führe folgendes lokal aus:

docker --context meingitlab exec -it mein-gitlab_gitlab grep 'Password:' /etc/gitlab/initial_root_password

Du kannst dich natürlich auch auf deinen Host einloggen, und den Befehl dort ausführen.

Runner registrieren

Der Runner läuft an sich erstmal alleine und GitLab weiß gar nichts davon. Deshalb geben wir GitLab Bescheid, dass es einen Runner gibt. Gehe dazu auf “Admin > Overview > Runners”, dann “New instance runner”.

In den GitLab-Versionen vor 16.0 musstest du einen “Registration Token” kopieren. Das ist allerdings nicht mehr der Fall.

Als nächstes gibst du dem Runner einen Tag, um ihn später in der CI/CD-Pipeline ansprechen zu können.

Anschließend erhälst du einen Authentication-Token. Diesen benutzt du im GitLab-Runner, um ihn zu registrieren. Zur Abwechslung machen wir das mal direkt auf dem Host:

# In den Custom Runner einloggen
docker exec -it meingitlab_runner-custom /bin/bash

# Den Runner registrieren
gitlab-runner register --url https://git.meinedomain.com --token <TOKEN> --executor shell --description "Runner Custom" -n

# In den Docker Runner einloggen
docker exec -it meingitlab_runner-docker /bin/bash

# Den Runner registrieren
gitlab-runner register --url https://git.meinedomain.com --token <TOKEN> --executor docker --description "Runner Docker" -n

Ganz wichtig ist es in unserem Fall, den Executor shell zu wählen, damit alles direkt in diesem Container ausgeführt werden kann. Alternativ kann man in der CI/CD-Pipeline auch ein Docker-Image wählen, mit dem der Runner arbeiten soll (das also jedes mal gestartet wird), aber das wäre im Prinzip das Gleiche in unserem Fall.

Der Runner ist jetzt in dem o.a. Menüpunkt zu finden und einsatzbereit.

GitLab updaten

Um GitLab auf die neueste Version zu bringen, musst du eigentlich nur das Image anpassen. Aber aufpassen: Du musst dafür einen “Upgrade Pfad” einhalten, den du hier findest.

Das Tool hilft dir auch, bestimmte Hinweise zu beachten, wie z.B. das hier:

Eine CI/CD Pipeline bauen

Wie baut man denn jetzt sein Projekt für das Deployment? War das nicht der Sinn dieses Artikels? Nein. Doch. Vielleicht. Aber irgendwann muss ja auch mal Schluss sein.

Die verschiedenen Arten des Deployments werden in einem eigenen Artikel folgen. Bis dahin: Kümmer dich um dein Pferd!

GitLab automatisch warten

Wenn man GitLab einige Zeit laufen lässt, sammeln sich viele Daten an. Deshalb sollte man folgendes beachten:

  • Die Docker-Logs rotieren und begrenzen (GitLab spammt die Logs voll)
  • Die GitLab-Registry aufräumen (in der neuen Version kann man das auch über die Web-Oberfläche)
  • Den Docker-Build-Cache des Hosts löschen (der baut sich auf, wenn man Images über CI/CD erstellt)

Docker Logs rotieren

Die Gitlab-Container sorgen für richtig große Logfiles, die irgendwann deine Platte zulaufen lassen! Du findest diese unter /var/lib/docker/containers. Damit diese ein bisschen gebändigt werden, legst du einfach diese Config für Docker an:

/etc/docker/daemon.json

{
  "log-driver": "json-file",
  "log-opts": {
	"max-size": "50m",
	"max-file": "3"
  }
}

Danach lädst du Docker mit systemctl restart docker neu. Aber Achtung: Die Einstellung gilt nur für neue Container! Du musst dein Gitlab danach also nochmal erstellen.

GitLab-Registry und Docker-Build-Cache

Um hier ein bisschen Festplatte zu sparen, führst du folgende Befehle in einem CronJob aus. Die sorgen dafür, dass die Registry nicht zu groß und der Build-Cache regelmäßig gelöscht wird.

0 0 * * * /usr/bin/docker exec -it meingitlab gitlab-ctl registry-garbage-collect -m
0 1 * * * /usr/bin/docker builder prune -f

Björn Falszewski
24. September 2022
Disclaimer
Alle meine Artikel entstehen mit bestem Wissen und Gewissen, sind aber nicht perfekt und sollten immer nur als Ausgangspunkt für deine eigenen Recherchen bilden.

Sollte dir etwas Fehlerhaftes auffallen, freue ich mich über deine Nachricht!