diff --git a/php/base.Dockerfile b/php/base.Dockerfile index 523eb33..bd22567 100644 --- a/php/base.Dockerfile +++ b/php/base.Dockerfile @@ -57,6 +57,10 @@ COPY --chmod=755 common/ / # opcache.file_cache needs the directory to exist and be writable RUN mkdir -p /var/cache/opcache && chown www-data:www-data /var/cache/opcache +# The entrypoint installs the metrics listener config as www-data at runtime. +# The nginx variants already chown /etc/nginx, the frankenphp one doesn't. +RUN if [ -d /etc/frankenphp/caddyfile.d ]; then chown www-data:www-data /etc/frankenphp/caddyfile.d; fi + ARG TARGETARCH ARG PHP_VERSION ARG OS diff --git a/php/common/etc/entrypoint.d/40-prepare-laravel.sh b/php/common/etc/entrypoint.d/40-prepare-laravel.sh index 517f9c5..1b4fc4d 100755 --- a/php/common/etc/entrypoint.d/40-prepare-laravel.sh +++ b/php/common/etc/entrypoint.d/40-prepare-laravel.sh @@ -1,7 +1,7 @@ #!/bin/sh if [ -f "$APP_BASE_DIR/artisan" ]; then - echo "Preparing Laravel application..." + echo "🤖 Preparing Laravel application..." cd $APP_BASE_DIR diff --git a/php/common/etc/entrypoint.d/45-warm-opcache.sh b/php/common/etc/entrypoint.d/45-warm-opcache.sh index cef9ae8..f5920c7 100755 --- a/php/common/etc/entrypoint.d/45-warm-opcache.sh +++ b/php/common/etc/entrypoint.d/45-warm-opcache.sh @@ -2,8 +2,8 @@ # Populate the opcache file cache once per container. if [ -f "$APP_BASE_DIR/artisan" ] && [ -w /var/cache/opcache ]; then - echo "Warming opcache file cache..." + echo "🤖 Warming opcache file cache..." cd "$APP_BASE_DIR" || exit 0 php -d opcache.jit=off artisan --version >/dev/null 2>&1 || \ - echo "opcache warm-up failed, continuing without it" >&2 + echo "⚠️ WARNING: opcache warm-up failed, continuing without it" >&2 fi diff --git a/php/common/etc/entrypoint.d/50-metrics.sh b/php/common/etc/entrypoint.d/50-metrics.sh new file mode 100755 index 0000000..bfa0019 --- /dev/null +++ b/php/common/etc/entrypoint.d/50-metrics.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Expose the private metrics listener on port 8081, but only for Laravel apps: +# the endpoint boots the framework to read the queue depth. + +[ -f "${APP_BASE_DIR:-/app}/artisan" ] || exit 0 + +echo "📊 Metrics enabled on port 8081" + +if command -v nginx >/dev/null 2>&1; then + cp /usr/local/share/metrics/nginx.conf /etc/nginx/conf.d/metrics.conf +elif [ -f /etc/frankenphp/Caddyfile ]; then + cp /usr/local/share/metrics/metrics.caddyfile /etc/frankenphp/caddyfile.d/metrics.caddyfile +fi diff --git a/php/common/usr/local/share/metrics/metrics.caddyfile b/php/common/usr/local/share/metrics/metrics.caddyfile new file mode 100644 index 0000000..906ff42 --- /dev/null +++ b/php/common/usr/local/share/metrics/metrics.caddyfile @@ -0,0 +1,9 @@ +# Private metrics listener, installed by /etc/entrypoint.d/50-metrics.sh. +:8081 { + root * /usr/local/share/metrics + + @metrics path /_metrics/queue-size + rewrite @metrics /queue-size.php + + php_server +} diff --git a/php/common/usr/local/share/metrics/nginx.conf b/php/common/usr/local/share/metrics/nginx.conf new file mode 100644 index 0000000..702f5db --- /dev/null +++ b/php/common/usr/local/share/metrics/nginx.conf @@ -0,0 +1,19 @@ +# Private metrics listener, installed by /etc/entrypoint.d/50-metrics.sh. +server { + listen 8081; + + server_name _; + access_log off; + + location = /_metrics/queue-size { + include fastcgi_params; + fastcgi_param SCRIPT_NAME /_metrics/queue-size; + fastcgi_param SCRIPT_FILENAME /usr/local/share/metrics/queue-size.php; + fastcgi_read_timeout 5s; + fastcgi_pass 127.0.0.1:9000; + } + + location / { + return 404; + } +} diff --git a/php/common/usr/local/share/metrics/queue-size.php b/php/common/usr/local/share/metrics/queue-size.php new file mode 100644 index 0000000..242a31c --- /dev/null +++ b/php/common/usr/local/share/metrics/queue-size.php @@ -0,0 +1,82 @@ + {"name":"high,default","size":42} + * + * Queues and connection are auto-discovered from the app when not given, so + * apps need no configuration. Failures answer 500 on purpose: a scaler that + * reads 0 from a broken app scales the workers away and jobs stop running. + */ + +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + +ini_set('default_socket_timeout', '3'); + +header('Content-Type: application/json'); + +if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/_metrics/queue-size') { + http_response_code(404); + echo json_encode(['error' => 'not found']); + + return; +} + +try { + $basePath = getenv('APP_BASE_DIR') ?: '/app'; + + require $basePath.'/vendor/autoload.php'; + + $request = Request::capture(); + + $app = require $basePath.'/bootstrap/app.php'; + $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + // A "queue" is a name, a comma separated list of names, or an array of either. + $split = fn ($value) => collect(Arr::wrap($value)) + ->flatMap(fn ($item) => explode(',', (string) $item)) + ->map(fn (string $queue) => trim($queue)) + ->filter() + ->values(); + + $connection = $request->query('connection'); + // "queue" is accepted too: it is what artisan and the chart call it. + $queues = $split($request->query('queues', $request->query('queue', []))); + + if ($queues->isEmpty()) { + // Detect queues from Horizon configuration + $supervisors = collect(config('horizon.environments.'.$app->environment()) ?: []) + ->map(fn (array $options, string $name) => array_merge(config("horizon.defaults.{$name}", []), $options)) + ->whenEmpty(fn () => collect(config('horizon.defaults') ?: [])); + + foreach ($supervisors as $supervisor) { + $queues = $queues->merge($split($supervisor['queue'] ?? [])); + $connection ??= $supervisor['connection'] ?? null; + } + } + + $connection ??= config('queue.default'); + + $queues = $queues->unique()->values() + ->whenEmpty(fn () => $split(config("queue.connections.{$connection}.queue", 'default'))); + + $queue = $app->make('queue')->connection($connection); + + // Queue::size() is driver agnostic: on redis it counts pending + delayed + + // reserved, on database every row in "jobs". No per-driver branching, and + // delayed jobs are included so a scaled-to-zero worker still wakes up. + echo json_encode([ + 'name' => $queues->implode(','), + 'size' => $queues->sum(fn (string $name) => $queue->size($name)), + ]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +}