PHP und Continuous Deployment

Mit Gitlab und Laravel Envoy eine professionelle Multistage-Continuous Deployment-Umgebung für PHP-Webapps erstellen

Allein die Überschrift lässt sicher manche Entwickler aus dem Java und C#-Umfeld grübeln, ob man mit PHP ernsthaft DevOps und professionelle Software-Entwicklung betreiben kann, wie der Autor selber des Öfteren aus Erfahrung feststellen musste.

Vermutlich liegt es daran, dass PHP viel zu lange den Ruf einer eher „dreckigen“ Skriptsprache für Quereinsteiger, Agenturen und Webdesigner inne hatte und gar lange Zeit für tot erklärt wurde.

Dabei bietet PHP dem Entwickler neben einem hohen Umfang an Standardfunktionen für jeden erdenklichen Zweck, ohne sich aufzudrängen die maximale Flexibilität, seinen Code so zu schreiben, wie er möchte und ist praktisch auf jedem angemieteten virtuellem oder physischem Webserver oder Webspace vorhanden, ohne dass zunächst ein teures self-managed System mit Vollzugriff gekauft werden muss. Die Vorurteile sind zudem aufgrund der großen Community und den performanten Versionen PHP7 und 8 mit vielen modernen Design Pattern aus anderen Sprachen mittlerweile völlig unberechtigt.

Im Gegenteil: da die meisten PHP-Entwickler zumindest gelernt haben, wie sie sich auf einem Linux-Webserver mit der Kommandozeile zurechtfinden müssen und wie sie Config-Files für einen Apache-Webserver editieren müssen, um notwendige Module zu installieren und zu aktivieren, werden nach Ansicht des Autors sogar eher höhere DevOps-Anforderungen gestellt als in der komfortablen Basis-Ausstattung für die Entwicklung mit anderen Sprachen und deren Frameworks. So ist es auch nur folgerichtig, dass eher moderne Technologien wie Containerisierung mit Docker und DevOps-Pipelines auch in PHP Einzug halten. Vorbei sind, Gott sei Dank, die Tage, dass ein einzelner Entwickler als Lonely Ranger „mal eben schnell“ per SFTP-Client prozedurale Skripte auf den Live-Server verschiebt oder gar jeder Entwickler den per DSGVO fragwürdigen Vollzugriff auf die blanken Kundendaten in einer Mysql-Datenbank mittels PHPMyAdmin hat und diese bei Bedarf nach Belieben modifizieren kann. Der Grund für die Vorurteile scheint viel eher ein trivialer zu sein: nach Erfahrung des Autors halten zuviele PHP-Entwickler leider ein Studium oder Fortbildungen außerhalb ihres verwendeten Frameworks für den Blick über den Tellerrand gar für überflüssig (oder Arbeitgeber betrachten es nicht als sinnvolle Fortbildung, woraus schlicht ein Zeitmangel resultiert) und manche betrachten ihre Entwicklung mehr als Handwerk, denn als Ingenieurskunst, während Consulting-Unternehmen und Software-Firmen überwiegend Berufsanfänger nach dem Studium zu günstigen Preisen anwerben und Inhouse erste Erfahrungen sammeln und Karriere machen lassen. Wie gut, dass der Autor in beiden Welten Erfahrungen gesammelt hat, obwohl ihm erstere zum aktuellen Zeitpunkt die vertrautere zu sein scheint. Da der aktuelle IT-Markt im Allgemeinen aufgrund des hohen und weiterwachsenden Bedarfs auch für jede erdenkliche Form von Karriere offen und weniger festgefahren ist, mag selbstverständlich ein jeder nach seiner Fasson selig werden.


Doch zurück zum eigentlichen Thema:

In dieser Anleitung zeige ich exemplarisch in detaillierten Schritten auf, wie du als Web-Entwickler mit minimalen DevOps-Kenntnissen eine professionelle und vollautomatische CI/CD-Deployment-Pipeline für PHP-Anwendungen für einen gewöhnlichen Webserver mit SSH-Zugriff auf einen Linux-Webserver (Debian/Ubuntu) aus Gitlab heraus aufsetzt (mit Github soll dieses ähnlich möglich sein). Dadurch sparst du dir jede Menge Arbeit und Fehlerquellen (vor allem in der Zusammenarbeit mit mehreren Entwicklern) im Gegensatz zum verpöhnten Vollzugriff auf den Webserver und Kopieren der Dateien mittels SCP, SYNC oder gar per SFTP-Client.

