Browse Source

feat: Adds automatic thread scaling at runtime and php_ini configuration in Caddyfile (#1266)

Adds option to scale threads at runtime

Adds php_ini configuration in Caddyfile
Alliballibaba2 3 weeks ago
parent
commit
072151dfee
10 changed files with 506 additions and 4 deletions
  1. 1 1
      .github/actions/watcher/action.yaml
  2. 65 0
      caddy/admin.go
  3. 215 0
      caddy/admin_test.go
  4. 70 1
      caddy/caddy.go
  5. 68 0
      caddy/caddy_test.go
  6. 2 2
      caddy/watcher_test.go
  7. 46 0
      debugstate.go
  8. 19 0
      docs/config.md
  9. 10 0
      docs/performance.md
  10. 10 0
      docs/worker.md

+ 1 - 1
.github/actions/watcher/action.yaml

@@ -19,7 +19,7 @@ runs:
       name: Compile e-dant/watcher
       run: |
         mkdir watcher
-        gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1
+        gh release download 0.13.2 --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1
         cd watcher
         cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
         cmake --build build

+ 65 - 0
caddy/admin.go

@@ -0,0 +1,65 @@
+package caddy
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/caddyserver/caddy/v2"
+	"github.com/dunglas/frankenphp"
+	"net/http"
+)
+
+type FrankenPHPAdmin struct{}
+
+// if the id starts with "admin.api" the module will register AdminRoutes via module.Routes()
+func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "admin.api.frankenphp",
+		New: func() caddy.Module { return new(FrankenPHPAdmin) },
+	}
+}
+
+// EXPERIMENTAL: These routes are not yet stable and may change in the future.
+func (admin FrankenPHPAdmin) Routes() []caddy.AdminRoute {
+	return []caddy.AdminRoute{
+		{
+			Pattern: "/frankenphp/workers/restart",
+			Handler: caddy.AdminHandlerFunc(admin.restartWorkers),
+		},
+		{
+			Pattern: "/frankenphp/threads",
+			Handler: caddy.AdminHandlerFunc(admin.threads),
+		},
+	}
+}
+
+func (admin *FrankenPHPAdmin) restartWorkers(w http.ResponseWriter, r *http.Request) error {
+	if r.Method != http.MethodPost {
+		return admin.error(http.StatusMethodNotAllowed, fmt.Errorf("method not allowed"))
+	}
+
+	frankenphp.RestartWorkers()
+	caddy.Log().Info("workers restarted from admin api")
+	admin.success(w, "workers restarted successfully\n")
+
+	return nil
+}
+
+func (admin *FrankenPHPAdmin) threads(w http.ResponseWriter, r *http.Request) error {
+	debugState := frankenphp.DebugState()
+	prettyJson, err := json.MarshalIndent(debugState, "", "    ")
+	if err != nil {
+		return admin.error(http.StatusInternalServerError, err)
+	}
+
+	return admin.success(w, string(prettyJson))
+}
+
+func (admin *FrankenPHPAdmin) success(w http.ResponseWriter, message string) error {
+	w.WriteHeader(http.StatusOK)
+	_, err := w.Write([]byte(message))
+	return err
+}
+
+func (admin *FrankenPHPAdmin) error(statusCode int, err error) error {
+	return caddy.APIError{HTTPStatus: statusCode, Err: err}
+}

+ 215 - 0
caddy/admin_test.go

