mein Docker-Webserver Setup für Laravel - Konfig im Detail

Wie bereits angekündigt, habe ich für meine Webseiten Plesk mit Docker ersetzt. Zwar verwalte ich die Seiten jetzt über das Terminal und nicht mehr über eine GUI, dafür bin ich mit dem Setup aber wesentlich flexibler: Ich kann die Container jederzeit kopieren und auf einem anderen Server starten, oder schneller mal eine neue Seite online stellen, bzw. für eine bestimmte Seite den Webserver oder die PHP-Version tauschen. Auch der Einsatz neuer Features wie Laravel Octane und Swoole sind damit einfach realisierbar. Der Beweggrund für dieses Setup kann auf folgender Seite nachgelesen werden: Docker vs. Plesk, für den Betrieb von Webseiten.  

Docker Basics
Docker ermöglicht es, Applikationen per Befehl in einem sogenannten Container zu starten.
Ein Container ist eine vom Betriebssystem (OS) unabhängige isolierte Umgebung:
Das OS spielt also keine Rolle, vorausgesetzt Docker lässt sich installieren.
Beim ersten Start eines Containers, lädt Docker selbstständig alle notwendigen Quellen
aus dem Internet.
Docker kann unter Windows, macOS oder einer Linux-Distribution installiert werden,
siehe auch: Docker
In diesem Beitrag habe ich meine aktuellen Webserver-Konfig-Dateien zusammengefasst. Für mein Setup habe ich alle notwendigen Services für den Betrieb eines Laravel-Webservers in einen Container gepackt, mit Ausnahme der Datenbank. Für das Starten der Prozesse verwende ich Supervisor.

Dem Webserver Nginx verwende ich in beiden Fällen um statischen Content direkt auszuliefern und nicht über den PHP-Webserver. Als Webserver für die PHP-Applikation habe ich zwei verschiedene Varianten getestet: Einmal mit php-fpm, einmal mit swoole als Web-Worker

Variante php-fpm:

  • supervisord,
  • Nginx,
  • php-fpm,
  • redis und
  • cron

Variante swoole:

  • supervisord,
  • Nginx,
  • swoole,
  • redis und
  • cron

Cron könnte für Laravel auch über den Host gestartet werden, also ein Eintrag der jede Minute im Container des Webservers den Laravel Scheduler startet.
Mir gefällt der Ansatz Cron auch im Container zu starten etwas besser, da der Container für dieses Setup nicht extra in der Crontab des Hosts hinterlegt werden muss und ohne extra Konfiguration Out-of-the-box funktioniert.

Webserver-Konfig Laravel-Webseite: Variante mit php-fpm

Für Docker habe ich folgende Konfig-Dateien angelegt:

docker-compose.yml

version: '3'
services:
  web:
    container_name: laravel_web
    build:
      context: .
      dockerfile: Dockerfile
    expose:
      - "80"   
    restart: always
    environment:
      VIRTUAL_HOST: '${VIRTUAL_HOST}'
      VIRTUAL_PORT: '${VIRTUAL_PORT}'
      LETSENCRYPT_HOST: '${LETSENCRYPT_HOST}'
      LETSENCRYPT_EMAIL: '${LETSENCRYPT_EMAIL}'
    volumes:
      - "./www:/var/www"
  mysql:
    image: 'mysql'
    container_name: laravel_mysql
    environment:
        MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
        MYSQL_DATABASE: '${DB_DATABASE}'
        MYSQL_USER: '${DB_USERNAME}'
        MYSQL_PASSWORD: '${DB_PASSWORD}'   
    restart: always
    volumes:
        - './db:/var/lib/mysql'
    healthcheck:
      test: ["CMD", "mysqladmin", "ping"]
          
networks:
  default:
    external:
      name: webproxy

Als Unterordner für die Datenbank verwende ich: "db" und für die Webseite: "www"

