Enhance Docker Configuration and Health Checks (#761)

* Enhance Docker Configuration and Health Checks

- Added PHP configuration settings in `docker-compose.dev.yml` and `docker-compose.yml` to improve memory management and execution limits, ensuring better performance for PHP applications.
- Introduced health checks for various services including `api`, `api-worker`, `api-scheduler`, `ui`, `redis`, and `db` to ensure service availability and reliability.
- Updated environment variables in `.env.docker` and `client/.env.docker` to include new keys for H-Captcha and reCAPTCHA, enhancing security features.
- Refactored the PHP-FPM entrypoint script to apply PHP configurations dynamically based on environment variables, improving flexibility in deployment.
- Removed outdated PHP configuration files to streamline the Docker setup.

These changes aim to enhance the overall stability, performance, and security of the application in a Dockerized environment.

* Refactor Dockerfile for Improved Build Process

- Changed the Dockerfile to utilize a multi-stage build approach, separating the build and runtime environments for better efficiency.
- Introduced a builder stage using the PHP CLI image to install dependencies and extensions, optimizing the final image size.
- Removed unnecessary installation steps and combined related commands to streamline the Dockerfile, enhancing readability and maintainability.
- Updated the runtime stage to use the PHP FPM Alpine image, ensuring a smaller and more secure production environment.

These changes aim to improve the build process, reduce image size, and enhance the overall performance of the Dockerized application.
This commit is contained in:
Julien Nahum 2025-05-20 19:20:44 +02:00 committed by GitHub
parent b2b04d7f2a
commit ae21cae8cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 320 additions and 164 deletions

View File

@ -10,6 +10,7 @@ LOG_CHANNEL=errorlog
LOG_LEVEL=debug
FILESYSTEM_DRIVER=local
LOCAL_FILESYSTEM_VISIBILITY=public
BROADCAST_CONNECTION=log
@ -36,5 +37,17 @@ AWS_BUCKET=
JWT_TTL=1440
JWT_SECRET=
JWT_SKIP_IP_UA_VALIDATION=true
OPEN_AI_API_KEY=
OPEN_AI_API_KEY=
H_CAPTCHA_SITE_KEY=
H_CAPTCHA_SECRET_KEY=
RE_CAPTCHA_SITE_KEY=
RE_CAPTCHA_SECRET_KEY=
TELEGRAM_BOT_ID=
TELEGRAM_BOT_TOKEN=
SHOW_OFFICIAL_TEMPLATES=true

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Carbon;
class SchedulerStatusCommand extends Command
{
private const MODE_CHECK = 'check';
private const MODE_RECORD = 'record';
private const CACHE_KEY_LAST_RUN = 'scheduler_last_run_timestamp';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:scheduler-status {--mode=' . self::MODE_CHECK . ' : Mode of operation ("' . self::MODE_CHECK . '" or "' . self::MODE_RECORD . '")} {--max-minutes=3 : Maximum minutes since last run for check mode}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Records or checks the scheduler last run timestamp, conditional on self-hosted mode.';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$isSelfHosted = config('app.self_hosted', false);
if (!$isSelfHosted) {
$this->error('This command is only functional in self-hosted mode. Please set SELF_HOSTED=true in your .env file.');
Log::warning('SchedulerStatusCommand: Attempted to run in non-self-hosted mode.');
return \Illuminate\Console\Command::FAILURE;
}
$mode = $this->option('mode');
if ($mode === self::MODE_RECORD) {
Cache::put(self::CACHE_KEY_LAST_RUN, Carbon::now()->getTimestamp(), Carbon::now()->addHours(2));
$this->info('Scheduler last run timestamp recorded.');
Log::info('SchedulerStatusCommand: Recorded last run timestamp.');
return \Illuminate\Console\Command::SUCCESS;
}
// Default to 'check' mode (this covers explicit 'check' or any other value for mode)
$lastRunTimestamp = Cache::get(self::CACHE_KEY_LAST_RUN);
if (!$lastRunTimestamp) {
$this->error('Scheduler last run timestamp not found.');
Log::warning('SchedulerStatusCommand: Last run timestamp not found during check.');
return \Illuminate\Console\Command::FAILURE;
}
$maxMinutes = (int) $this->option('max-minutes');
if (Carbon::now()->getTimestamp() - $lastRunTimestamp > $maxMinutes * 60) {
$this->error("Scheduler last ran more than {$maxMinutes} minutes ago. Last run: " . Carbon::createFromTimestamp($lastRunTimestamp)->diffForHumans());
Log::warning("SchedulerStatusCommand: Health check failed. Last ran more than {$maxMinutes} minutes ago.");
return \Illuminate\Console\Command::FAILURE;
}
$this->info('Scheduler is healthy. Last ran: ' . Carbon::createFromTimestamp($lastRunTimestamp)->diffForHumans());
return \Illuminate\Console\Command::SUCCESS;
}
}