@@ -0,0 +1,215 @@
+package caddy_test
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"sync"
+	"testing"
+
+	"github.com/caddyserver/caddy/v2/caddytest"
+	"github.com/dunglas/frankenphp"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRestartWorkerViaAdminApi(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				worker ../testdata/worker-with-counter.php 1
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				rewrite worker-with-counter.php
+				php
+			}
+		}
+		`, "caddyfile")
+
+	tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
+	tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:2")
+
+	assertAdminResponse(t, tester, "POST", "workers/restart", http.StatusOK, "workers restarted successfully\n")
+
+	tester.AssertGetResponse("http://localhost:"+testPort+"/", http.StatusOK, "requests:1")
+}
+
+func TestShowTheCorrectThreadDebugStatus(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				num_threads 3
+				max_threads 6
+				worker ../testdata/worker-with-counter.php 1
+				worker ../testdata/index.php 1
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				rewrite worker-with-counter.php
+				php
+			}
+		}
+		`, "caddyfile")
+
+	debugState := getDebugState(t, tester)
+
+	// assert that the correct threads are present in the thread info
+	assert.Equal(t, debugState.ThreadDebugStates[0].State, "ready")
+	assert.Contains(t, debugState.ThreadDebugStates[1].Name, "worker-with-counter.php")
+	assert.Contains(t, debugState.ThreadDebugStates[2].Name, "index.php")
+	assert.Equal(t, debugState.ReservedThreadCount, 3)
+	assert.Len(t, debugState.ThreadDebugStates, 3)
+}
+
+func TestAutoScaleWorkerThreads(t *testing.T) {
+	wg := sync.WaitGroup{}
+	maxTries := 10
+	requestsPerTry := 200
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				max_threads 10
+				num_threads 2
+				worker ../testdata/sleep.php 1
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				rewrite sleep.php
+				php
+			}
+		}
+		`, "caddyfile")
+
+	// spam an endpoint that simulates IO
+	endpoint := "http://localhost:" + testPort + "/?sleep=2&work=1000"
+	amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates)
+
+	// try to spawn the additional threads by spamming the server
+	for tries := 0; tries < maxTries; tries++ {
+		wg.Add(requestsPerTry)
+		for i := 0; i < requestsPerTry; i++ {
+			go func() {
+				tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
+				wg.Done()
+			}()
+		}
+		wg.Wait()
+
+		amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates)
+		if amountOfThreads > 2 {
+			break
+		}
+	}
+
+	// assert that there are now more threads than before
+	assert.NotEqual(t, amountOfThreads, 2)
+}
+
+// Note this test requires at least 2x40MB available memory for the process
+func TestAutoScaleRegularThreadsOnAutomaticThreadLimit(t *testing.T) {
+	wg := sync.WaitGroup{}
+	maxTries := 10
+	requestsPerTry := 200
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				max_threads auto
+				num_threads 1
+				php_ini memory_limit 40M # a reasonable limit for the test
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				php
+			}
+		}
+		`, "caddyfile")
+
+	// spam an endpoint that simulates IO
+	endpoint := "http://localhost:" + testPort + "/sleep.php?sleep=2&work=1000"
+	amountOfThreads := len(getDebugState(t, tester).ThreadDebugStates)
+
+	// try to spawn the additional threads by spamming the server
+	for tries := 0; tries < maxTries; tries++ {
+		wg.Add(requestsPerTry)
+		for i := 0; i < requestsPerTry; i++ {
+			go func() {
+				tester.AssertGetResponse(endpoint, http.StatusOK, "slept for 2 ms and worked for 1000 iterations")
+				wg.Done()
+			}()
+		}
+		wg.Wait()
+
+		amountOfThreads = len(getDebugState(t, tester).ThreadDebugStates)
+		if amountOfThreads > 1 {
+			break
+		}
+	}
+
+	// assert that there are now more threads present
+	assert.NotEqual(t, amountOfThreads, 1)
+}
+
+func assertAdminResponse(t *testing.T, tester *caddytest.Tester, method string, path string, expectedStatus int, expectedBody string) {
+	adminUrl := "http://localhost:2999/frankenphp/"
+	r, err := http.NewRequest(method, adminUrl+path, nil)
+	assert.NoError(t, err)
+	if expectedBody == "" {
+		_ = tester.AssertResponseCode(r, expectedStatus)
+		return
+	}
+	_, _ = tester.AssertResponse(r, expectedStatus, expectedBody)
+}
+
+func getAdminResponseBody(t *testing.T, tester *caddytest.Tester, method string, path string) string {
+	adminUrl := "http://localhost:2999/frankenphp/"
+	r, err := http.NewRequest(method, adminUrl+path, nil)
+	assert.NoError(t, err)
+	resp := tester.AssertResponseCode(r, http.StatusOK)
+	defer resp.Body.Close()
+	bytes, err := io.ReadAll(resp.Body)
+	assert.NoError(t, err)
+
+	return string(bytes)
+}
+
+func getDebugState(t *testing.T, tester *caddytest.Tester) frankenphp.FrankenPHPDebugState {
+	threadStates := getAdminResponseBody(t, tester, "GET", "threads")
+
+	var debugStates frankenphp.FrankenPHPDebugState
+	err := json.Unmarshal([]byte(threadStates), &debugStates)
+	assert.NoError(t, err)
+
+	return debugStates
+}

