Corporate Hardware Networking Sistemi Software

Dockerizzare un’applicazione Laravel

Contesto

Sui server della TwoBeeSolution girano molte applicazioni, delle quali una parte consistente è basata sulla web application framework Laravel. Per semplificare la gestione delle applicazioni e dei relativi backup e per rendere più standard e sicuro l’infrastruttura, abbiamo scelto di utilizzare Docker.

In questo articolo vogliamo condividere le modalità con cui è possibile effettuare il deploy di un applicativo Laravel nella struttura di Docker, senza la pretesa di realizzare una infrastruttura particolarmente ottimizzata o pronta a un utilizzo in produzione in ambito enterprise; si vuole bensì definire una base sulla quale poter poi approfondire le problematiche specifiche di ciascuna realtà. 

Se si vuole entrare nel mondo dei container più approfonditamente, lo stesso Docker offre strumenti per ottimizzare molto le operazioni qui descritte e, sebbene vi siano sistemi di gestione dei container più recenti, performanti e sicuri (Podman su tutti), questi sono compatibili con i medesimi standard che utilizza Docker e solitamente utilizzabili con comandi e file di configurazione del tutto analoghi. 

Inoltre, per esigenze enterprise, è indispensabile governare e automatizzare le operazioni sui container attraverso software detti orchestratori, tra i quali possiamo certamente individuare in Kubernetes quello più utilizzato.

Cos’è Docker

Docker è un sistema che permette di incapsulare applicativi in contenitori indipendenti (container) che possono coesistere su una stessa macchina, in maniera analoga a un set di macchine virtuali.

Invece di installare macchine virtuali vere e proprie, con il conseguente dispendio di risorse (ogni macchina virtuale ha il suo set di risorse dedicate e il suo sistema operativo completo), Docker sfrutta le funzionalità di isolamento presenti nel kernel Linux per simulare diverse macchine, ognuna in un diverso container, ma con il kernel in comune.

Questa modalità di isolamento consente una grandissima flessibilità e, allo stesso tempo, un peso sul sistema davvero minimo rispetto a un sistema di virtualizzazione classico. Inoltre, permettendo di distribuire la configurazione del container assieme all’applicativo, rende l’applicativo del tutto indipendente dalla configurazione del sistema ospite.

Concetti di base

Prima di addentrarci nell’approfondimento tecnico, dobbiamo introdurre alcuni concetti e terminologie necessarie a facilitare la comprensione dei contenuti del presente articolo.

Le image

Una image è un file che contiene “il sistema” su cui l’applicativo sarà lanciato. Non si tratta propriamente del sistema poiché, come abbiamo detto, tutti gli applicativi che girano su Docker condividono il medesimo kernel. Possiamo però facilmente immaginarla come l’analogo filosofico del concetto di “immagine di sistema operativo” presente nel mondo delle macchine virtuali.

Un file di immagine è composto da layer. Il concetto di layer, ereditato dai sistemi grafici, va associato ad una funzione o uno scopo. A ciascuno dei layer che compongono il file immagine, è possibile aggiungere contenuti (file, software, configurazioni), che sono storicizzati consentendo il ripristino di una precedente versione.

Ad esempio prendiamo la image di php (che contiene php e le utility di sistema necessarie a farlo funzionare), creiamo un nuovo layer installando Composer, successivamente un ulteriore layer che contiene un set di librerie.

Le image possono essere definite a priori oppure create in fase di deployment e possono essere utilizzate da più applicativi differenti.

I container

Un container è l’unità destinata a contenere (e, in da un certo punto di vista, ad essere) l’applicativo. Viene creato a partire da una image ed è ciò che Docker effettivamente esegue. Se manteniamo la metafora con i sistemi di macchine virtuali, che qui scricchiola ulteriormente, il container è la macchina.

I container sono isolati tra loro: anche se utilizzano il medesimo kernel, non hanno modo di vedersi l’un l’altro, fisicamente.

Possono però esporre porte di rete agli altri container, in modo che gli applicativi possano parlare tra loro attraverso la rete.

Diversamente dalle image, per le quali viene creato un layer ad ogni modifica, tutte le modifiche a un container non sono storicizzate. Vengono scritte tutte assieme sull’ultimo layer, detto layer scrivibile, e vengono perse se il container viene ricreato.

I volume