View File

@ -25,6 +25,9 @@ class Kernel extends ConsoleKernel
{
$schedule->command('forms:database-cleanup')->hourly();
$schedule->command('forms:integration-events-cleanup')->daily();
if (config('app.self_hosted')) {
$schedule->command('app:scheduler-status --mode=record')->everyMinute();
}
}
/**

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
// Base controller
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Illuminate\Http\JsonResponse;
use Throwable;
class HealthCheckController extends Controller
{
public function apiCheck(): JsonResponse
{
// This controller action should only be reachable if config('app.self_hosted') is true
// due to the routing configuration.
$checks = [
'database' => false,
'redis' => false,
];
$overallStatus = true;
try {
DB::connection()->getPdo();
$checks['database'] = true;
} catch (Throwable $e) {
Log::error('Health check: Database connection failed', ['exception' => $e->getMessage()]);
$overallStatus = false;
}
try {
Redis::ping();
$checks['redis'] = true;
} catch (Throwable $e) {
Log::error('Health check: Redis connection failed', ['exception' => $e->getMessage()]);
$overallStatus = false;
}
if ($overallStatus) {
return response()->json([
'status' => 'ok',
'dependencies' => $checks,
]);
}
return response()->json([
'status' => 'error',
'dependencies' => $checks,
], 503);
}
}

View File

@ -30,6 +30,7 @@ use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use App\Http\Controllers\HealthCheckController;
/*
|--------------------------------------------------------------------------
@ -42,6 +43,10 @@ use Illuminate\Support\Facades\Storage;
|
*/
if (config('app.self_hosted')) {
Route::get('/healthcheck', [HealthCheckController::class, 'apiCheck']);
}
Route::group(['middleware' => 'auth:api'], function () {
Route::post('logout', [LoginController::class, 'logout'])->name('logout');
Route::post('update-credentials', [ProfileController::class, 'updateAdminCredentials'])->name('credentials.update');

View File

@ -1,4 +1,7 @@
NUXT_PUBLIC_APP_URL=/
NUXT_PUBLIC_API_BASE=/api
NUXT_PRIVATE_API_BASE=http://ingress/api
NUXT_PUBLIC_ENV=dev
NUXT_PUBLIC_ENV=dev
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_RE_CAPTCHA_SITE_KEY=
NUXT_PUBLIC_ROOT_REDIRECT_URL=

View File

@ -8,7 +8,6 @@
"hasInstallScript": true,
"dependencies": {
"@codemirror/lang-html": "^6.4.9",
"@gtm-support/vue-gtm": "^3.1.0",
"@iconify-json/material-symbols": "^1.2.4",
"@nuxt/ui": "^2.19.2",
"@pinia/nuxt": "^0.11.0",
@ -1448,32 +1447,6 @@
"integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==",
"license": "MIT"
},
"node_modules/@gtm-support/core": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@gtm-support/core/-/core-3.0.1.tgz",
"integrity": "sha512-SctcoqvvAbGAgZzOb7DZ4wjbZF3ZS7Las3qIEByv6g7mzPf4E9LpRXcQzjmywYLcUx2jys7PWJAa3s5slvj/0w==",
"license": "MIT"
},
"node_modules/@gtm-support/vue-gtm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@gtm-support/vue-gtm/-/vue-gtm-3.1.0.tgz",
"integrity": "sha512-kGUnCI+Z5lBeCKd7rzgU7UoFU8Q0EkJfh17SgeyAyx8cLdISqeq54BNJKZrME3WXersoigLZVJ1GLs0buYD3lA==",
"license": "MIT",
"dependencies": {
"@gtm-support/core": "^3.0.1"
},
"optionalDependencies": {
"vue-router": ">= 4.4.1 < 5.0.0"
},
"peerDependencies": {
"vue": ">= 3.2.26 < 4.0.0"
},
"peerDependenciesMeta": {
"vue-router": {
"optional": true
}
}
},
"node_modules/@headlessui/tailwindcss": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",