Um für die Webseite ein SSL-Zertifikat zu verwenden, habe ich den nginx-LetsEncrypt Reverse Proxy vorgeschaltet, daher beinhaltet die docker-compose.yml das Netzwerk "webproxy". Die Variablen für VIRTUAL_HOST, VIRTUAL_PORT, LETSENCRYPT_HOST und LETSENCRYPT_EMAIL habe ich in der .env-Datei von Laravel hinterlegt, diese können beim Starten des Containers über den Parameter "--env" übergeben werden, der Build-Prozess kann mit "--build" angestoßen werden und damit der Container im Hintergrund gestartet wird, als Parameter "-d" verwendet werden.

 docker-compose --env-file ./www/.env up -d --build 

Dockerfile

Folgendes Setup beinhaltet PHP-FPM und Swoole als PHP-Extension. Je nach Webserver-Setup kann FPM oder Swoole verwendet, bzw. die nicht verwendete Variante entfernt werden:

FROM ubuntu:20.04

ARG WWWGROUP

WORKDIR /var/www

ENV DEBIAN_FRONTEND noninteractive
ENV TZ=UTC
ENV PHP_VERSION 8.0

RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# Install Dependencies like in Laravel Sail:
RUN apt-get update \
    && apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev tesseract-ocr python2 \
    && mkdir -p ~/.gnupg \
    && chmod 600 ~/.gnupg \
    && echo "disable-ipv6" >> ~/.gnupg/dirmngr.conf \
    && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys E5267A6C \
    && apt-key adv --homedir ~/.gnupg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C300EE8C \
    && echo "deb http://ppa.launchpad.net/ondrej/php/ubuntu focal main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
    && apt-get update \
    && apt-get install -y php${PHP_VERSION}-cli php${PHP_VERSION}-dev \
       php${PHP_VERSION}-pgsql php${PHP_VERSION}-sqlite3 php${PHP_VERSION}-gd \
       php${PHP_VERSION}-curl php${PHP_VERSION}-memcached \
       php${PHP_VERSION}-imap php${PHP_VERSION}-mysql php${PHP_VERSION}-mbstring \
       php${PHP_VERSION}-xml php${PHP_VERSION}-zip php${PHP_VERSION}-bcmath php${PHP_VERSION}-soap \
       php${PHP_VERSION}-intl php${PHP_VERSION}-readline \
       php${PHP_VERSION}-msgpack php${PHP_VERSION}-igbinary php${PHP_VERSION}-ldap \
       php${PHP_VERSION}-gmp php${PHP_VERSION}-mbstring php${PHP_VERSION}-redis \
    && php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer \
    && curl -sL https://deb.nodesource.com/setup_15.x | bash - \
    && apt-get install -y nodejs \
    && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
    && apt-get update \
    && apt-get install -y yarn \
    && apt-get install -y mysql-client \
    && apt-get -y autoremove \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# add nginx
RUN apt-get update && apt-get install -y software-properties-common && apt-add-repository ppa:nginx/stable -y && apt-get install -y php${PHP_VERSION}-fpm nginx && \
    mkdir -p /run/php && chmod -R 755 /run/php && \
    sed -i 's|.*listen =.*|listen=9000|g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's|.*error_log =.*|error_log=/proc/self/fd/2|g' /etc/php/${PHP_VERSION}/fpm/php-fpm.conf && \
    sed -i 's|.*access.log =.*|access.log=/proc/self/fd/2|g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's|.*user =.*|user=root|g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's|.*group =.*|group=root|g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i -e "s/;catch_workers_output\s*=\s*yes/catch_workers_output = yes/g" /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's#.*variables_order.*#variables_order=EGPCS#g' /etc/php/${PHP_VERSION}/fpm/php.ini && \
    sed -i 's#.*date.timezone.*#date.timezone=UTC#g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's#.*clear_env.*#clear_env=no#g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i 's#.*pm = dynamic*#pm = ondemand#g' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i '/pm.max_children = /c\pm.max_children = 50' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i '/pm.process_idle_timeout = /c\pm.process_idle_timeout = 60s' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf && \
    sed -i '/pm.max_requests = /c\pm.max_requests = 15' /etc/php/${PHP_VERSION}/fpm/pool.d/www.conf 