Il fatto che alla ri-creazione di un container si perdano tutte le modifiche fatte dalla sua creazione in poi, li rende poco adatti alle applicazioni che hanno la necessità di scrivere sulla macchina dati che devono essere conservati.

Per questo Docker permette di creare delle strutture di storage chiamate volume.

Un volume è una struttura dati, residente sul filesystem della macchina host, a cui i container sono in grado di accedere. Risiedendo sul filesystem dell’host, il volume non fa parte del layer scrivibile del container, ed è quindi persistente, sopravvivendo al container stesso. Inoltre, grazie al suo essere comunque una struttura definita e regolata da Docker, il volume può essere in tutta sicurezza condiviso tra container diversi e può essere gestito dagli strumenti in riga di comando di Docker.

I bind mount

Docker permette anche di linkare cartelle della macchina host all’interno di un container, per poter accedere al loro contenuto, eventualmente anche in scrittura. 

Si perdono tutti i vantaggi dei volumi, ma l’utilizzo può comunque essere opportuno nei casi in cui questi vantaggi non servono e serve invece di poter accedere il più facilmente possibile ai dati dal sistema host.

I network

Tra i vari container è possibile definire network, delle vere e proprie reti, separate tra loro. Si possono così, se necessario, far vivere gruppi di container diversi su reti distinte, senza che interferiscano tra loro.

File di configurazione: docker-compose e Dockerfile

Nel configurare Docker per Laravel incontreremo due tipi di file di configurazione diversi, il file docker-compose.yml e i file Dockerfile.

Il primo è un file leggibile dall’utility docker-compose e permette di definire come vanno creati container, volume, eventuali bind mount e networks.

I Dockerfile, invece, servono a configurare le singole image prima che vengano utilizzate per costruire container.

L’ambiente host

Docker, come detto in precedenza, utilizza le funzionalità di isolamento del kernel Linux. È quindi necessario che la macchina host sia una macchina Linux (può girare anche su Windows installando docker-machine, di fatto una macchina virtuale minimale con Linux).

La configurazione dell’host che si utilizza in questo articolo con sistema operativo Debian 10 con installato Docker e DockerCompose, assieme al driver per local-persist (che vedremo più avanti), ma va bene una qualunque altra distribuzione con questi software. 

Con il s.o. Debian occorre installare i software necessari con il comando: 

apt install docker docker-compose
curl -fsSL https://raw.githubusercontent.com/MatchbookLab/local-persist/master/scripts/install.sh | sudo bash

L’utente in uso, che chiamiamo utente, deve appartenere al gruppo docker. È quindi necessario lanciare anche il comando:

sudo adduser utente docker

Docker e Laravel

Per utilizzare Laravel su Docker dobbiamo prima di tutto scomporre l’ambiente su cui Laravel gira nei suoi applicativi fondamentali, in modo da poter ricreare tale ambiente passo passo. Ci serviranno:

  • php ovviamente, con tutti gli annessi di php, come le sue librerie principali e composer.
  • un server web, useremo nginx
  • un RDBMS compatibile, useremo MariaDB
  • Redis, per il caching

Configurazione delle Image

Iniziamo col configurare le image.

Creiamo nel nostro progetto una directory docker (sotto controllo versione). Qui dentro creeremo una struttura di directory per contenere tutti i file di configurazione che ci servono per le image.

php

L’image con php sarà anche quella che conterrà il sistema operativo che utilizzeremo per lanciare i principali comandi, che richiedono tutti php. Per il momento, però, ci metteremo solo il minimo necessario, e torneremo poi ad aggiungere il resto. Creiamo una directory php sotto la directory docker, e creiamo al suo interno un file di testo Dockerfile con questo contenuto:

FROM php:7.2-fpm-alpine

RUN apk update && apk upgrade && apk add \
      freetype-dev \
      libjpeg-turbo-dev \
      libpng-dev \
      autoconf \
      gcc \
      build-base \
      yarn \
      imagemagick \
      imagemagick-libs \
      imagemagick-dev \
    && docker-php-ext-install -j$(nproc) iconv \
    && docker-php-ext-configure gd –with-freetype-dir=/usr/include/ –with-jpeg-dir=/usr/include/ \
    && docker-php-ext-install -j$(nproc) gd \
    && docker-php-ext-install -j$(nproc) zip \
    && docker-php-ext-install -j$(nproc) mysqli pdo pdo_mysql