View File

@ -25,6 +25,11 @@ services:
# Storage settings
FILESYSTEM_DISK: local
LOCAL_FILESYSTEM_VISIBILITY: public
# PHP Configuration
PHP_MEMORY_LIMIT: "1G"
PHP_MAX_EXECUTION_TIME: "600"
PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "64M"
# Development settings
PHP_IDE_CONFIG: serverName=Docker
XDEBUG_MODE: ${XDEBUG_MODE:-off}
@ -86,6 +91,7 @@ services:
environment:
NGINX_HOST: localhost
NGINX_PORT: 80
NGINX_MAX_BODY_SIZE: 64m
ports:
- "80:80"
depends_on:

View File

@ -15,54 +15,75 @@ services:
DB_USERNAME: ${DB_USERNAME:-forge}
DB_PASSWORD: ${DB_PASSWORD:-forge}
DB_CONNECTION: ${DB_CONNECTION:-pgsql}
# Storage settings
FILESYSTEM_DISK: local
LOCAL_FILESYSTEM_VISIBILITY: public
env_file:
# PHP Configuration
PHP_MEMORY_LIMIT: "1G"
PHP_MAX_EXECUTION_TIME: "600"
PHP_UPLOAD_MAX_FILESIZE: "64M"
PHP_POST_MAX_SIZE: "64M"
env_file:
- ./api/.env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
condition: service_healthy # Depend on redis being healthy too
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"]
interval: 30s
timeout: 15s
retries: 3
start_period: 60s
api-worker:
<<: *api-environment
container_name: opnform-api-worker
command: ["php", "artisan", "queue:work"]
volumes: *api-environment-volumes
environment:
<<: *api-env
APP_ENV: production
IS_API_WORKER: "true"
env_file:
- ./api/.env
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
api-scheduler:
<<: *api-environment
container_name: opnform-api-scheduler
command: ["php", "artisan", "schedule:work"]
volumes: *api-environment-volumes
environment:
<<: *api-env
APP_ENV: production
IS_API_WORKER: "true"
# Scheduler settings
IS_API_WORKER: "true" # This might not be strictly true for scheduler, but consistent with setup
CONTAINER_ROLE: scheduler
PHP_MEMORY_LIMIT: 512M
PHP_MAX_EXECUTION_TIME: 60
env_file:
- ./api/.env
healthcheck:
test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1"]
interval: 60s
timeout: 30s
retries: 3
start_period: 70s # Allow time for first scheduled run and cache write
ui:
image: jhumanj/opnform-client:latest
container_name: opnform-client
env_file:
env_file:
- ./client/.env
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 45s
redis:
image: redis:7
container_name: opnform-redis
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 30s
timeout: 5s
db:
image: postgres:16
@ -73,9 +94,8 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD:-forge}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-forge}"]
interval: 5s
interval: 30s
timeout: 5s
retries: 5
volumes:
- postgres-data:/var/lib/postgresql/data
@ -86,11 +106,19 @@ services:
- ./docker/nginx.conf:/etc/nginx/templates/default.conf.template
ports:
- 80:80
environment:
- NGINX_MAX_BODY_SIZE=64m
depends_on:
api:
condition: service_started
ui:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
postgres-data:

View File