Darüber hinaus wird eine sinnvolle Grundkonfiguration mit dem Codesniffer PHPCS, dem statischen Analysetool Phan und der Bibliothek für Unit-Testing PHPUnit aufgezeigt, die bei korrekter Verwendung die Code-Qualität deiner Anwendung erheblich steigern und Bugs vermeiden. Der Rollout mittels dem verbreiteten DevOps-Paradigma CI/CD (Continuous Integration/Continuous Deployment) hat zum einen den Vorteil, dass deine Anwendung beim Rollout eine Zero-Downtime von 0s hat und außerdem, dass du keinen komplizierten und fehleranfälligen nervenaufreibenden Rollout-Prozess über diverse System-Stages per Bash/Kommandozeile durchführen musst, der dir graue Haare wachsen lässt und vor dem es dir bereits Tage im Voraus gruselt. Die Anwendung ist nach einem push in das Git-Repository sofort da und du sparst gerade in kleinen Teams oder als Einzelperson jede Menge Zeit. Natürlich bringt große Macht auch eine hohe Verantwortung mit sich…


1. Zunächst einmal solltest du einen neuen Branch anlegen oder einen bestehenden konfigurieren.

Dann ist es notwendig, sowohl auf deinem lokalen Host den Deployment-Key zu generieren, als auch den Zugriff von Gitlab auf deinen Webserver und umgekehrt zum Klonen des Repositorys einzurichten. Dies ist der Schritt, an dem ich beim erstmaligen Aufsetzen deutlich mehr Zeit und Hirnschmalz aufwenden musste, als für alle anderen Schritte. Auf deinem lokalen Rechner führst du aus:


ssh-keygen -t rsa -C "<deine Email-Adresse>" -N "" deploy_key

mv deploy_key* ~/.ssh/

cat ~/.ssh/deploy_key | base64 -w0


Nun kopierst den base64-verschlüsselten Key (vermeidet Probleme mit Linefeeds), öffnest dein Gitlab-Repository (angemeldet) und klickst auf Settings→CI/CD und den Button Expand neben Variables. Dort klickst du auf Add Variable und erstellst eine neue Variable namens SSH_PRIVATE_KEY. Vorsicht: Wenn du deinen Key auf Protected setzt, müssen die entsprechenden Branches in deinem Repository ebenfalls auf protected gestellt sein, um ihn dort benutzen zu können!


Jetzt kopierst du noch den Public Key mittels „cat ~/.ssh/deploy_key.pub“ und fügst ihn in deinem Gitlab-Repository unter Settings→Repository→Deploy Keys als neuen Schlüssel ein.


Nun Verschaffst du dir Zugriff auf den Webserver per SSH und kopierst ausgehend von deinem lokalen Rechner mittels SCP deinen Public-Key dorthin:


scp /home/user/.ssh/deploy_key.pub loginuser@webserver:/home/user/.ssh/


Anmelden auf dem Webserver:

ssh loginuser@webserver


Diesen Key trägst du nun in deiner authorized_hosts-Datei ein mit:

cat ~/.ssh/deploy_key.pub >> ~/.ssh/authorized_keys


ssh-keyscan gitlab.com >> ~/.ssh/known_hosts


Vom Webserver solltest du anschließend deine Verbindung zu Gitlab überprüfen mit:

ssh -T git@gitlab.com


Wenn diese fehlschlägt, überprüfe sorgfältig die vorangegangenen Schritte anhand der Fehlermeldung.


Falls du dein lokales Verzeichnis deiner Anwendung noch nicht mit Gitlab verknüpft hast, kannst du folgendermaßen vorgehen:

git config --global user.name "Hans Mustermann"
git config --global user.email "hans.mustermann@me.com"

git init

git remote add origin https://gitlab.com/username/mein-repo.git

git add .

git commit -m "Initial commit"
git push -u origin main


Wichtig für die nachfolgenden Schritte ist, dass du 2 Branches master/main und dev erstellt hast für den Rollout auf dem live-System oder in der Testumgebung, je nachdem, in welchen Branch du gerade dein git push ausführst.


