Laravel CI – Ein Tutorial mit Docker und Gitlab

Ein nicht unbeachtlicher Anteil unserer Projekte basieren auf dem Laravel Framework, welches wir bereits in diesem Blogeintrag vorgestellt haben. Unser Entwicklungsablauf beinhaltet fast immer die Bereitstellung eines Staging-Systems, auf welchen wir unseren Kunden die neusten Änderungen demonstrieren, ohne dabei das Live-System anfassen zu müssen. Die Bereitstellung der Laravel Installationen auf unseren Staging-Systemen ist dabei allerdings nicht immer ganz einfach. Eine Laravel Webanwendung kann ganz verschiedene Anforderungen an die Server Umgebung haben. Mal wird PHP Version 7.0 benötigt, mal aber 5.6. Es werden unterschiedliche Build-Tools benötigt (npm, composer, gulp usw.), es werden unterschiedliche Datenbanken angebunden und es werden projektspezifische Programme benötigt – zum Beispiel das html2pdf  Programm, um einfach PDF Dateien zu erzeugen. Außerdem haben wir einen sich wiederholenden Aufwand für jedes Deployment auf dem Staging Server.

Wir wollen die Software schneller und einfacher ausliefern können. Wie cool wäre es also diesen ganzen Prozess zu automatisieren und in unser bestehendes System zu integrieren?

Deshalb haben wir uns daran gesetzt, ein allgemeines Vorhensmodell zu schaffen, Laravel-Projekte einfach über ein Continious Deployment Prozess auszuliefern. Unsere Umsetzung dieser Automatisierung durch die Open Source Software Docker und Gitlab stellen wir hier vor. Dieser Artikel richtet sich vor allem an Entwickler, Administratoren und technisch versierte Leser.

Architektur

Zuerst erstellen wir eine reproduzierbare Entwicklungsumgebung, die leichtgewichtig, schnell und unabhängig ist von global installierter Software auf dem Server. Deshalb kapseln wir alle Komponenten einer Anwendung in Docker-Container. Das heißt auf unserem Server gibt es keine global installierte Software wie PHP, Apache, Composer, Node, NPM… Wir wollen eine andere PHP Version? Mit Docker können wir die ganze Umgebung einfach zerstören, neu konfigurieren und neu hochziehen innerhalb von Sekunden. Alle Container sind unabhängig voneinander.

 

Außerdem will man als Entwickler gewöhnlich mehr als eine Anwendung auf dem Server bereitstellen. Läuft bereits eine Web-App auf Port 80 kann keine andere App diesen Port benutzen. Eine gute Architektur ist hier die Apps über Port 80 durch spezifische Subdomains bereit zu stellen.

 

Diese Anforderung lässt sich mit einem (Reverse) Proxy umsetzen. Wir nutzen hierfür nginx-proxy von Jason Wilder. Nginx-proxy enthält Docker-gen, ein kleines Tool welches die Konfiguration automatisch aus den Meta-Daten der Docker Container generiert. Dazu nutzt es die Docker API. Die beiden Anwendungen laufen selbst in einem Docker Container und sind schnell zum Laufen gebracht.

Um den Nginx-Proxy zu starten muss einfach folgender Kommando ausgeführt werden:

docker run -d -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock jwilder/nginx-proxy

Um einen Container über eine Subdomain auf Port 80 erreichen zu können, muss dieser die VIRTUAL_HOST Umgebungsvariable gesetzt haben. Dazu definieren wir die VIRTUAL_HOST Umgebungsvariable in der docker-compose.yml. Mehr dazu später.

Gitlab verwaltet den Laravel-Code. Sobald eine neue Veröffentlichungsversion vorliegt (in unserem Master-Branch), wird die Laravel-Anwendung vollautomatisch erstellt.

Umsetzung

Um eine multi-container Docker Anwendung zu definieren und zu starten, nutzen wir Docker Compose. Dafür erstellen wir eine docker-compose.yml Datei.