+ 70 - 1
caddy/caddy.go

@@ -27,9 +27,12 @@ import (
 
 const defaultDocumentRoot = "public"
 
+var iniError = errors.New("'php_ini' must be in the format: php_ini \"<key>\" \"<value>\"")
+
 func init() {
 	caddy.RegisterModule(FrankenPHPApp{})
 	caddy.RegisterModule(FrankenPHPModule{})
+	caddy.RegisterModule(FrankenPHPAdmin{})
 
 	httpcaddyfile.RegisterGlobalOption("frankenphp", parseGlobalOption)
 
@@ -54,8 +57,12 @@ type workerConfig struct {
 type FrankenPHPApp struct {
 	// NumThreads sets the number of PHP threads to start. Default: 2x the number of available CPUs.
 	NumThreads int `json:"num_threads,omitempty"`
+	// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
+	MaxThreads int `json:"max_threads,omitempty"`
 	// Workers configures the worker scripts to start.
 	Workers []workerConfig `json:"workers,omitempty"`
+	// Overwrites the default php ini configuration
+	PhpIni map[string]string `json:"php_ini,omitempty"`
 
 	metrics frankenphp.Metrics
 	logger  *zap.Logger
@@ -80,7 +87,13 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
 func (f *FrankenPHPApp) Start() error {
 	repl := caddy.NewReplacer()
 
-	opts := []frankenphp.Option{frankenphp.WithNumThreads(f.NumThreads), frankenphp.WithLogger(f.logger), frankenphp.WithMetrics(f.metrics)}
+	opts := []frankenphp.Option{
+		frankenphp.WithNumThreads(f.NumThreads),
+		frankenphp.WithMaxThreads(f.MaxThreads),
+		frankenphp.WithLogger(f.logger),
+		frankenphp.WithMetrics(f.metrics),
+		frankenphp.WithPhpIni(f.PhpIni),
+	}
 	for _, w := range f.Workers {
 		opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch))
 	}
@@ -126,6 +139,58 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 				}
 
 				f.NumThreads = v
+			case "max_threads":
+				if !d.NextArg() {
+					return d.ArgErr()
+				}
+
+				if d.Val() == "auto" {
+					f.MaxThreads = -1
+					continue
+				}
+
+				v, err := strconv.ParseUint(d.Val(), 10, 32)
+				if err != nil {
+					return err
+				}
+
+				f.MaxThreads = int(v)
+			case "php_ini":
+				parseIniLine := func(d *caddyfile.Dispenser) error {
+					key := d.Val()
+					if !d.NextArg() {
+						return iniError
+					}
+					if f.PhpIni == nil {
+						f.PhpIni = make(map[string]string)
+					}
+					f.PhpIni[key] = d.Val()
+					if d.NextArg() {
+						return iniError
+					}
+
+					return nil
+				}
+
+				isBlock := false
+				for d.NextBlock(1) {
+					isBlock = true
+					err := parseIniLine(d)
+					if err != nil {
+						return err
+					}
+				}
+
+				if !isBlock {
+					if !d.NextArg() {
+						return iniError
+					}
+					err := parseIniLine(d)
+					if err != nil {
+						return err
+					}
+				}
+
 			case "worker":
 				wc := workerConfig{}
 				if d.NextArg() {
@@ -192,6 +257,10 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
 		}
 	}
 
+	if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
+		return errors.New("'max_threads' must be greater than or equal to 'num_threads'")
+	}
+
 	return nil
 }
 

