Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ jobs:
# oneshot, and cbox-init exits non-zero when a oneshot fails.
docker run --rm --name cbox-init-pid1 cbox-init-pid1

- name: Runtime PHP-FPM autotuner e2e
if: matrix.distro == 'debian'
# cbox-init as PID 1 supervising real php-fpm, with the embedded fpm-tune
# loop: discover, enable status, size, resize under load, reload via
# SIGUSR2 (not a restart). The script's exit code is the result.
run: bash tests/integration/e2e-fpm-tune.sh

- name: Run integration tests - ${{ matrix.distro }}
run: |
docker build \
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build build-all clean test test-all test-integration bench coverage lint deps install dev help \
.PHONY: build build-all clean test test-all test-integration test-e2e-fpm-tune bench coverage lint deps install dev help \
check check-configs cover-check fmt fmt-check vet vulncheck sbom sbom-check license-check

# Build variables
Expand Down Expand Up @@ -77,6 +77,10 @@ test-integration:
done
@echo "✅ All integration tests passed"

test-e2e-fpm-tune: build-all
@echo "🧪 Running the runtime PHP-FPM autotuner e2e (php-fpm container)..."
@bash tests/integration/e2e-fpm-tune.sh

# Run benchmarks
bench:
@echo "⚡ Running benchmarks..."
Expand Down
4 changes: 2 additions & 2 deletions cmd/cbox-init/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7361,7 +7361,7 @@ func TestPerformGracefulShutdownDirect(t *testing.T) {
}
done <- true
}()
performGracefulShutdown(cfg, pm, nil, nil, auditLog, "test")
performGracefulShutdown(cfg, pm, nil, nil, nil, auditLog, "test")
}()

select {
Expand Down Expand Up @@ -8107,7 +8107,7 @@ func TestPerformGracefulShutdownWithServers(t *testing.T) {
}
done <- true
}()
performGracefulShutdown(cfg, pm, nil, nil, auditLog, "test_signal")
performGracefulShutdown(cfg, pm, nil, nil, nil, auditLog, "test_signal")
}()

select {
Expand Down
109 changes: 109 additions & 0 deletions cmd/cbox-init/fpmtune.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"context"
"fmt"
"log/slog"

"github.com/cboxdk/fpm-tune/plan"
"github.com/cboxdk/fpm-tune/serve"
"github.com/cboxdk/fpm-tune/state"

"github.com/cboxdk/init/internal/config"
)

// The p95 hybrid is the intended default sizing basis: size on the 95th
// percentile of the worker memory distribution plus a small margin, floored so it
// still reacts to a real jump in one scrape. It mirrors fpm-tune's own default
// (`serve -sizing p95`); the zero value would instead be the pure peak-follower,
// which sizes forever on the worst worker ever seen and consistently over-provisions.
const (
fpmSizingPercentile = 0.95
fpmSizingMargin = 0.10
)

// startFPMTune starts the embedded runtime PHP-FPM autotuner as a background
// loop when it is enabled. It returns a stop function that halts the loop and
// waits for it to finish — releasing its state-file lock and saving learned
// baselines. Call the stop function BEFORE php-fpm is torn down, because the loop
// rewrites php-fpm's pool configuration and reloads it; a nil stop function means
// the autotuner was disabled and nothing was started.
//
// The loop embeds github.com/cboxdk/fpm-tune. It does its own discovery of the
// running master (scan-and-retry), so starting it here, right after the processes
// are up, is fine even if php-fpm is still coming up. Its config is loaded per
// round, so a pool's boot-time pm.max_children (set by the calculator before
// php-fpm started) is the seed it refines, not something it fights.
func startFPMTune(ctx context.Context, cfg *config.Config, log *slog.Logger) (func(), error) {
ft := cfg.Global.FPMTune
if ft == nil || !ft.Enabled {
return nil, nil
}

sc := serve.Config{
// apply is the default and the point of embedding it; advisory is the only
// opt-out. The empty paths (state, backup, drop-in) are resolved by
// fpm-tune's own Config.Defaults() exactly as the standalone tool resolves them.
Apply: ft.Mode != "advisory",
Interval: ft.Interval,
DropInDir: ft.DropInDir,
StatePath: ft.StatePath,
BackupDir: ft.BackupDir,
MetricsAddr: ft.MetricsAddr,
RecommendPath: ft.RecommendPath,
ReserveFraction: ft.ReserveFraction,
Workload: resolveFPMWorkload(ft.Workload, log),
Version: version, // reported on the loop's /history.json

StateOptions: state.Options{
Sizing: state.Sizing{Percentile: fpmSizingPercentile, Margin: fpmSizingMargin},
},
}

loop, err := serve.New(sc, log)
if err != nil {
return nil, fmt.Errorf("fpm-tune: %w", err)
}

// The loop gets its own context so shutdown can stop it independently: the
// serve context is cancelled only after graceful shutdown has already run, and
// a tuner that keeps rewriting and reloading php-fpm while it is being drained
// is the one thing we must not allow.
loopCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
if err := loop.Run(loopCtx); err != nil {
log.Error("Runtime PHP-FPM autotuner exited with error", "error", err)
}
}()