2. Anschließend solltest du Docker lokal bei dir installieren. Dann erstellst du ein Dockerfile im Hauptverzeichnis deiner Anwendung:


FROM php:7.4
RUN apt-get update
RUN apt-get install -qq git curl libmcrypt-dev libjpeg-dev libpng-dev libfreetype6-dev libbz2-dev libmcrypt-dev libzip-dev
RUN apt-get clean
RUN pecl install mcrypt-1.0.4 && docker-php-ext-enable mcrypt
RUN docker-php-ext-install pdo_mysql zip
RUN curl --silent --show-error "https://getcomposer.org/installer" | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer global require "laravel/envoy=~1.0"


Dieses Script dient Docker als Vorlage, um ein Image zu bauen, mit dem bei jedem Deployment-Prozess deine Stages einen Linux-Container zur Verfügung haben, mit dem sie ihre Tests und Deployment-Schritte durchführen können.


Das Image baust du und legst es in der Gitlab-Registry folgendermaßen ab:

docker login registry.gitlab.com

docker build -t registry.gitlab.com/<username>/<mein-repo>.git .

docker push registry.gitlab.com/<username>/<mein-repo>.git


3. Im Docker-Image im letzten Step hast du ja bereits das Deployment Tool Envoy des populären Frameworks Laravel eingebunden. Ein großer Vorteil von Laravel ist, dass sich die Module auch einzeln verwenden lassen und sich (zumindest nach Sicht des Autors) professionelle umfangreiche Anwendungen deutlich intuitiver, schneller, sauber und mit weniger akademischen Konzepten aufbauen lassen als bei anderen PHP-Frameworks wie Symfony. Das Modul Envoy, das auf Blade-Views aufsetzt, ermöglicht dabei ein recht einfaches Deployment der Anwendung, indem PHP schlichte Shell-Skripte, verpackt in Direktiven, hier getriggert durch das finale docker-compose-File ausführt. Ein Skript hierfür kann folgendermaßen aussehen (Envoy.blade.php):


@servers(['web' => 'username@webserver'])

@setup
$repository = 'git@gitlab.com:username/mein-repo.git';
$app_dir_dev = '/var/www/user/app-dir-test';
$app_dir_prod = '/var/www/user/app-dir-production';
@endsetup

@story('deploy_dev')
clone_repository_dev
run_composer_dev
send_final_slack_message_dev
@endstory

@story('deploy_prod')
clone_repository_prod
run_composer_prod
send_final_slack_message_prod
@endstory

@task('clone_repository_dev')
echo 'Pulling repository to dev server'
[ -d {{ $app_dir_dev }} ] || mkdir {{ $app_dir_dev }}
cd {{ $app_dir_dev }}
ENV_FILE="$(cat .env)"
rm -rf {{ $app_dir_dev }}/.git
git init
git remote add origin {{ $repository }}
git fetch
git checkout -t origin/dev -f
echo "$ENV_FILE" > .env
@endtask

@task('clone_repository_prod')
echo 'Pulling repository to prod server'
[ -d {{ $app_dir_prod }} ] || mkdir {{ $app_dir_prod }}
cd {{ $app_dir_prod }}
ENV_FILE="$(cat .env)"
rm -rf {{ $app_dir_prod }}/.git
git init
git remote add origin {{ $repository }}
git fetch
git checkout -t origin/master -f
echo "$ENV_FILE" > .env
@endtask

@task('run_composer_dev')
echo "running composer"
cd {{ $app_dir_dev }}
php7.4 composer.phar install --prefer-dist --no-scripts -q -o
@endtask

@task('run_composer_prod')
echo "running composer"
cd {{ $app_dir_prod }}
php7.4 composer.phar install --prefer-dist --no-scripts -q -o
@endtask

@task('send_final_slack_message_dev')
curl -X POST -H 'Content-type: application/json' --data '{"text":"Eine neue Version von XYZ (#{{ $commit }}) wurde soeben auf DEV ausgerollt."}' https://hooks.slack.com/services/webhook-path/XYZ
@endtask

@task('send_final_slack_message_prod')
curl -X POST -H 'Content-type: application/json' --data '{"text":"Eine neue Version von XYZ (#{{ $commit }}) wurde soeben auf dem Live-System ausgerollt."}' https://hooks.slack.com/services/webhook-path/XYZ
@endtask