Diese Template-Datei definiert unseren drei Service für Web, App, und Datenbank. Variablen sind definiert in der Form <<VARIABLE>>. In der späteren Bereitstellung der Anwendung werden diese Teile angepasst. So lassen sich alle Variablen an einer Stelle definieren. Darunter fallen auch die benötigten Versionen von PHP, Node, NPM. Die Variablen werden später in der .gitlab-ci.yml festgelegt.

 

So sieht die docker-compose.yml aus:

version: '2'
services:
    web:
        container_name: <<CONTAINER_NAME>>_web
        build:
            context: ./
            dockerfile: web.docker
        volumes:
            - ./:/var/www
        links:
            - app
        expose:
            - 80
        environment:
            - "VIRTUAL_HOST=<<SUBDOMAIN>>.<<DOMAIN>>"
        network_mode: "bridge"
    app:
        container_name: <<CONTAINER_NAME>>_app
        build:
            context: ./
            dockerfile: app.docker
        volumes:
            - ./:/var/www
        links:
            - database
        environment:
            - "DB_PORT=3306"
            - "DB_HOST=database"
        network_mode: "bridge"
    database:
        container_name: <<CONTAINER_NAME>>_db
        image: mysql:<<MY_SQL_VERSION>>
        environment:
            - "MYSQL_ROOT_PASSWORD=<<MYSQL_ROOT_PASSWORD>>"
            - "MYSQL_DATABASE=<<MYSQL_DATABASE>>"
        network_mode: "bridge"
        ports:
            - "<<DBPORT>>:3306"

Nachfolgend gehen wir auf die einzelnen Container der Docker-Umgebung ein, welche wir für jede Anwendung einrichten.

Webserver

Der Web Container ist zuständig für die Bereitstellung statischer Daten und für die Weiterleitung von Anfragen die von der Laravel Anwendung behandelt werden. Wir nutzen dafür Nginx.

  • Der Container bekommt einen eindeutigen Namen, um ihn später aufrufen zu können.
  • Es wird auf ein separates web.dockerfile verwiesen, auf dessen Basis wir den Container bauen.
  • Wir wollen im Container auf lokale Daten zugreifen können. Über die Volumes Definition mounten wir alles aus dem aktuellen Verzeichnis in /var/www im Container.
  • Links definiert die Verbindung zum App Container
  • Expose, Environment und Network Mode definieren die Vernetzung der Container, dass der Container über einen Virtual Host erreichbar ist

Das Dockerfile legt die Installationsschritte für den Container fest.

Für den Container wäre das web.docker.

FROM nginx:<<NGINX_VERSION>>
ADD ./vhost.conf /etc/nginx/conf.d/default.conf
WORKDIR /var/www

Hier die vhost.conf