RUN pecl install redis-4.0.1 && docker-php-ext-enable redis
RUN pecl install imagick && docker-php-ext-enable imagick

RUN php -r “copy(‘https://getcomposer.org/installer’, ‘composer-setup.php’);” \
    && php -r “if (hash_file(‘sha384’, ‘composer-setup.php’) === ‘a5c698ffe4b8e849a443b120cd5ba38043260d5c4023dbf93e1558871f1f07f58274fc6f4c93bcfd858c6bd0775cd8d1’) { echo ‘Installer verified’; } else { echo ‘Installer corrupt’; unlink(‘composer-setup.php’); } echo PHP_EOL;” \
    && php composer-setup.php –install-dir=/usr/local/bin –filename=composer \
    && php -r “unlink(‘composer-setup.php’);”

WORKDIR /var/www

EXPOSE 9000

CMD php-fpm

Analizziamolo.

Il primo comando, FROM, specifica quale sia la image di partenza sulla quale andremo ad aggiungere layer. La image verrà scaricata da un repository centralizzato, il Docker Hub. Abbiamo specificato la image php, che è una image ufficiale di Docker (esistono image già personalizzate, da evitare se non si sa bene cosa contengano), nella sua versione 7.2-fpm-alpine. Per fpm ovviamente si intende che si tratta della versione con php-fpm, mentre alpine indica la versione basata su Alpine Linux, una distribuzione Linux estremamente leggera (qualche MB), molto utilizzata su Docker per la sua estrema pulizia, leggerezza e sicurezza.

Con i successivi RUN installiamo (quasi) tutto il necessario per un’installazione di Laravel. Ci sono solo due cose importanti da notare. La prima è che ogni RUN aggiunge un layer: è per questo che ammucchiamo più comandi in un singolo RUN: è saggio non mettere in RUN diversi comandi che agiscono sullo stesso file se non c’è un buon motivo per farlo, in quanto verranno conservate tutte le versioni di quel file, e ingombrano. La seconda, invece, è che il comando di installazione di Composer cambia la firma a seconda della versione, verificate il comando attuale sul sito.

Il comando WORKDIR imposta la directory di base per questa image, tale directory sarà utilizzata come base per i successivi comandi e per i comandi che lanceremo sul container.

Infine, CMD esegue php-fpm all’avvio della macchina.

nginx

Il server web avrà un Dockerfile molto più semplice, ma allo stesso tempo richiede di configurare un po’ il software. Per farlo, ho preferito ricreare l’intera struttura di directory di configurazione sotto la directory docker/nginx. Quindi creiamo sotto docker la directory nginx, con dentro etc/nginx/conf.d.

Dentro etc/nginx/ creiamo il file di configurazione principale di nginx, nginx.conf:

worker_processes 4;
pid /run/nginx.pid;
daemon off;

events {
  worker_connections  2048;
  multi_accept on;
  use epoll;
}

http {
  server_tokens off;
  sendfile on;
  tcp_nopush on;
  tcp_nodelay on;
  keepalive_timeout 15;
  types_hash_max_size 2048;
  client_max_body_size 20M;
  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  access_log /var/log/nginx/access.log;
  error_log /var/log/nginx/error.log;
  gzip on;
  gzip_disable “msie6”;
 
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
  ssl_ciphers ‘ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS’;
 
  include /etc/nginx/conf.d/*.conf;
  open_file_cache off;
  charset UTF-8;
}

All’interno di etc/nginx/conf.d mettiamo invece la configurazione del nostro nginx server, nel file default.conf:

server {

    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    server_name localhost;
    root /var/www/public;
    index index.php index.html index.htm;

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

    location ~ \.php$ {
        try_files $uri /index.php =404;
        fastcgi_pass php-upstream;
        fastcgi_index index.php;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        #fixes timeouts
        fastcgi_read_timeout 600;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }

    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt/;
        log_not_found off;
    }
}

Spiegare i parametri di questi file è parecchio al di fuori dello scopo di questo articolo, quindi vi rimando alla documentazione.

Passiamo a configurare la image, con un file Dockerfile sotto docker/nginx:

FROM nginx:alpine

COPY /etc/nginx/nginx.conf /etc/nginx/
COPY /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/

RUN apk update \
    && apk upgrade \
    && apk add –no-cache bash

# Set upstream conf
RUN echo “upstream php-upstream { server NOMEMACCHINA_php:9000; }” > /etc/nginx/conf.d/upstream.conf


CMD [“nginx”]

EXPOSE 80 443

Il comando FROM, come per php, ci permette di selezionare la image di partenza dal Docker Hub. Utilizziamo l’ultima versione di nginx su Alpine Linux.

I due comandi COPY copiano nella image i file di configurazione che abbiamo creato.

Il primo comando RUN aggiorna i pacchetti e aggiunge la shell (può sempre servire a dare un’occhiata in seguito, ma non è necessaria), mentre il secondo crea un terzo file di configurazione con il riferimento a php-fpm. L’indirizzo NOMEMACCHINA_php è il nome che daremo al container con php nel file per docker-compose. Ricordiamo che dovrà corrispondere.

CMD lancia il server nginx all’avvio dell’immagine ed EXPOSE apre sulla macchina le porte 80 e 443.

MariaDB

MariaDB ha una configurazione abbastanza banale. Creiamo al solito la directory mariadb sotto docker, con il necessario per la configurazione del software. Creiamo etc/mysql/conf.d/ con dentro il file my.cnf:

[mysql]

[mysqld]
sql-mode=”STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION”
character-set-server=utf8

Per spiegazioni sui parametri utilizzati, anche qui rimando alla documentazione ufficiale.

Passiamo al Dockerfile, da mettere al solito sotto docker/mariadb:

FROM mariadb:10.3

ARG TZ=UTC
ENV TZ ${TIMEZONE}
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN chown -R mysql:root /var/lib/mysql/

COPY ./etc/mysql/conf.d/my.cnf /etc/mysql/conf.d/my.cnf

CMD [“mysqld”]

EXPOSE 3306

Con FROM selezioniamo la image di partenza e la sua versione.

ARG ed ENV impostano il default (usato prima che la image venga eseguita) e il valore reale (usato in esecuzione) per la variabile di ambiente TZ, che contiene la timezone. Se non avete impostato alla timezone corretta la variabile TIMEZONE nel .env di Laravel, fatelo.

Il successivo RUN imposta i permessi corretti per le directory degli eseguibili di MariaDB, e COPY copia il file di configurazione nella image.

CMD avvia il daemon di MariaDB ed EXPOSE apre la porta 3306 sul container.

Redis

Redis ha solo il Dockerfile, da mettere in docker/redis, semplicissimo:

FROM redis:latest

VOLUME /data

EXPOSE 6379

CMD [“redis-server”]

FROM ha la sua solita funzione, VOLUME crea un volume Docker montato su /data (nella macchina host sarà creato in una cartella gestita da Docker), EXPOSE apre al solito la porta per accedere al server, e CMD esegue il daemon.

Aggiungiamo il crontab e Supervisor

Nella gran parte degli applicativi Laravel è necessario impostare una riga nel crontab per eseguire i task periodici, ed è necessario impostare Supervisor per il queue worker.

Considerando che entrambe le cose hanno necessità di utilizzare php, non possiamo che aggiungerle nella image che contiene php.

Prepariamo quindi la directory redis/php ad accogliere i file di configurazione, creando al suo interno una directory etc contenente le directory cron.d e supervisor, e all’interno di supervisor la directory conf.d.

In cron.d aggiungiamo il file laravel-cron con la riga necessaria:

* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1

In supervisor creiamo il file supervisord.conf con la configurazione di base:

[unix_http_server]
file=/var/run/supervisor.sock

[supervisord]
logfile=/var/www/storage/logs/supervisord.log
pidfile=/var/run/supervisord.pid
nodaemon=true

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl=unix:///var/run/supervisor.sock

[include]
files = /etc/supervisor/conf.d/*.ini

In supervisor/conf.d aggiungiamo il file laravel.ini con la configurazione per Supervisor:

[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis –queue=default –tries=1 –timeout=0 –daemon
autostart=true
autorestart=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/queue-worker.log

La documentazione è da queste parti.

Purtroppo, nel Dockerfile non è possibile eseguire più di un applicativo all’avvio del container, poiché l’idea è che ogni applicativo dovrebbe avere il suo container. A volte, come in questo caso, è però necessario. Un modo semplice per aggirare la limitazione è configurare Supervisor per eseguire lui sia php che cron.

Aggiungiamo quindi in supervisor/conf.d il file cron.ini per configurare l’avvio di cron:

[program:cron]
process_name=cron
command=crond
user=root
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/cron.log

e il file php_fpm.ini per configurare l’avvio di php-fpm:

[program:php]
process_name=php-fpm
command=php-fpm
user=root
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/php.log

Andiamo a questo punto ad aggiungere il necessario nel Dockerfile di php.

Nel primo comando RUN aggiungiamo l’installazione di supervisor:


      yarn \
+     supervisor \
    && docker-php-ext-install -j$(nproc) iconv \

Dopo l’installazione delle librerie php, aggiungiamo i comandi per copiare le configurazioni che abbiamo creato, impostare i permessi corretti e creare il file di log per cron:


RUN pecl install imagick && docker-php-ext-enable imagick

+RUN mkdir /etc/supervisor.d
+COPY etc/supervisor/conf.d/laravel.ini /etc/supervisor/conf.d/laravel.ini
+COPY etc/supervisor/conf.d/cron.ini /etc/supervisor/conf.d/cron.ini
+COPY etc/supervisor/conf.d/php_fpm.ini /etc/supervisor/conf.d/php_fpm.ini
+COPY etc/supervisor/supervisord.conf /etc/supervisor/supervisord.conf

+COPY etc/cron.d/laravel-cron /etc/cron.d/laravel-cron
+RUN chmod 0644 /etc/cron.d/laravel-cron
+RUN touch /var/log/cron.log

RUN php -r “copy(‘https://getcomposer.org/installer’, ‘composer-setup.php’);” \

Infine, dopo l’apertura della porta, sostituiamo l’esecuzione di php con quella del demone di Supervisor:

EXPOSE 9000

+CMD supervisord -c /etc/supervisor/supervisord.conf
-CMD php-fpm

Configurazione di container e ambiente

A questo punto siamo pronti per configurare tutto quello che serve per far girare correttamente i container che creeremo a partire da queste macchine.

Considerato che i container avranno un nome, che dipenderà dal vostro progetto, utilizzerò la stringa NOMEMACCHINA, che dovrete sostituire con il nome, ad esempio, del vostro progetto. La stessa cosa va fatta nel Dockerfile di nginx.

La configurazione dei container e di tutto il necessario viene effettuata da un’utilità che si chiama docker-compose, e che prende in pasto il file docker-compose.yml che dovremo mettere nella directory base del progetto (la stessa in cui si trova la directory docker).

Il file è piuttosto lungo, vediamolo per sezioni. Prima le cose semplici, definiamo il network che ci serve:

version: ‘3’

networks:
  default:
    external:
      name: server_default

La riga version definisce semplicemente la versione del tipo di file di configurazione (la sua struttura è cambiata nel tempo).

Con networks, invece, definiamo il nostro network, che è di tipo “external” (vive sull’host) e si chiama server_default. Dovremo creare il network sull’host, con il comando docker network create server_default.

Passiamo alla configurazione dei volumi:

volumes:
  db_data:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/db/NOMEMACCHINA
  www_storage:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/storage/NOMEMACCHINA
  nginx_logs:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/logs/NOMEMACCHINA/nginx
  laravel_logs:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/logs/NOMEMACCHINA/laravel

La configurazione è piuttosto semplice. Per ogni volume, a cui diamo un nome (la chiave che contiene la sua configurazione), utilizza il driver local-persist, che ci permette di scegliere dove posizionarlo sulla macchina host (il mountpoint). Ho deciso di mettere tutti i volumi nella directory /var/server/ dell’host, divisi per progetto. Potete ovviamente fare la scelta che ritenete più opportuna.

In coda, dovremo aggiungere la configurazione dei services, ovvero di fatto dei container. È piuttosto complessa:

services:

  NOMEMACCHINA_php:
    container_name: NOMEMACCHINA_php
    build:
      context: ./docker/php
    volumes:
      ./:/var/www
      www_storage:/var/www/storage
      laravel_logs:/var/www/laravel
    networks:
      default
    restart: always

  NOMEMACCHINA_nginx:
    container_name: NOMEMACCHINA_nginx
    build:
      context: ./docker/nginx
    volumes:
      ./:/var/www
      www_storage:/var/www/storage
      nginx_logs:/var/log/nginx
      ./docker/nginx/sites_available:/etc/nginx/sites-available
    depends_on:
      gimmi_mi_m4_php
    networks:
      default
    restart: always

  NOMEMACCHINA_mariadb:
    container_name: NOMEMACCHINA_mariadb
    env_file:
      .env
    build:
      context: ./docker/mariadb
      args:
        TIMEZONE=${TIMEZONE}
    environment:
      MYSQL_DATABASE=${DB_DATABASE}
      MYSQL_USER=${DB_USERNAME}
      MYSQL_PASSWORD=${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD=${DB_PASSWORD}
      TZ=${TIMEZONE}
    volumes:
      db_data:/var/lib/mysql
    networks:
      default
    restart: always

  NOMEMACCHINA_redis:
    container_name: NOMEMACCHINA_redis
    build: ./docker/redis
    networks:
      default
    restart: always

Per ogni servizio avremo queste chiavi:

  • container_name è il nome del container, che viene utilizzato anche come nome di rete
  • build.context indica la directory in cui è presente il Dockerfile dell’immagine a cui il servizio fa riferimento. Al suo posto si può usare image che indica direttamente la image di riferimento, se non va ulteriormente modificata con un Dockerfile.
  • build.args specifica variabili utilizzabili nel Dockerfile.
  • volumes è un array di volume che la macchina deve vedere. A sinistra di : si trova il nome del volume, definito prima nel file, mentre a destra si trova il punto in cui il volume verrà montato. Se a sinistra al posto del nome c’è un percorso, il volume sarà un bind mount. Il percorso è relativo a dove si trova il file docker-compose.yml.
  • networs indica su quali reti la macchina è visibile. Useremo sempre l’unica rete definita in cima al file, default
  • restart indica come comportarsi con il servizio quando Docker (o l’host) si riavvia. Usiamo sempre always, che fa riavviare sempre il servizio al riavvio di Docker.
  • depends_on indica che il servizio dipende da un altro dei servizi (e va quindi avviato dopo).
  • env_file specifica un file con variabili d’ambiente, che possiamo poi utilizzare nel successivo environment.
  • environment permette di definire variabili d’ambiente. 

Primo avvio del software

A questo punto tutto è pronto per avviare la nostra applicazione. Se non lo abbiamo già fatto, per prima cosa bisognerà creare il network, con il comando

docker network create server_default

Creiamo poi la directory /var/server/ per contenere gli storage:

sudo mkdir /var/server/

A questo punto, possiamo far creare a docker-compose le macchine, per poi fargliele avviare:

docker-compose build
docker-compose up -d

Il comando docker-ps dovrebbe mostrare le nostre macchine attive e in funzione.

Possiamo ora installare il nostro applicativo Laravel, copiando anche nel volume di storage la directory storage:

docker exec NOMEMACCHINA_php composer install
docker cp -r storage NOMEMACCHINA_php:/var/www/storage
docker exec NOMEMACCHINA_php chown -R www-data:www-data storage
docker exec NOMEMACCHINA_php chown -R www-data:www-data bootstrap/cache
docker exec NOMEMACCHINA_php php artisan migrate

Il nostro sistema a questo punto è pienamente funzionante. Vivendo nel network di Docker, però, non è raggiungibile dall’esterno. Dobbiamo quindi creare un reverse proxy che ci consenta di pubblicarlo all’esterno del server (e di pubblicare eventuali altri applicativi creati nella stessa maniera).

Il proxy

Il proxy vivrà fuori dall’applicativo Laravel, poiché servirà a tutti gli applicativi presenti nel sistema. È un semplice reverse proxy nginx, ma installato in Docker per poter fare riferimento ai nomi delle macchine come nomi di rete: un proxy esterno dovrebbe usare gli indirizzi IP delle macchine, che possono variare riavviando rendendo davvero poco pratico il meccanismo.

Creiamo quindi una directory sul server dove metteremo la configurazione del proxy, e ci creiamo dentro un file docker-compose.yml.

version: ‘3’

services:

  webproxy:
    container_name: webproxy
    image: nginx
    ports:
      “80:80”
    volumes:
      conf:/etc/nginx
    restart: always

volumes:

  conf:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/webproxy

networks:
  default:
    external:
      name: server_default

Dovremmo ormai avere una certa familiarità con questo file. L’unica cosa da notare è il fatto che creiamo una nuova directory sotto /var/server/ con la configurazione del proxy.

Possiamo scrivere, dopo aver avviato il container con docker up -d, la configurazione direttamente nello storage, con l’editor che si preferisce, anteponendo sudo. Il primo file da modificare è /var/server/webproxy/nginx.conf, che dovrà essere analogo a questo (di fatto restituiamo un errore 403 sull’accesso diretto):

http {
    server {
        listen       80;
        server_name  localhost;

        location / {
            root   html;
            index  index.html index.htm;
            return 403;
        }
    }
}
include /etc/nginx/sites/*;

A questo punto, possiamo configurare il sito, nel file /var/server/webproxy/sites/NOMEMACCHINA.conf, dandogli un nome opportuno. Nel file va sostituito DOMINIO con il nome dominio, .

server {
        listen 80;
        server_name     DOMINIO;

        location / {
                set $upstreamName       NOMEMACCHINA:80;
                proxy_pass_header       Server;
                proxy_set_header        Host $host;
                proxy_set_header        X-Real-IP $remote_addr;
                proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header        X-Forwarded-Proto $scheme;
                proxy_pass              http://$upstreamName;
    }
}

Riavviando il container con docker up -d, vedremo finalmente il nostro server da fuori, utilizzando il suo nome di dominio.

HTTPS con Let’s Encrypt

HTTP ovviamente non ci basta. Per avere HTTPS, useremo una image apposita per configurare i certificati con Let’s Encrypt. Servendoci una volta sola (a progetto), non salveremo file di configurazione per Docker, ma sarà comunque necessario configurare l’accesso al provider DNS.

Per configurare correttamente Let’s Encrypt in maniera automatizzata, è necessario che il provider che fornisce il servizio di DNS abbia un web service supportato da Certbot, il client per Let’s Encrypt.

L’esempio che faremo è per il DNS di CloudFlare, di cui ci serviamo alla TwoBeeSolution, ma molto probabilmente il vostro provider fornisce un servizio del tutto analogo.

Prima di tutto dobbiamo creare una directory nella home dell’utente, che chiameremo certbot. All’interno, metteremo un file cloudflare-credentials.ini, con le credenziali di accesso alle API di Cloudflare, disponibili nella pagina dell’account. Il file sarà una cosa di questo tipo:

dns_cloudflare_email = [email protected]
dns_cloudflare_api_key = 0123456789abcdef0123456789abcdef01234567

A questo punto possiamo eseguire la nostra image

docker run -it –rm -v ~/certbot:/creds -v /var/server/certs/NOMEMACCHINA:/etc/letsencrypt certbot/dns-cloudflare certonly
–rsa-key-size 3072 –dns-cloudflare –dns-cloudflare-credentials /creds/cloudflare-credentials.ini –dns-cloudflare-propagation-seconds 90

  • -it fa sì che il comando sia eseguito interattivamente (i sta per interattivo, t sta per “su terminale”)
  • –rm rimuove automaticamente il container una volta eseguito
  • -v /var/server/certs/NOMEMACCHINA:/etc/letsencrypt crea un volume in cui andremo a salvare i certificati, che sull’host sarà in /var/server/certs/NOMEMACCHINA
  • certbot/dns-cloudflare è la image per creare i certificati andando a verificare il DNS con le API di Cloudflare. La sua documentazione è qui.

Certbot farà diverse domande, a cui è necessario rispondere per creare un certificato adatto al nostro sistema.

Creati i certificati, dobbiamo andare a configurare il nginx che fa da reverse proxy per utilizzarli. Modifichiamo quindi sull’host (serve sudo) il file /var/server/webproxy/sites/NOMEMACCHINA.conf per aggiungere il necessario ad utilizzare i certificati (per praticità riporto nuovamente l’intero file):

server {
        include /etc/nginx/bots.d/ddos.conf;

        listen 80;
        server_name     DOMINIO;

        location / {
                return 301      https://DOMINIO$request_uri;
        }
}

server {
        listen 443 ssl http2;
        server_name     DOMINIO;

        ssl_certificate         /etc/le_certs/NOMEMACCHINA/live/DOMINIO/fullchain.pem;
        ssl_certificate_key     /etc/le_certs/NOMEMACCHINA/live/DOMINIO/privkey.pem;

        location / {
                set $upstreamName       NOMEMACCHINA:80;
                proxy_pass_header       Server;
                proxy_set_header        Host $host;
                proxy_set_header        X-Real-IP $remote_addr;
                proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header        X-Forwarded-Proto $scheme;
                proxy_pass              http://$upstreamName;
        }
}

Infine, sempre sul nginx che fa da proxy, è necessario aggiungere lo storage con i certificati nel docker-compose.yml, assieme all’apertura della porta 443:


ports:
      – “80:80”
+     – “443:443”
    volumes:
      – conf:/etc/nginx
+      – gimmi_mi_m4_certs:/etc/le_certs/DOMINIO
    restart: always

volumes:

  conf:
    driver: local-persist
    driver_opts:
      mountpoint: /var/server/webproxy

+NOMEMACCHINA_certs:
+    driver: local-persist
+    driver_opts:
+      mountpoint: /var/server/certs/NOMEMACCHINA

Riavviando il proxy con docker-compose up -d, avremo il sistema disponibile su HTTPS.

Qualche script utile

Per concludere l’articolo, ecco alcuni script utili alla manutenzione, sia dei singoli progetti che dell’intero apparato.

Rebuild

In caso di aggiornamento della configurazione (Dockerfile, docker-compose.yml o file di configurazione sotto la directory docker) è necessario procedere al rebuild del container. Questo script, da mettere nella directory docker del vostro sistema Laravel, fa tutto il necessario:

#!/bin/bash
cd “$(dirname “$0″)/..” || exit
docker exec NOMEMACCHINA_php php artisan down
git pull
docker-compose build
docker-compose up -d
docker exec NOMEMACCHINA_php composer install
docker cp -r storage NOMEMACCHINA_php:/var/www/storage
docker exec NOMEMACCHINA_php chown -R www-data:www-data storage
docker exec NOMEMACCHINA_php chown -R www-data:www-data bootstrap/cache
docker exec NOMEMACCHINA_php php artisan up

Update

Quando invece modifichiamo solo cose all’interno del software in Laravel, non è necessario fare il rebuild della macchina, mentre dobbiamo aggiornare i pacchetti di Laravel ed effettuare le operazioni di manutenzione sul database (attenzione: utilizzo migrate –force, potreste non volerlo):

#!/bin/bash
cd “$(dirname “$0″)/..” || exit
docker exec NOMEMACCHINA_php php artisan down
git pull
docker exec NOMEMACCHINA_php composer install
docker exec NOMEMACCHINA_php composer dump-autoload
docker exec NOMEMACCHINA_php php artisan migrate
docker exec NOMEMACCHINA_php php artisan up

Let’s encrypt

I certificati di Let’s Encrypt, ovviamente, scadono e vanno rinnovati. Possiamo mettere uno script per rinnovarli automaticamente, per tutti i progetti, in ~/bin/letsencrypt_renewal:

#!/bin/sh

for d in /var/server/certs/*; do
    echo “—— $d” > /dev/stderr
    docker run –rm -v ~/certbot:/creds -v “$d”:/etc/letsencrypt certbot/dns-cloudflare renew –rsa-key-size 3072 –dns-cloudflare –dns-cloudflare-credentials /creds/cloudflare-credentials.ini –dns-cloudflare-propagation-seconds 90
    echo “—— $d” > /dev/stderr
done

docker restart webproxy

Ovviamente, se credete, potete aggiungere lo script al crontab della macchina host:

* 7,19 * * * /home/UTENTE/bin/letsencrypt_renewal

Una nota

L’architettura descritta in questo articolo ha uno strato in più del minimo necessario (ci sono due server http), e potreste volerlo eliminare. L’ho inserito perché permette di realizzare un’applicazione completa (aprendo le porte del server sulla macchina host si può evitare il proxy), ma lo si può eliminare senza problemi, e gli strumenti necessari sono i medesimi già descritti in questo articolo.

Un modo di eliminare uno strato conserva i due server http e la completezza, ma elimina una comunicazione di rete: al posto della macchina php si può utilizzare una macchina Apache, con mod_php e php. Il resto rimane grossomodo invariato.

Un altro modo, invece, è eliminare la macchina nginx, e configurare opportunamente il nginx che fa da reverse proxy per servire le pagine elaborate da php-fpm.

Infine, se il nostro applicativo Laravel è l’unico presente sul server, possiamo direttamente esporre con il comando ports nel docker-compose.yaml le porte 80 e 443 del nginx dell’applicativo.

Instagram

Connection error. Connection fail between instagram and your server. Please try again