# add swoole
RUN pecl install --configureoptions 'enable-sockets="no" enable-openssl="no" enable-http2="no" enable-mysqlnd="no" enable-swoole-json="no" enable-swoole-curl="no"' swoole
#You should add "extension=swoole.so" to php.ini
RUN echo "extension=swoole.so" > /etc/php/${PHP_VERSION}/cli/conf.d/99-php.ini
RUN echo "extension=swoole.so" > /etc/php/${PHP_VERSION}/fpm/conf.d/99-php.ini

# add redis
RUN apt-get update && apt-get install -y redis-server

# add cron
RUN apt-get install -y cron
RUN echo "* * * * * root /usr/bin/php /var/www/artisan schedule:run >> /dev/null 2>&1" > /etc/cron.d/laravel-scheduler
RUN chmod 644 /etc/cron.d/laravel-scheduler

# Add user for laravel application
RUN groupadd -g 1000 www
RUN useradd -u 1000 -ms /bin/bash -g www www

#for supervisor to start the right version:
RUN mv /usr/sbin/php-fpm${PHP_VERSION} /usr/sbin/php-fpm 

COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY php.ini /etc/php/${PHP_VERSION}/cli/conf.d/99-php.ini
COPY php.ini /etc/php/${PHP_VERSION}/fpm/conf.d/99-php.ini
COPY nginx.conf /etc/nginx/nginx.conf
COPY mysql.cnf /etc/mysql/conf.d/mysql.cnf

CMD /usr/bin/supervisord
EXPOSE 80

supervisord.conf

Um die notwendigen Prozesse zu überwachen und zu starten, verwende ich Supervisor, hier meine supervisord.conf für die php-fpm-Variante:

[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisord.log
pidfile=/var/run/supervisord.pid

[program:nginx]
command=/usr/sbin/nginx
autostart = true
autorestart=true
stdout_logfile=/dev/nginx-stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/nginx-stderr
stderr_logfile_maxbytes=0

[program:php-fpm]
command=/usr/sbin/php-fpm -R --nodaemonize
autostart=true
autorestart=true
stdout_logfile=/var/log/php-fpm-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/php-fpm-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

[program:redis]
command=redis-server
autostart=true
autorestart=true
stdout_logfile=/var/log/redis-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/redis-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

[program:cron]
command=cron
autostart=true
autorestart=true
stdout_logfile=/var/log/cron-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/cron-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

nginx.conf

Die Nginx-Konfiguration unterscheidet zwischen statischem Content, PHP-Seiten und abhängig von einem bestimmten Cookie, ob statische gecachte Files verwendet werden sollen, oder die Anfrage zu php-fpm geschickt werden soll. Für das Caching der statischen Files verwende ich das Laravel-Paket page-cache (siehe auch: JosephSilber/page-cache und Webseite Stresstest - Performance messen Anfragen/Sekunde). 

#worker_processes  2;
daemon off;
user root;

#pid        logs/nginx.pid;
events {
    worker_connections  1024;
}

error_log /dev/stdout info;

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile on;
    keepalive_timeout  65;
    gzip  on;
    gzip_vary on;
    gzip_min_length 10240;
    gzip_proxied any;
    gzip_disable msie6;
    gzip_comp_level 1;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

    error_log /dev/stdout;

    server {
        listen 80 ;

        server_name _;

#        auth_basic           "Test Area";
#        auth_basic_user_file /var/www/.htpasswd;
        root /var/www/public;


        #redirect index.php
        if ($request_uri ~* "^/index\.php/(.*)") {
            return 301 /$1;
        }


        location ~* ^/storage/.*\.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
            expires 7d;
            add_header Cache-Control "public, no-transform";
        }


        #set variables for Cache...
        set $shouldusecache4root @usecache4root;
        set $shouldusecache4pages @usecache4pages;
        if ($http_cookie ~* "nocache=YES(?:;|$)") {
            set $shouldusecache4root @nocache4root;
            set $shouldusecache4pages @nocache4pages;
        }
        if ($query_string != "") {
            set $shouldusecache4root @nocache4root;
            set $shouldusecache4pages @nocache4pages;
        }
        #hack locations...
        location = / {
            try_files /dev/null $shouldusecache4root;
        }
        location / {
            try_files /dev/null $shouldusecache4pages;
        }
        #use named locations from hacked locations...
        location @usecache4root {
            try_files /page-cache/pc__index__pc.html /index.php?$is_args$args;
        }
        location @nocache4root {
            try_files $uri /index.php?$is_args$args;
        }

        location @usecache4pages {
            try_files $uri $uri/ /page-cache/$uri.html /page-cache/$uri.json /index.php$is_args$args;
        }
        location @nocache4pages {
            try_files $uri $uri/ /index.php$is_args$args;
        }
        

        location ~ ^/index\.php(/|$) {
            fastcgi_pass 127.0.0.1:9000;
            fastcgi_split_path_info ^(.+\.php)(/.*)$;
            include /etc/nginx/fastcgi_params;

            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
            fastcgi_param DOCUMENT_ROOT $realpath_root;

            # Prevents URIs that include the front controller. This will 404:
            # http://domain.tld/app.php/some-path
            # Remove the internal directive to allow URIs like this
            internal;
        }

    }
}