@ -1,54 +1,64 @@
FROM php:8.3-fpm
# Build stage - using PHP CLI image with extensions
FROM php:8.3-cli AS builder
# Install system dependencies
# Install composer and extensions needed for dependency installation
RUN apt-get update && apt-get install -y \
libzip-dev \
libpng-dev \
postgresql-client \
libpq-dev \
unzip \
gcc \
make \
autoconf \
libc-dev \
pkg-config \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
git \
&& docker-php-ext-install -j$(nproc) \
bcmath \
gd \
zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Install composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1
WORKDIR /app
# Install PHP extensions
ENV CFLAGS="-O1 -D_GNU_SOURCE" \
CXXFLAGS="-O1 -D_GNU_SOURCE"
# Copy the entire source code for proper installation
COPY api/ .
# Install basic extensions first
RUN set -eux; \
docker-php-ext-install -j$(nproc) pdo zip && \
php -m | grep -E 'pdo|zip'
# Install dependencies including dev dependencies
RUN composer install --optimize-autoloader --no-interaction \
--ignore-platform-req=php \
--ignore-platform-req=ext-bcmath \
--ignore-platform-req=ext-gd
# Install GD
RUN set -eux; \
docker-php-ext-install -j$(nproc) gd && \
php -m | grep -E 'gd'
# Final stage - smaller runtime image
FROM php:8.3-fpm-alpine
# Install PostgreSQL related extensions
RUN set -eux; \
docker-php-ext-configure pgsql && \
docker-php-ext-install -j$(nproc) pgsql pdo_pgsql && \
php -m | grep -E 'pgsql|pdo_pgsql'
# Install runtime dependencies
RUN apk add --no-cache \
libzip \
libpng \
postgresql-client \
libpq \
procps \
unzip \
bash \
icu-libs \
&& rm -rf /var/cache/apk/*
# Install bcmath with optimization flags for stability
RUN set -eux; \
docker-php-ext-install -j1 bcmath && \
php -m | grep -E 'bcmath'
# Install Redis extension
RUN set -eux; \
pecl install -f --configureoptions 'enable-redis-igbinary="no" enable-redis-lzf="no" enable-redis-zstd="no" enable-redis-msgpack="no" enable-redis-lz4="no"' redis && \
docker-php-ext-enable redis && \
php -m | grep -E 'redis'
# Install build dependencies and PHP extensions
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
libzip-dev \
libpng-dev \
postgresql-dev \
oniguruma-dev \
icu-dev \
&& docker-php-ext-configure pgsql \
&& docker-php-ext-configure gd \
&& docker-php-ext-install -j$(nproc) \
pdo \
zip \
gd \
pgsql \
pdo_pgsql \
bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
WORKDIR /usr/share/nginx/html/
@ -62,26 +72,8 @@ RUN mkdir -p storage/framework/sessions \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Copy composer files and helpers.php first
COPY api/composer.json api/composer.lock ./
COPY api/app/helpers.php ./app/helpers.php
# Default to production settings unless overridden during build
ARG APP_ENV=production
ARG COMPOSER_FLAGS=--no-dev --optimize-autoloader --no-interaction
# Install dependencies without running scripts
RUN composer install ${COMPOSER_FLAGS} --no-scripts --ignore-platform-req=php
# Copy the rest of the application
COPY api/ .
# Run composer scripts and clear cache
RUN composer dump-autoload -o \
&& php artisan package:discover --ansi \
&& composer clear-cache \
&& chmod -R 775 storage \
&& chown -R www-data:www-data storage
# Copy the entire application from the builder stage
COPY --from=builder /app/ ./
# Setup entrypoint
COPY docker/php-fpm-entrypoint /usr/local/bin/opnform-entrypoint

View File

@ -20,8 +20,15 @@ RUN npm cache clean --force && \
# RUN npm install esbuild@0.21.5
ADD ./client/ /app/
# Increase Node memory limit to prevent out of memory error during build
RUN NODE_OPTIONS="--max-old-space-size=4096" npm run build
# Optimize memory usage during build
ENV NODE_OPTIONS="--max-old-space-size=4096"
# Set production mode to reduce memory usage
ENV NODE_ENV=production
# Disable source maps to reduce memory usage
ENV GENERATE_SOURCEMAP=false
# Run the build
RUN npm run build
FROM node:20-alpine
WORKDIR /app

View File

@ -7,6 +7,7 @@ server {
listen 80;
server_name opnform;
root /usr/share/nginx/html/public;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
access_log /dev/stdout;
error_log /dev/stderr error;

View File

@ -7,6 +7,7 @@ server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/public;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
access_log /dev/stdout;
error_log /dev/stderr error;

View File

@ -3,42 +3,82 @@
main() {
if [ "$IS_API_WORKER" = "true" ]; then
# This is the API worker, skip setup and just run the command
apply_php_configuration
exec "$@"
else
# This is the API service, run full setup
# This is the API service or scheduler, run full setup
apply_php_configuration
prep_file_permissions
prep_storage
wait_for_db
apply_db_migrations
run_init_project
optimize_application
if [ "$CONTAINER_ROLE" = "scheduler" ]; then
echo "Initializing scheduler status for first run (entrypoint)"
./artisan app:scheduler-status --mode=record
fi
run_server "$@"
fi
}
apply_php_configuration() {
echo "Applying PHP configuration from environment variables"
# Create custom PHP config file
PHP_CUSTOM_CONFIG_FILE="/usr/local/etc/php/conf.d/99-custom.ini"
# Apply memory limit if provided
if [ -n "$PHP_MEMORY_LIMIT" ]; then
echo "memory_limit = $PHP_MEMORY_LIMIT" >> $PHP_CUSTOM_CONFIG_FILE
fi
# Apply max execution time if provided
if [ -n "$PHP_MAX_EXECUTION_TIME" ]; then
echo "max_execution_time = $PHP_MAX_EXECUTION_TIME" >> $PHP_CUSTOM_CONFIG_FILE
fi
# Apply upload max filesize if provided
if [ -n "$PHP_UPLOAD_MAX_FILESIZE" ]; then
echo "upload_max_filesize = $PHP_UPLOAD_MAX_FILESIZE" >> $PHP_CUSTOM_CONFIG_FILE
fi
# Apply post max size if provided
if [ -n "$PHP_POST_MAX_SIZE" ]; then
echo "post_max_size = $PHP_POST_MAX_SIZE" >> $PHP_CUSTOM_CONFIG_FILE
fi
# Log applied configuration
echo "Applied PHP configuration:"
cat $PHP_CUSTOM_CONFIG_FILE
}
prep_file_permissions() {
chmod a+x ./artisan
}
prep_storage() {
# Create Laravel-specific directories
mkdir -p /persist/storage/framework/cache/data
mkdir -p /persist/storage/framework/sessions
mkdir -p /persist/storage/framework/views
local app_storage_path="/usr/share/nginx/html/storage"
# Create Laravel-specific directories directly in the mounted volume
mkdir -p "$app_storage_path/app/public"
mkdir -p "$app_storage_path/framework/cache/data"
mkdir -p "$app_storage_path/framework/sessions"
mkdir -p "$app_storage_path/framework/views"
mkdir -p "$app_storage_path/logs"
# Set permissions for the entire storage directory
chown -R www-data:www-data /persist/storage
chmod -R 775 /persist/storage
# Create symlink to the correct storage location
ln -sf /persist/storage /usr/share/nginx/html/storage
chown -R www-data:www-data "$app_storage_path"
chmod -R 775 "$app_storage_path"
touch /var/log/opnform.log
chown www-data /var/log/opnform.log
# Ensure proper permissions for the storage directory
chown -R www-data:www-data /usr/share/nginx/html/storage
chmod -R 775 /usr/share/nginx/html/storage
# Run Laravel's storage link command (ensure script is run from app root or adjust path to artisan)
echo "Creating public storage symlink"
./artisan storage:link
}
wait_for_db() {

View File

@ -1,15 +0,0 @@
[www]
user = www-data
group = www-data
listen = 9000
pm = dynamic
pm.max_children = 20
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 1000
clear_env = no
catch_workers_output = yes
decorate_workers_output = no

View File

@ -1,27 +0,0 @@
; PHP Configuration
memory_limit = 512M
max_execution_time = 60
upload_max_filesize = 64M
post_max_size = 64M
max_input_vars = 3000
; Error reporting
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /dev/stderr
; Date
date.timezone = UTC
; Session
session.save_handler = redis
session.save_path = "tcp://redis:6379"
; OpCache
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=0