+ 68 - 0
caddy/caddy_test.go

@@ -648,3 +648,71 @@ func TestAllDefinedServerVars(t *testing.T) {
 		expectedBody,
 	)
 }
+
+func TestPHPIniConfiguration(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				num_threads 2
+				worker ../testdata/ini.php 1
+				php_ini max_execution_time 100
+				php_ini memory_limit 10000000
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				php
+			}
+		}
+		`, "caddyfile")
+
+	testSingleIniConfiguration(tester, "max_execution_time", "100")
+	testSingleIniConfiguration(tester, "memory_limit", "10000000")
+}
+
+func TestPHPIniBlockConfiguration(t *testing.T) {
+	tester := caddytest.NewTester(t)
+	tester.InitServer(`
+		{
+			skip_install_trust
+			admin localhost:2999
+			http_port `+testPort+`
+
+			frankenphp {
+				num_threads 1
+				php_ini {
+					max_execution_time 15
+					memory_limit 20000000
+				}
+			}
+		}
+
+		localhost:`+testPort+` {
+			route {
+				root ../testdata
+				php
+			}
+		}
+		`, "caddyfile")
+
+	testSingleIniConfiguration(tester, "max_execution_time", "15")
+	testSingleIniConfiguration(tester, "memory_limit", "20000000")
+}
+
+func testSingleIniConfiguration(tester *caddytest.Tester, key string, value string) {
+	// test twice to ensure the ini setting is not lost
+	for i := 0; i < 2; i++ {
+		tester.AssertGetResponse(
+			"http://localhost:"+testPort+"/ini.php?key="+key,
+			http.StatusOK,
+			key+":"+value,
+		)
+	}
+}

+ 2 - 2
caddy/watcher_test.go

@@ -19,7 +19,7 @@ func TestWorkerWithInactiveWatcher(t *testing.T) {
 
 			frankenphp {
 				worker {
-					file ../testdata/worker-with-watcher.php
+					file ../testdata/worker-with-counter.php
 					num 1
 					watch ./**/*.php
 				}
@@ -28,7 +28,7 @@ func TestWorkerWithInactiveWatcher(t *testing.T) {
 
 		localhost:`+testPort+` {
 			root ../testdata
-			rewrite worker-with-watcher.php
+			rewrite worker-with-counter.php
 			php
 		}
 		`, "caddyfile")

+ 46 - 0
debugstate.go

@@ -0,0 +1,46 @@
+package frankenphp
+
+// EXPERIMENTAL: ThreadDebugState prints the state of a single PHP thread - debugging purposes only
+type ThreadDebugState struct {
+	Index                    int
+	Name                     string
+	State                    string
+	IsWaiting                bool
+	IsBusy                   bool
+	WaitingSinceMilliseconds int64
+}
+
+// EXPERIMENTAL: FrankenPHPDebugState prints the state of all PHP threads - debugging purposes only
+type FrankenPHPDebugState struct {
+	ThreadDebugStates   []ThreadDebugState
+	ReservedThreadCount int
+}
+
+// EXPERIMENTAL: DebugState prints the state of all PHP threads - debugging purposes only
+func DebugState() FrankenPHPDebugState {
+	fullState := FrankenPHPDebugState{
+		ThreadDebugStates:   make([]ThreadDebugState, 0, len(phpThreads)),
+		ReservedThreadCount: 0,
+	}
+	for _, thread := range phpThreads {
+		if thread.state.is(stateReserved) {
+			fullState.ReservedThreadCount++
+			continue
+		}
+		fullState.ThreadDebugStates = append(fullState.ThreadDebugStates, threadDebugState(thread))
+	}
+
+	return fullState
+}
+
+// threadDebugState creates a small jsonable status message for debugging purposes
+func threadDebugState(thread *phpThread) ThreadDebugState {
+	return ThreadDebugState{
+		Index:                    thread.threadIndex,
+		Name:                     thread.handler.name(),
+		State:                    thread.state.name(),
+		IsWaiting:                thread.state.isInWaitingState(),
+		IsBusy:                   !thread.state.isInWaitingState(),
+		WaitingSinceMilliseconds: thread.state.waitTime(),
+	}
+}