Falls keine .env-Datei vorhanden ist, können die jeweiligen Zeilen entfernt werden, in denen der Inhalt der vorhandenen Datei eingelesen, zwischengespeichert und wieder nach dem Update als .env-File im Hauptverzeichnis abgelegt wird. Praktisch an dieser Vorgehensweise ist, dass überhaupt keine Credentials, etwa für Datenbank und Emailserver, im Repository gespeichert werden müssen und nur der Entwickler mit Vollzugriff auf den Webserver diese einsehen kann.

Die manuellen Slack-Benachrichtigungen mit Curl über eine Webhook können auch herausgenommen werden. Bislang lässt sich in Envoy-Tasks noch nicht die @Slack-Direktive verwenden, weshalb dies ein Workaround ist. Voraussetzung zum Versenden von Slack-Nachrichten ist ein eigener Slack-Channel mit installiertem Webhook-Plugin und Konfiguration nach https://slack.com/intl/de-de/help/articles/115005265063-Eingehende-Webhooks-f%C3%BCr-Slack#:~:text=Schalte%20auf%20der%20Seite%20%E2%80%9EFunktionen,Nachricht%20in%20Slack%20zu%20posten.

Hierzu sei erwähnt, dass Slack sich generell über ein Hersteller-Plugin mit Gitlab verbinden lässt und eine sehr komfortable Funktion von ChatOps ermöglicht, was gerade in kleinen Teams enorm zeitsparend sein kann und Übersicht über Releases und Prozesse automatisiert für alle Mitarbeitenden ermöglicht.


4. Als Nächstes installieren wir (falls noch nicht geschehen) den Paketmanager composer im Hauptverzeichnis der Anwendung mit
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && php composer-setup.php && rm composer-setup.php && php composer.phar install


Mit Composer installieren wir das Testmodul PHPUnit mit seinen Abhängigkeiten in das Verzeichnis vendor/phpunit über

php composer.phar require junit/junit


und erstellen eine neue Datei ExampleTest.php im Verzeichnis tests/Unit:


<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function test_example()
{
$this->assertTrue(true);
}
}


Die Tests laden wir über eine neue Datei phpunit.xml im Hauptverzeichnis für die Konfiguration von PHPUnit, angenommen, wir möchten die Testabdeckung von unseren Dateien im Unterverzeichnis src ermitteln:


<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./libs/vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src</directory>
</include>
</coverage>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<!-- <server name="DB_CONNECTION" value="sqlite"/> -->
<!-- <server name="DB_DATABASE" value=":memory:"/> -->
<server name="MAIL_MAILER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="array"/>
<server name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>


Nun testen wir das ganze von der Kommandozeile mit

vendor/bin/phpunit


Zur weiterführendem Wissen sei hier auf die Dokumentation von PHPUnit sowie die generelle Herangehensweise beim Schreiben von Tests verwiesen.


5. Das schreiben von Unittests kann unter Umständen gerade bei Zeitdruck und nicht dafür optimiertem Framework-Code ziemlich aufwendig werden. Einfacher sind statische Tests mit dem Tool Phan. Hiermit lassen sich viele Fehler, wie etwa unerreichbarer Code, undefinierte Methoden, Funktionen, Variablen oder zuwenige oder zuviele Parameter sowie Tippfehler schon im Voraus abfangen, ohne dass sie in Produktion gelangen. Das einzige, was benötigt wird, ist eine Datei config.php im Unterverzeichnis .phan und das Tool selbst, das wir über Composer installieren mit
php composer require phan/phan


Config.php:

<?php

return [

// Supported values: `'5.6'`, `'7.0'`, `'7.1'`, `'7.2'`, `'7.3'`, `'7.4'`,
// `'8.0'`, `null`.
"target_php_version" => null,

'exclude_analysis_directory_list' => [
'vendor',
],

'directory_list' => [
'src',
'tests',
],

'plugins' => [
'AlwaysReturnPlugin',
'DollarDollarPlugin',
'DuplicateArrayKeyPlugin',
'DuplicateExpressionPlugin',
'PregRegexCheckerPlugin',
'PrintfCheckerPlugin',
'SleepCheckerPlugin',
'UnreachableCodePlugin',
'UseReturnValuePlugin',
'EmptyStatementListPlugin',
'LoopVariableReusePlugin',
],
];