php.ini

[PHP]
post_max_size=512M
upload_max_filesize=512M
variables_order=EGPCS
max_execution_time=60

[opcache]
opcache.enable=1
; 0 means it will check on every request
; 0 is irrelevant if opcache.validate_timestamps=0 which is desirable in production
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.max_accelerated_files=30000
opcache.memory_consumption=256
opcache.max_wasted_percentage=10
opcache.interned_strings_buffer=16
opcache.fast_shutdown=1

mysql.cnf - Tuning mysql Memory Usage

Um mehrere Webseiten zu betreiben, kann der Speicherplatz von mysql optimiert werden, indem das Performance-Schema deaktiviert wird: Anstelle von über 500MB, benötigt mysql bei meiner Webseite ohne dem Performance-Schema nur mehr ca. 300MB / Container

[mysqld]
performance_schema = 0
expire_logs_days = 2
key_buffer_size = 5M
innodb_buffer_pool_size = 60M

Laravel Swoole und Octane: Variante mit swoole

Für den Einsatz von Swoole als Webserver für Laravel Octane muss lediglich die Dateien php.ini, nginx.conf und supervisord.conf angepasst werden:

supervisord.conf

[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisord.log
pidfile=/var/run/supervisord.pid

[program:nginx]
command=/usr/sbin/nginx
autostart = true
autorestart=true
stdout_logfile=/dev/nginx-stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/nginx-stderr
stderr_logfile_maxbytes=0

[program:octane]
command=/usr/bin/php -d variables_order=EGPCS /var/www/artisan octane:start --server=swoole --watch --host=0.0.0.0 --port=8000
autostart=true
autorestart=true
stdout_logfile=/var/log/php-fpm-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/php-fpm-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

[program:redis]
command=redis-server
autostart=true
autorestart=true
stdout_logfile=/var/log/redis-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/redis-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

[program:cron]
command=cron
autostart=true
autorestart=true
stdout_logfile=/var/log/cron-stdout.log
stdout_logfile_maxbytes=0
stderr_logfile=/var/log/cron-stderr.log
stderr_logfile_maxbytes=0
exitcodes=0

php.ini

Für Swoole ist es notwendig die Swoole-Extension in der php.ini Datei zu laden:

[PHP]
post_max_size=512M
upload_max_filesize=512M
variables_order=EGPCS
max_execution_time=240
memory_limit = 512M

[opcache]
opcache.enable=1
opcache.revalidate_freq=0
opcache.validate_timestamps=1
opcache.max_accelerated_files=30000
opcache.memory_consumption=256
opcache.max_wasted_percentage=10
opcache.interned_strings_buffer=16
opcache.fast_shutdown=1
opcache.jit_buffer_size=100M
opcache.jit=1255

extension=swoole.so

Nginx.conf

Auch hier unterscheidet die Nginx-Konfiguration zwischen statischem Content, PHP-Seiten und abhängig von einem bestimmten Cookie, ob statische gecachte Files verwendet werden sollen, oder die Anfrage zu Swoole geschickt werden soll. Für das Caching der statischen Files verwende ich das Laravel-Paket page-cache (siehe auch: JosephSilber/page-cache und Webseite Stresstest - Performance messen Anfragen/Sekunde). 

daemon off;
user root;

#pid        logs/nginx.pid;
events {
    worker_connections  1024;
}

error_log /dev/stdout info;

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    sendfile on;
    keepalive_timeout  65;
    gzip  on;
    gzip_vary on;
    gzip_min_length 10240;
    gzip_proxied any;
    gzip_disable msie6;
    gzip_comp_level 1;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;


    error_log /dev/stdout;

    server {
        listen 80 ;

        server_name _;

        auth_basic           "Test Area";
        auth_basic_user_file /var/www/.htpasswd;

        root /var/www/public;

        #redirect index.php
        if ($request_uri ~* "^/index\.php/(.*)") {
            return 301 /$1;
        }

        location ~* ^/storage/.*\.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
            expires 7d;
            add_header Cache-Control "public, no-transform";
        }

        #set variables for Cache...
        set $shouldusecache4root @usecache4root;
        set $shouldusecache4pages @usecache4pages;
        if ($http_cookie ~* "nocache=YES(?:;|$)") {
            set $shouldusecache4root @nocache4root;
            set $shouldusecache4pages @nocache4pages;
        }
        if ($query_string != "") {
            set $shouldusecache4root @nocache4root;
            set $shouldusecache4pages @nocache4pages;
        }

        #hack locations...
        location = / {
            try_files /dev/null $shouldusecache4root;
        }
        location / {
            try_files /dev/null $shouldusecache4pages;
        }
        #use named locations from hacked locations...
        location @usecache4root {
            try_files /page-cache/pc__index__pc.html @swoole;
        }
        location @nocache4root {
            try_files $uri @swoole;
        }

        location @usecache4pages {
            try_files $uri $uri/ /page-cache/$uri.html /page-cache/$uri.json @swoole;
        }
        location @nocache4pages {
            try_files $uri $uri/ @swoole;
        }
        
        location @swoole {
            set $suffix "";
            if ($uri = /index.php) {
                set $suffix ?$query_string;
            }
            proxy_http_version 1.1;
            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 120s;
            proxy_set_header Connection "keep-alive";
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Real-PORT $remote_port;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
            proxy_set_header Scheme $scheme;
            proxy_set_header Server-Protocol $server_protocol;
            proxy_set_header Server-Name $server_name;
            proxy_set_header Server-Addr $server_addr;
            proxy_set_header Server-Port $server_port;
            proxy_set_header X-Requested-With $http_x_requested_with;
            proxy_pass http://127.0.0.1:8000$suffix;
            proxy_cookie_path / /;
        }

    }
}

Server Tuning

Neben der eigentlichen Docker-Installation habe ich folgendes am Webserver geändert:

Testumgebung

Um mit Docker eine Testumgebung zu erstellen, kann die Konfiguration zusätzlich auf demselben oder auf einem anderen Host mit einem alternativen DNS-Namen betrieben werden. Nachdem die Abhängigkeiten und Pakete im Docker-Container hinterlegt sind, ist die Testumgebung nicht nur ähnlich, sondern identisch zum produktiven Webserver.

Andere Beiträge zu Docker, siehe: /topic/docker

positive Bewertung({{pro_count}})
Beitrag bewerten:
{{percentage}} % positiv
negative Bewertung({{con_count}})

DANKE für deine Bewertung!



Fragen / Kommentare


Wir verwenden Cookies, um Inhalte und Anzeigen zu personalisieren, Funktionen für soziale Medien anbieten zu können und die Zugriffe auf unsere Website zu analysieren. Außerdem geben wir Informationen zu Ihrer Nutzung unserer Website an unsere Partner für soziale Medien, Werbung und Analysen weiter. Details anzeigen.