log.Info("Runtime PHP-FPM autotuner started",
"mode", ft.Mode,
"apply", sc.Apply,
"interval", ft.Interval,
"metrics", ft.MetricsAddr,
)

stop := func() {
cancel()
<-done // Run's deferred Close() releases the state lock and saves baselines.
}

return stop, nil
}

// resolveFPMWorkload maps the configured workload name to a class, warning on an
// unknown name rather than failing — the same forgiving behaviour as the fpm-tune
// CLI, so a typo degrades to the safe web default instead of stopping the daemon.
func resolveFPMWorkload(name string, log *slog.Logger) plan.Workload {
if name == "" {
return plan.WorkloadWeb
}
w, ok := plan.WorkloadByName(name, plan.WorkloadWeb)
if !ok {
log.Warn("Unknown fpm_tune.workload; using the web default", "name", name)
}

return w
}
80 changes: 80 additions & 0 deletions cmd/cbox-init/fpmtune_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package main

import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"
"time"

"github.com/cboxdk/init/internal/config"
)

func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

// TestStartFPMTuneDisabled: a missing or disabled block starts nothing and
// returns a nil stop, so serve can call it unconditionally.
func TestStartFPMTuneDisabled(t *testing.T) {
for _, ft := range []*config.FPMTuneConfig{nil, {Enabled: false}} {
cfg := &config.Config{Global: config.GlobalConfig{FPMTune: ft}}
stop, err := startFPMTune(context.Background(), cfg, discardLogger())
if err != nil {
t.Fatalf("disabled autotuner returned an error: %v", err)
}
if stop != nil {
t.Error("disabled autotuner returned a non-nil stop function")
}
}
}

// TestStartFPMTuneStartsAndStops drives the real embedded loop (advisory, so it
// never writes php-fpm config) against temp paths, then stops it. The stop must
// return promptly — it releases the state lock and saves baselines. Guarded by a
// timeout so a hang fails the test instead of blocking the suite.
func TestStartFPMTuneStartsAndStops(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
Global: config.GlobalConfig{
FPMTune: &config.FPMTuneConfig{
Enabled: true,
Mode: "advisory", // never writes php-fpm config in a test
Interval: time.Second,
StatePath: filepath.Join(dir, "state.json"),
BackupDir: filepath.Join(dir, "backup"),
// MetricsAddr empty: bind no port.
},
},
}

stop, err := startFPMTune(context.Background(), cfg, discardLogger())
if err != nil {
t.Fatalf("startFPMTune: %v", err)
}
if stop == nil {
t.Fatal("enabled autotuner returned a nil stop function")
}

done := make(chan struct{})
go func() {
stop()
close(done)
}()

select {
case <-done:
case <-time.After(20 * time.Second):
t.Fatal("stop() did not return; the loop is not shutting down cleanly")
}

// The state lock is released, so a second loop on the same state path starts.
stop2, err := startFPMTune(context.Background(), cfg, discardLogger())
if err != nil {
t.Fatalf("second start after stop failed (lock not released?): %v", err)
}
if stop2 != nil {
stop2()
}
}
21 changes: 19 additions & 2 deletions cmd/cbox-init/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,15 @@ func runServe(cmd *cobra.Command, args []string) {
defer func() { _ = configWatcher.Stop() }()
}

// Runtime PHP-FPM autotuner (embedded fpm-tune). Started last, after every
// startup step that can os.Exit, so its stop is never skipped. Non-critical:
// if it cannot start (for example a second copy already holds the state lock),
// php-fpm keeps its boot-time size and the container runs on.
stopFPMTune, err := startFPMTune(ctx, cfg, log)
if err != nil {
slog.Warn("Runtime PHP-FPM autotuner not started", "error", err)
}

// Main event loop - handles shutdown signals and config reloads
var shutdownReason string
for {
Expand All @@ -411,7 +420,7 @@ func runServe(cmd *cobra.Command, args []string) {
}

// Graceful shutdown for other reasons (signal, all processes dead)
performGracefulShutdown(cfg, pm, apiServer, metricsServer, auditLogger, shutdownReason)
performGracefulShutdown(cfg, pm, apiServer, metricsServer, stopFPMTune, auditLogger, shutdownReason)
break
}

Expand Down Expand Up @@ -800,7 +809,7 @@ func waitForShutdownOrReload(
}