Der Code lässt sich hierbei vorab vor dem Deployment testen mit

php libs/vendor/phan/phan/phan --progress-bar --allow-polyfill-parser


Zur weiterführenden Dokumentation sei hier auf das Github-Repository von Phan unter https://github.com/phan/phan verwiesen.


6. Da für die Zusammenarbeit in produktiver Umgebung gemeinsame Coding-Standards unerlässlich sind, empfiehlt sich ein sogenannter Code-Sniffer wie PHPCS. Dieser lässt sich im Hauptverzeichnis der Anwendung herunterladen mit den Befehlen

wget https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar

wget https://squizlabs.github.io/PHP_CodeSniffer/phpcbf.phar


Das ebenfalls heruntergeladene Tool PHPCBF ermöglicht dabei die automatische Korrektur nach dem verwendeten Standard, was in vielen Fällen sehr zeitsparend ist.

Für die Konfiguration legen wir noch eine Datei phpcs.xml im Hauptverzeichnis an:


<?xml version="1.0"?>
<ruleset name="MyStandards">

<description>My Coding Standards</description>


<file>src</file>

<exclude-pattern>*/cache/*</exclude-pattern>
<exclude-pattern>*/*.js</exclude-pattern>
<exclude-pattern>*/*.css</exclude-pattern>
<exclude-pattern>*/*.xml</exclude-pattern>
<exclude-pattern>*/*.blade.php</exclude-pattern>
<exclude-pattern>*/vendor/*</exclude-pattern>

<arg name="report" value="full" />
<arg name="colors"/>
<arg value="p"/>
<ini name="memory_limit" value="128M"/>
<rule ref="PSR12"/>

</ruleset>


Hierbei wurde unter rule der aktuelle Standard PSR12 (https://www.php-fig.org/psr/psr-12/) konfiguriert. Getestet werden alle Dateien, die nicht den Schemas in den exclude-pattern-Tags entsprechen im Verzeichnis src.


Ausführen kann man die Tests nun mit dem Befehl

php phpcs.phar


Zur weiterführenden Dokumentation sei auch hier auf das Github-Repository verwiesen unter https://github.com/squizlabs/PHP_CodeSniffer


7. Zu guter letzt fügen wir die einzelnen Stages in einem Skript namens .gitlab-ci.yml zusammen und testen die Pipeline via

git checkout dev

git merge master

git add .

Git commit -m „devops pipeline“

git push

image: registry.gitlab.com/username/mein-repo.git:latest

stages:
- linting
- static_tests
- unit_tests
- deploy_dev
- deploy_prod

phpcs_linter_PSR12:
stage: linting
image: registry.gitlab.com/pipeline-components/php-codesniffer:latest
script:
- phpcs -s -p --colors --extensions=php --standard=phpcs.xml --config-set show_warnings 0

static_tests:
stage: static_tests
script:
- php libs/composer.phar install
- libs/vendor/phan/phan/phan --progress-bar --allow-polyfill-parser -o phan_results.txt
artifacts:
when: always
expire_in: 1 month
paths:
- phan_results.txt

unit_tests:
stage: unit_tests
script:
- php libs/composer.phar install
- cd libs && vendor/bin/phpunit
artifacts:
when: always
expire_in: 1 month
paths:
- coverage

deploy_dev:
stage: deploy_dev
script:
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY" | base64 -d)
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- ~/.composer/vendor/bin/envoy run deploy_dev --commit="$CI_COMMIT_SHA"
environment:
name: development
url: https://localhost
#when: manual
only:
- dev

deploy_prod:
stage: deploy_prod
script:
- 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
- ssh-add <(echo "$SSH_PRIVATE_KEY" | base64 -d)
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- ~/.composer/vendor/bin/envoy run deploy_prod --commit="$CI_COMMIT_SHA"
environment:
name: production
url: https://localhost
#when: manual
only:
- master



Quellen:


[1] https://docs.gitlab.com/ee/ci/examples/laravel_with_gitlab_and_envoy/

[2] https://www.cloudways.com/blog/deploy-gitlab-ci-cd/