server {
    listen 80;
    index index.php index.html;
    root /var/www/public;

    location / {
        try_files $uri /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

Mit dieser Konfiguration werden PHP-Anfragen zu app:9000 weitergeleitet.

App-Container

Der App-Container ist für das Ausführen von Code innerhalb der Anwendung zuständig. Auch vorbereitende PHP-Skripte werden hier ausgeführt, wie z.B. artisan (das CLI Tool, welches mit Laravel kommt).

Dieser Container definiert die Laufzeitumgebung der Laravel-Applikationen. Je nach Laravel-Anwendung kann der App-Container leichtgewichtig sein oder bei Bedarf zusätzliche Software laden.

So sieht die app.docker Datei beispielsweise aus:

FROM php:<<PHP_VERSION>>

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends apt-utils
RUN apt-get install -y wget libmcrypt-dev mysql-client unzip gnupg libpng-dev git && docker-php-ext-install pdo_mysql
RUN apt-get install -y gcc nasm libtool dh-autoreconf

RUN curl -sS https://getcomposer.org/installer -o composer-setup.php
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer

RUN curl -sL https://deb.nodesource.com/setup_<<NODE_VERSION>>; | bash && apt-get install -y nodejs && npm i -g npm && npm -v
RUN npm install --global webpack

WORKDIR /var/www

Datenbank

Hier läuft eine MySQL-Datenbank. Wir haben hier kein Dockerfile, da wir den Container nicht weiter anpassen müssen.

Schließlich muss die Laravel Anwendung die Umgebungsvariablen kennen. Dazu passen wir die .env Datei an.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=33061
DB_DATABASE=dockerApp
DB_USERNAME=root
DB_PASSWORD=<<MYSQL_ROOT_PASSWORD>>

Container starten

Wir starten alle Container und generieren einen Application Key mit folgendem Code. Diese und weitere Anweisungen sollen im nächsten Schritt mit Gitlab Runner automatisch ausführen.

docker-compose up -d 
docker exec -i <<CONTAINER_NAME>>_app php artisan key:generate

Gitlab Runner

Gitlab Runner ermöglicht es uns Code abhängig von einem Push auf dem Server auszuführen. Wir benötigen im Root Verzeichnis die Datei .gitlab-ci.yml

variables:
  SUBDOMAIN: "sub"
  DOMAIN: "example.org"
  PHP_VERSION: "7-fpm"
  NODE_VERSION: "8.x"
  MY_SQL_VERSION: "5.6"
  NGINX_VERSION: "1.10"
  MYSQL_DATABASE: "dockerApp"

deploy_master:
  stage: deploy
  script:
     - echo "Deployment of " $CI_PROJECT_NAME
     - docker run -d -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock jwilder/nginx-proxy || echo "Proxy already running"
     - mkdir -p /home/gitlab-runner/deploy/$CI_PROJECT_NAME
     - cd ..
     - cp -a $CI_PROJECT_NAME/. /home/gitlab-runner/deploy/$CI_PROJECT_NAME
     - cd /home/gitlab-runner/deploy/$CI_PROJECT_NAME
     - chmod -R 777 storage/
     - cp docker/* .
     - cp env.docker .env
     - sed -i "s/<<SUBDOMAIN>>/$SUBDOMAIN/g" docker-compose.yml
     - sed -i "s/<<DOMAIN>>/$DOMAIN/g" docker-compose.yml
     - let "dbport=30000+$CI_PROJECT_ID"
     - sed -i "s/<<DBPORT>>/$dbport/g" docker-compose.yml
     - sed -i "s/<<MY_SQL_VERSION>>/$MY_SQL_VERSION/g" docker-compose.yml
     - sed -i "s/<<CONTAINER_NAME>>/$SUBDOMAIN/g" docker-compose.yml
     - sed -i "s/<<MYSQL_ROOT_PASSWORD>>/$DB_ROOT_PW/g" docker-compose.yml
     - sed -i "s/<<MYSQL_ROOT_PASSWORD>>/$DB_ROOT_PW/g" .env
     - sed -i "s/<<MYSQL_DATABASE>>/$MYSQL_DATABASE/g" docker-compose.yml
     - sed -i "s/<<PHP_VERSION>>/$PHP_VERSION/g" app.docker
     - sed -i "s/<<NODE_VERSION>>/$NODE_VERSION/g" app.docker
     - sed -i "s/<<NGINX_VERSION>>/$NGINX_VERSION/g" web.docker
     - docker-compose kill
     - docker-compose up -d
     - docker exec -i ${SUBDOMAIN}_app composer update
     - docker exec -i ${SUBDOMAIN}_app php artisan key:generate
     - docker exec -i ${SUBDOMAIN}_app php artisan migrate
     - docker exec -i ${SUBDOMAIN}_app sh -c 'npm install && npm run dev || (rm -rf node_modules && echo test && rm -f package-lock.json yarn.lock && npm cache clear --force && npm install && npm run dev)'
  artifacts:
    paths:
    - public/
  only:
  - master

Dieses Skript wird jedes Mal ausgeführt, wenn wir das Projekt auf dem Master Branch pushen. Je nach Bedarf kann diese Datei mit noch zusätzlichen Befehlen versehen werden.

Zu Beginn legen wir die Subdomain fest unter der die App laufen soll und definieren die Software Versionen.

variables:
  SUBDOMAIN: "sub"
  DOMAIN: "example.org"
  PHP_VERSION: "7-fpm"

Wir starten den reverse Procxy, falls dieser nicht bereits läuft.

docker run -d -p 80:80 -v /var/run/docker.sock:/tmp/docker.sock jwilder/nginx-proxy || echo "Proxy already running"

Anschließend bereiten wir die App vor. Die Dateien von Gitlab werden in einen neuen Ordner kopiert. $CI_PROJECT_NAME seht für eine vordefinierte Variable von Gitlab die den Projektnamen enthält. Wir wechseln in diesen Ordner. Der Storage Ordner erhält Berechtigungen und die docker-Template-Dateien werden in die App-Root kopiert.

Schließlich passen wir die Template-Dateien an. Alle Variablen werden nun in die docker-spezifischen Dateien geschrieben. Der Port über den der Datenbank-Container erreichbar ist, legen wir als 30000 + Projekt-ID von Gitlab fest, sodass es zu keinen Überschneidungen kommt.

sed -i "s/<<SUBDOMAIN>>/$SUBDOMAIN/g" docker-compose.yml
sed -i "s/<<DOMAIN>>/$DOMAIN/g" docker-compose.yml
let "dbport=30000+$CI_PROJECT_ID"
sed -i "s/<<DBPORT>>/$dbport/g" docker-compose.yml

Ehe wir den Docker-Container (neu-)starten

docker-compose kill
docker-compose up -d

Weiter installieren wir alle Abhängigkeiten und machen Laravel startklar. Die PHP-Pakete werden geladen, der Appliclation Key wird generiert, die Datenbank wird migriert und Node-Pakete werden installiert. Dazu führen wir folgende Shell-Befehle im App-Container aus.

docker exec -i ${SUBDOMAIN}_app composer update
docker exec -i ${SUBDOMAIN}_app php artisan key:generate
docker exec -i ${SUBDOMAIN}_app php artisan migrate
docker exec -i ${SUBDOMAIN}_app sh -c 'npm install && npm run prod || (rm -rf node_modules && rm -f package-lock.json yarn.lock && npm cache clear --force && npm install && npm run prod)'

Runner registrieren

Um dieses Skript so nutzen zu können müssen wir schließlich den Runner registrieren um Shell auszuführen.

Please enter the executor: ssh, docker+machine, docker-ssh+machine, kubernetes, docker, parallels, virtualbox, docker-ssh, shell:
shell

Das Datenbank Passwort wird als geheime Variable in Gitlab gespeichert.

Project -> Settings -> CI / CD -> Secret variables

Ein git push auf den Master sollte die Laravel-App nun automatisch erstellen und integrieren.

Wir können uns alle laufenden Container ansehen mit:

 docker ps

Die Ausgabe sieht etwa so aus

Zudem können wir uns nun in jeden Container einloggen, um manuell Nacharbeiten durchzuführen („XXX“ entspricht hierbei dem vergebenen Container-Name) .

 docker exec -it XXX_app bash

Schluss

Wenn alles funktioniert bist du nun in der Lage vollständige Anwendungsumgebungen aus deiner Codebasis heraus zu bauen, zu verstören und neu zu bauen, all dies innerhalb von Sekunden. Schließlich lässt sich der ganze Prozess auch individuell anpassen und um automatisierte Tests erweitern.

Hoffentlich war dir diese Anleitung hilfreich bei der Erstellung einer flexiblen, aber konsistenten Entwicklungsumgebung, die du und dein Team nutzen kannst.

Viel Spaß beim Entwickeln!