// performGracefulShutdown gracefully shuts down all components
func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer *api.Server, metricsServer *metrics.Server, auditLogger *audit.Logger, reason string) {
func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer *api.Server, metricsServer *metrics.Server, stopFPMTune func(), auditLogger *audit.Logger, reason string) {
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(),
time.Duration(cfg.Global.ShutdownTimeout)*time.Second,
Expand All @@ -812,6 +821,14 @@ func performGracefulShutdown(cfg *config.Config, pm *process.Manager, apiServer
"timeout", cfg.Global.ShutdownTimeout,
)

// Stop the runtime autotuner FIRST, before php-fpm is drained: it rewrites
// php-fpm's config and reloads it, so it must not be acting on a master that
// is being torn down. The stop waits for the loop to finish its round, save
// baselines, and release its lock.
if stopFPMTune != nil {
stopFPMTune()
}

// Shutdown process manager
if err := pm.Shutdown(shutdownCtx); err != nil {
slog.Error("Shutdown completed with errors", "error", err)
Expand Down
43 changes: 43 additions & 0 deletions configs/examples/php-fpm-autotune.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Runtime PHP-FPM autotuning
#
# cbox-init sizes php-fpm twice, at two different moments:
#
# 1. Boot: the autotune profile computes a starting pm.max_children from the
# container's memory limit and templates it into www.conf before php-fpm
# starts. (Set PHP_FPM_AUTOTUNE_PROFILE, or the --php-fpm-profile flag.)
#
# 2. Runtime: the fpm_tune block below embeds cboxdk/fpm-tune, which measures
# live per-worker memory (PSS), and when the right size has moved it rewrites
# a pool drop-in and reloads php-fpm with SIGUSR2 (never a restart). Every
# change is validated against a throwaway copy, written atomically, and
# rolled back if the master does not come back.
#
# The boot value is the seed; the runtime loop refines it as traffic arrives.

version: "1.0"

global:
shutdown_timeout: 30
log_format: json
log_level: info

fpm_tune:
enabled: true
# apply: write drop-ins and reload php-fpm (the default).
# advisory: observe and only write a recommendation (set recommend_path).
mode: apply
interval: 30s
# Expose fpm_tune_* metrics (worker RSS/PSS, recommended vs configured
# workers, whether the machine is full). Empty disables the endpoint.
metrics_addr: ":9110"
# Leave the paths unset to let fpm-tune resolve them the way the standalone
# tool does: drop_in_dir = the directory the master includes,
# state_path = /var/lib/fpm-tune/state.json, backup_dir = its default.
# reserve_fraction: 0.2 # hold 20% of the budget back from the pools
# workload: web # default class for pools that spawn no children

processes:
php-fpm:
enabled: true
command: ["php-fpm", "-F", "-R"]
restart: always
36 changes: 36 additions & 0 deletions docs/configuration/php-fpm-autotune.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Cbox Init includes intelligent PHP-FPM worker auto-tuning based on container res
## Table of Contents

- [Overview](#overview)
- [Runtime Auto-Tuning](#runtime-auto-tuning)
- [How It Works](#how-it-works)
- [Application Profiles](#application-profiles)
- [Safety Features](#safety-features)
Expand All @@ -30,6 +31,41 @@ PHP-FPM worker configuration is critical for Laravel application performance and
- Reserves memory for Nginx, Redis clients, system overhead
- Validates calculations to prevent over-provisioning

## Runtime Auto-Tuning

Everything above is **boot-time** sizing: a one-shot calculation that runs before
php-fpm starts and templates `pm.max_children` into the pool config from the
container's memory limit and a workload profile. It is a good starting value, but
it is a guess made before a single request has been served, from an assumed
per-worker memory figure.

Cbox Init can also refine that value at **runtime**. The `global.fpm_tune` block
embeds the [fpm-tune](https://github.com/cboxdk/fpm-tune) engine as a built-in
background loop: it measures live per-worker memory (PSS, which does not
double-count shared OPcache), and when the right size has moved it rewrites a pool
drop-in and reloads php-fpm with `SIGUSR2` (a graceful reload, not a restart). Each
change is validated against a throwaway copy, written atomically, and rolled back
if the master does not come back.

The two compose. The boot calculator seeds `pm.max_children` so php-fpm starts
sane; the runtime loop owns the number from there, following real memory use and
damping reloads with hysteresis so it does not react to every small drift. Enable
it alongside a php-fpm process:

```yaml
global:
fpm_tune:
enabled: true
mode: apply # write drop-ins and reload; use "advisory" to only recommend
interval: 30s
metrics_addr: ":9110" # exposes fpm_tune_* metrics; empty disables it
```

A full example lives in `configs/examples/php-fpm-autotune.yaml`. The embedded
engine is the same tool that runs standalone, so do not point a separate `fpm-tune`
daemon at the same pools: the loop takes a lock on its state file, and a second
copy on the same state refuses to start.

## How It Works

### Detection Phase
Expand Down
Loading
Loading