+ 19 - 0
docs/config.md

@@ -51,6 +51,8 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s
 {
 	frankenphp {
 		num_threads <num_threads> # Sets the number of PHP threads to start. Default: 2x the number of available CPUs.
+		max_threads <num_threads> # Limits the number of additional PHP threads that can be started at runtime. Default: num_threads. Can be set to 'auto'.
+		php_ini <key> <value> # Set a php.ini directive. Can be used several times to set multiple directives.
 		worker {
 			file <path> # Sets the path to the worker script.
 			num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs.
@@ -227,6 +229,23 @@ To load [additional PHP configuration files](https://www.php.net/manual/en/confi
 the `PHP_INI_SCAN_DIR` environment variable can be used.
 When set, PHP will load all the file with the `.ini` extension present in the given directories.
 
+You can also change the PHP configuration using the `php_ini` directive in the `Caddyfile`:
+
+```caddyfile
+{
+    frankenphp {
+        php_ini memory_limit 256M
+
+        # or
+
+        php_ini {
+            memory_limit 256M
+            max_execution_time 15
+        }
+    }
+}
+```
+
 ## Enable the Debug Mode
 
 When using the Docker image, set the `CADDY_GLOBAL_OPTIONS` environment variable to `debug` to enable the debug mode:

+ 10 - 0
docs/performance.md

@@ -16,6 +16,16 @@ To find the right values, it's best to run load tests simulating real traffic.
 To configure the number of threads, use the `num_threads` option of the `php_server` and `php` directives.
 To change the number of workers, use the `num` option of the `worker` section of the `frankenphp` directive.
 
+### `max_threads`
+
+While it's always better to know exactly what your traffic will look like, real-life applications tend to be more
+unpredictable. The `max_threads` allows FrankenPHP to automatically spawn additional threads at runtime up to the specified limit.
+`max_threads` can help you
+figure out how many threads you need to handle your traffic and can make the server more resilient to latency spikes.
+If set to `auto`, the limit will be estimated based on the `memory_limit` in your `php.ini`. If not able to do so,
+`auto` will instead default to 2x `num_threads`.
+`max_threads is similar to PHP FPM's [pm.max_children](https://www.php.net/manual/en/install.fpm.configuration.php#pm.max-children).
+
 ## Worker Mode
 
 Enabling [the worker mode](worker.md) dramatically improves performance,

+ 10 - 0
docs/worker.md

@@ -128,6 +128,16 @@ A workaround to using this type of code in worker mode is to restart the worker
 
 The previous worker snippet allows configuring a maximum number of request to handle by setting an environment variable named `MAX_REQUESTS`.
 
+### Restart Workers Manually
+
+While it's possible to restart workers [on file changes](config.md#watching-for-file-changes), it's also possible to restart all workers
+gracefully via the [Caddy admin API](https://caddyserver.com/docs/api). If the admin is enabled in your
+[Caddyfile](config.md#caddyfile-config), you can ping the restart endpoint with a simple POST request like this:
+
+```console
+curl -X POST http://localhost:2019/frankenphp/workers/restart
+```
+
 ### Worker Failures
 
 If a worker script crashes with a non-zero exit code, FrankenPHP will restart it with an exponential backoff strategy.

Some files were not shown because too many files changed in this diff