Filament ⚡️ Docker
In una delle mie falcate da Fractional CTO per una delle realtà con cui collaboro, mi sono trovato a dover realizzare un PoC per la creazione di un piccolo pannello di amministrazione. Dopo un po’ di scouting per capire quale potesse essere la soluzione migliore per realizzarne uno in poco tempo, sono atterrato su questo progetto veramente interessante e che non conoscevo: Filament.
Filament è un framework di componenti UI full-stack “pre cucinate” basato sullo stack TALL ( Tailwind, Alpine.js, Laravel, Liveware ) e che ti permette ti costruire un pannello di amministrazione tramite il suo Panel Builder in maniera molto rapida.
Le motivazioni che mi hanno spinto a sceglierlo sono principalmente:
- E’ un oggetto fatto su PHP/Laravel ( un mio vecchio amore e quindi conoscenza ) supportato e utilizzato da molti sviluppatori e quindi con una community abbastanza estesa.
- Ha un bel sistema di plugins che permette di usare anche cose fatte da altri con veramente poco sforzo integrativo.
- Ha tutto quello che serve per un pannello di amministrazione out of the box.
- E’ open source e gratuito (https://github.com/filamentphp/filament).
Il Problema
Quello di cui voglio parlare in questo post però non è di Filament di per se ( ma vi consiglio vivamente di dargli uno sguardo ), ma del fatto che nel suo utilizzo mi sono imbattuto in un po’ di lavoro per fare la configurazione del giusto ambiente per far funzionare il tutto. Questa è una cosa abbastanza tipica del mondo PHP, almeno per come me lo ricordavo quando l’ho lasciato tempo fa, e siccome dopo averlo usato per un PoC mi è capitato di doverlo ( forse più volerlo ) usare anche per un altro PoC, allora ho deciso di scrivermi un piccolo tool che permettesse il bootstrap di un progetto basato su Filament, con giusto una manciata di comandi, ed essere operativo per lo sviluppo molto rapidamente. Per darvi un’ idea di cosa ho voluto risolvere, ecco quello di cui Filament necessita per funzionare:
Dipendenze
Per poter funzionare Filament ha bisogno di:
- PHP 8.1+
- Laravel v10.0+
- Livewire v3.0+
Sistema
Ovviamente il mio scopo è quello di poterci sviluppare sopra in locale e molto rapidamente. Per fare ciò ho bisogno quindi anche di avere:
- Un web server
- Un database
- Qualcosa per far girare e servire l’applicazione PHP
La Soluzione
Alla base di tutta la mia soluzione c’è Docker ed in generale un approccio ai containers che mi permette di essere operativo con tutto quello che mi serve in maniera rapida e soprattutto “sandboxed”, cioè senza inquinare troppo la macchina di sviluppo.
Dockerfile
Il primo step è stato quello di definire un Dockerfile
che rispondesse a tutte le esigenze del progetto, quindi in modo tale che l’immagine risultante dalla build avesse:
- PHP 8.1+
- Laravel v10.0+
- Livewire v3.0+
- PHP-FPM per eseguire il PHP
- Composer per gestire le dipendenze
Quello che ne è venuto fuori è il seguente Dockerfile:
FROM php:8.3-fpm-bullseye AS php-base
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git \
zip \
unzip \
libicu-dev \
&& mkdir -p /app \
&& mkdir -p /.composer && chmod -R 777 /.composer/
WORKDIR /app
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \
&& php composer-setup.php \
&& php -r "unlink('composer-setup.php');" \
&& mv composer.phar /usr/local/bin/composer \
&& docker-php-ext-configure intl && docker-php-ext-install intl \
&& cp /usr/local/etc/php/php.ini-development /usr/local/etc/php/php.ini \
&& curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \
pgsql pdo_pgsql \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs
EXPOSE 9000
FROM php-base AS php-prod
COPY --chown=www-data:www-data ./src /app
WORKDIR /app
RUN set -e; \
cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini; \
composer install --optimize-autoloader --no-dev; \
php artisan config:cache; \
php artisan route:cache; \
php artisan view:cache; \
npm install; \
npm run build; \
chown -R www-data:www-data /app;
USER www-data
Come si può vedere sono partito dalla base php-fpm ufficiale per poi installarci sopra Composer e anche NodeJS per poter gestire tutto quello che riguarda lo sviluppo. Il Dockerfile è un così detto multi-stage file che permette di avere diverse versioni della stessa immagine in fase di build a seconda delle esigenze. Chiaramente qui l’esigenza era quella di avere un’immagine adatta all sviluppo locale ed una adatta al deployment in un ambiente di pre-produzione.
Il target php-base
suppone che in esecuzione venga montanto un volume su /app
con il sorgente php, cosa che invece nella versione php-prod
viene copiato nell’immagine ed usato per generare la versione di produzione della app, con tutte le ottimizazioni del caso.
Il Tool
Dato che lo scopo era quello di avere un tool per la gestione del progetto, il prossimo passo è stato quello di crearmi una serie di scripts bash necessari allo scopo. Il risultato è stato quello di avere uno script main che funge da cli
per questa serie di scripts.
La struttura quindi, alla fine delle varie iterazioni, è diventata la seguente:
├── commands
│ ├── VERSION
│ ├── art
│ ├── build-docker-image
│ ├── common
│ ├── create-admin-user
│ ├── exec
│ ├── generate
│ ├── install-docker
│ ├── install-service
│ ├── migrate-db
│ └── run
└── manage
manage
è appunto uno script bash che da accesso ai vari comandi che a loro volta sono scripts bash che eseguono i vari tasks ( che a loro volta … ah no, basta ).
L’help command alla versione del tool nel momento in cui scrivo è:
$ ./manage help
Manage the laravel/filament project
Version: 0.9.0
Usage: manage [command]
Commands:
install-docker Install the docker engine
build-docker-image [dev|prod] Build/rebuild the docker image for the environment passed as argument
create-admin-user Create a new admin user that can access the dashboard
migrate-db Run the migration tasks for the DB
run Run the docker containers defined in the compose.yml file
generate Generate the laravel/filament project by installing all required components (it is destructive)
exec <command> Exec the command using the php-composer container
art <command> Execute 'php artisan' command using the php-composer container
install-service [name] Install the systemd service with the specified name if passed as argument
* Help
La lista dei comandi che vedete sono quelli che, al momento, sono serviti allo scopo dei PoC da realizzare. Inizialmente c’erano solo comandi utili allo sviluppo in locale ( come generate, run, build-docker-image, create-admin-user, migrate-db, art, etc...
). Poi avendo avuto anche l’esigenza di deployare la soluzione in modo che il PoC fosse usabile dai vari stakeholders in un ambiente che fosse una “pre-produzione”, ho integrato anche altri comandi utili a tale scopo ( come install-docker, install-service
).
Docker compose e infrastruttura
Ovviamente i comandi da soli non hanno capacità di fare nulla di speciale senza una adeguata infrastruttura sottostante.
E’ stato necessario definire un’architettura di runtime per eseguire i vari servizi richiesti come il database ed il web server.
Per fare questo mi sono servito di Docker Compose e delle sue features per orchestare i vari containers. Una delle prime features che ho usato è stata l’abilità di docker compose di poter mergiare e sovrascrivere una serie di compose files insieme. La struttura finale di tutto quello che riguarda docker compose è:
├── Dockerfile
├── compose.dev.yml
├── compose.prod.yml
├── compose.yml
Il file compose.yml
è il file di definizione base dei servizi ed ha il seguente contenuto:
version: "3"
services:
web:
image: nginx:1.25
ports:
- "${NGINX_HOST}:${NGINX_PORT}:${NGINX_INT_PORT}"
volumes:
- ./conf/${NGINX_CONFIG}:/etc/nginx/conf.d/${NGINX_INT_CONFIG}
- ./src/public:/app/public
- ./src/storage/app/public:/app/storage/app/public
php:
build:
context: .
target: php-base
image: php-composer:8.3
ports:
- "${FPM_HOST}:${FPM_PORT}:${FPM_INT_PORT}"
environment:
- DB_CONNECTION=${DB_CONNECTION}
- DB_HOST=postgres
- DB_PORT=${DB_PORT}
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
postgres:
image: postgres:16
ports:
- "127.0.0.1:${DB_PORT}:${DB_INT_PORT}"
environment:
- POSTGRES_DB=${DB_DATABASE}
- POSTGRES_USER=${DB_USERNAME}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=${DB_INT_DATA_PATH}/pgdata
volumes:
- ${DB_EXT_DATA_PATH}:${DB_INT_DATA_PATH}
Come si può osservare i servizi sono:
- Un nginx che funge da web server e reverse proxy verso il php-fpm. Da notare che nginx si prende in carico anche delle cartelle public del progetto per poterle servire direttamente, senza dover passare per il php-fpm.
- Un servizio php che crea/usa l’immagine php-base definita nel docker file
- Un servizio postgres che fornisce appunto il DBMS PostgreSQL
Nel file si fa abbondantemente uso di variabili di ambiente che permettono di definire il comportamento dei servizi senza doverne modificare la definizione. Le variabili sono definite nel file .env
che ha questo contenuto:
SERVICE_NAME=filament-app
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=db-name
DB_USERNAME=pg-user
DB_PASSWORD=needtobechanged
DB_INT_PORT=5432
DB_EXT_DATA_PATH=.local/data
DB_INT_DATA_PATH=/var/lib/postgresql/data
NGINX_HOST=0.0.0.0
NGINX_PORT=80
NGINX_INT_PORT=80
NGINX_CONFIG=nginx.dev.conf
NGINX_INT_CONFIG=default.conf
FPM_HOST=127.0.0.1
FPM_PORT=9000
FPM_INT_PORT=9000
LOGGING_DRIVER=local
LOGGING_MODE=blocking
Qui la reference di come docker compose gestisce il passaggio di variabili di ambiente ai containers https://docs.docker.com/compose/environment-variables/set-environment-variables/.
Lo scopo qui è abilitare la configurazione necessaria al progetto senza doversi preoccupare di modificare il file di definizione dei servizi.
Dando un rapido sguardo al file compose.dev.yml
:
version: "3"
services:
php:
build:
context: .
target: php-base
image: php-composer:8.3
volumes:
- ./src:/app
possiamo vedere che aggiunge al servizio php il mount del volume dei sorgenti.
Ora se diamo uno sguardo al comando commands/run
:
#!/usr/bin/env bash
. "$BAGCLI_WORKDIR/commands/common"
docker compose -f compose.yml -f compose.dev.yml down
cp $LF_SRC_PATH/.env $LF_SRC_PATH/.env.local
echo "" >> $LF_SRC_PATH/.env
echo "# ADDED BY DOCKER RUNTIME" >> $LF_SRC_PATH/.env
cat .env >> $LF_SRC_PATH/.env
docker compose -f compose.yml -f compose.dev.yml up
mv $LF_SRC_PATH/.env.local $LF_SRC_PATH/.env
docker compose -f compose.yml -f compose.dev.yml down
a parte una serie di operazioni di configurazione, possiamo notare il comando
$ docker compose -f compose.yml -f compose.dev.yml up
che è il modo in cui docker compose permette di usare più file di definizione dei servizi mergiandoli tra loro.
Se volete avere una preview di come effettivamente sarà il compose risultante potete eseguire il comando
$ docker compose -f compose.yml -f compose.dev.yml config
che se tutto è ben configurato vi mostrerà un output simile a questo:
name: filament-bootstrapper
services:
php:
build:
context: /filament-bootstrapper
dockerfile: Dockerfile
target: php-base
environment:
DB_CONNECTION: pgsql
DB_DATABASE: db-name
DB_HOST: postgres
DB_PASSWORD: needtobechanged
DB_PORT: "5432"
DB_USERNAME: pg-user
image: php-composer:8.3
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 9000
published: "9000"
protocol: tcp
volumes:
- type: bind
source: /filament-bootstrapper/src
target: /app
bind:
create_host_path: true
postgres:
environment:
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_DB: db-name
POSTGRES_PASSWORD: needtobechanged
POSTGRES_USER: pg-user
image: postgres:16
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 5432
published: "5432"
protocol: tcp
volumes:
- type: bind
source: /filament-bootstrapper/.local/data
target: /var/lib/postgresql/data
bind:
create_host_path: true
web:
image: nginx:1.25
networks:
default: null
ports:
- mode: ingress
host_ip: 0.0.0.0
target: 80
published: "80"
protocol: tcp
volumes:
- type: bind
source: /filament-bootstrapper/conf/nginx.dev.conf
target: /etc/nginx/conf.d/default.conf
bind:
create_host_path: true
- type: bind
source: /filament-bootstrapper/src/public
target: /app/public
bind:
create_host_path: true
- type: bind
source: /filament-bootstrapper/src/storage/app/public
target: /app/storage/app/public
bind:
create_host_path: true
networks:
default:
name: filament-bootstrapper_default
Per completezza il file compose.prod.yml
ha il seguente contenuto:
version: "3"
services:
web:
logging:
driver: ${LOGGING_DRIVER}
options:
mode: ${LOGGING_MODE}
tag: ${SERVICE_NAME}
labels: com.docker.compose.service
php:
build:
context: .
target: php-prod
image: ${SERVICE_NAME}:latest
user: ${DOCKER_USER}
volumes:
- ./src/.env:/app/.env:ro
- ./conf/custom-php.ini:/usr/local/etc/php/conf.d/custom-php.ini:ro
logging:
driver: ${LOGGING_DRIVER}
options:
mode: ${LOGGING_MODE}
tag: ${SERVICE_NAME}
labels: com.docker.compose.service
postgres:
logging:
driver: ${LOGGING_DRIVER}
options:
mode: ${LOGGING_MODE}
tag: ${SERVICE_NAME}
labels: com.docker.compose.service
che in sostanza definisce il target php-prod
per l’immagine del servizio php, ne cambia il nome della immagine risultante dalla build e definisce un sistema di log, configurabile tramite variabili di ambiente, per tutti i servizi. Questa ultima cosa è molto utile per il modello di esecuzione dell’intero sistema che si basa sul systemd
della macchina host su cui girerà la pre-produzione.
Configurazioni di deployment
Un’altro pezzo importante di tutta l’infrastruttura è definito all’interno della cartella conf
del progetto, il cui contenuto è:
conf
├── custom-php.ini
├── filament-app.service
├── nginx.dev.conf
└── nginx.prod.conf
Questa cartella contiene alcuni files utili alla configurazione dei vari servizi, come quelli per le due versioni di nginx, per lo sviluppo e per la produzione, e un file di configurazione per php che andrà a mergiarsi con il php.ini
dell’immagine docker generata ( guardando il file compose.prod.yml
potete vedere che viene montato come volume ).
Un file da considerare e relativo al modo in cui il tutto viene eseguito in produzione è filament-app.service
, che è uno unit file per systemd con il seguente contenuto:
[Unit]
Description=filamnent app service with docker compose
PartOf=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=true
WorkingDirectory=/path/to/project
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml up -d --remove-orphans
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml exec php php artisan config:cache
ExecStart=/usr/bin/docker compose -f compose.yml -f compose.prod.yml exec php php artisan migrate -n
ExecStop=/usr/bin/docker compose -f compose.yml -f compose.prod.yml down
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=filament-app
[Install]
WantedBy=multi-user.target
Come si può vedere questo definisce appunto un servizio per systemd che lancia la versione prod del docker compose ed effettua diversi tasks dopo l’esecuzione, relativi ad esempio alla rigenerazione della cache di laravel e all’applicazione delle eventuali migrazioni necessarie.
Conclusioni
Alla fine di tutto ho costruito un tool che mi permette di:
Generare un progetto filament da zero con tutte le dipendenze
$ ./manage generate
Avviare una versione di sviluppo del progetto con tutto il necessario
$ ./manage run
Avere uno strumento integrato per la gestione di vari task sul progetto
$ ./manage build-docker-image dev # (ri)genera l'immagine docker per lo sviluppo
$ ./manage create-admin-user # crea un nuovo utente admin per il pannello di filament
$ ./manage migrate-db # applica le migrazioni del database
$ ./manage art <command> # shortcut per eseguire php artisan all'interno del container php
$ ./manage exec <command> # shortcut per eseguire un comando all'interno del container php
Avere uno strumento che mi facilita il deployment e l’aggiornamento della soluzione
$ ./manage install-docker # Installa il docker engine sull'host
$ ./manage build-docker-image prod # (ri)genera l'immagine docker per la produzione
$ ./manage install-service <service-name> # Installa il servizio systemd sull'host usando il nome passato come parametro
$ ./manage update-deployment # Rigenera l'immagine docker di produzione e restarta i servizi
L’intero codice sorgente è sul mio GitHub qui https://github.com/Kyserbyte/filament-bootstrapper. Per poterlo usare basta fare il clone o il download del progetto e poi seguire le istruzioni contenute nel file README.md
.
Se poi il progetto vi piace, premete sulla stella per aggiugerlo ai vostri preferiti e restare aggiornati sui vari sviluppi.
Attenzione
La versione attuale, soprattutto la configurazione per il deployment, non è pensata ne lavorata per essere usata in un vero contesto di produzione, soprattutto perchè tralascia molti temi relativi alla sicurezza e alle performance.