Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

Every Way a Tenant Escaped: A Cross-Tenant Breakout Field Guide

Eight ways one site broke out of its sandbox on a multi-tenant PHP server, all found by attacking it on purpose, all fixed.

10-Minute Read

Table of tenant isolation boundaries in ePHPm, how each was crossed, and the PR that fixed it

ePHPm can serve a directory full of sites out of one process. Point sites_dir at a folder and every subdirectory is a virtual host. The Host header decides which one you get. That’s how the PR preview environment runs. Every pull request gets a subdomain, and the code behind it is whatever somebody pushed. One process holding many tenants means ePHPm enforces every per-tenant boundary itself, in-process. There’s no container wall between site A and site B unless you add one.

Making ephpm php Byte-for-Byte Indistinguishable From php-cli

A differential test harness against upstream php, and the bugs it found in an embedded PHP CLI on day one.

9-Minute Read

Bar chart of conformance corpus results: 65 pass and 10 xfail when the harness landed, 79 pass and 1 xfail after the fixes

ePHPm embeds PHP into a single Rust binary. The obvious follow-up question from anybody deploying it is “do I still need a php on the box?” Composer, WP-CLI, artisan, PHPUnit and every build script in the world call php. If ephpm php can stand in for it then the answer is no. The single binary really means a single binary. “Mostly works” isn’t good enough for that though. These tools parse PHP’s output, check its exit codes and re-invoke it. So the goal was…

P99 From 280 ms to 58 ms by Making the Queue Fair

ePHPm had the best throughput in a Laravel runtime comparison and the worst tail of the fast group. The cause was a bounded channel that let new requests cut in line.

9-Minute Read

Bar chart of P50, P75, P90 and P99 latency for FIFO versus barging admission in ePHPm worker mode

A third-party Laravel runtime comparison put ePHPm’s worker mode at the top on throughput. It was about 19% ahead of FrankenPHP on /api/static. It was also at the bottom of the fast group on tail latency, with a P99 of 157 ms against FrankenPHP’s 57 ms. Best throughput plus the fattest tail is a shape worth chasing, because fast execution can’t produce it. Making requests faster lowers the mean and the tail. Only waiting can pull them apart. Specifically, unfair waiting.

We Disabled PHP's Stack Guard Ourselves: Containing Segfaults in a Single-Process PHP Server

How ePHPm went from dying silently on a segfault to containing stack overflows per thread. It also covers the linker flag that had turned off PHP's own guard.

12-Minute Read

Diagram of a PHP thread stack, the guard window below it, and which faults get contained versus which kill the process

php-fpm has a property nobody thinks about until they leave it. When a worker segfaults, you lose one request. The master forks a new child and life goes on. ePHPm runs every tenant’s PHP as threads inside one process. That’s where the memory and latency wins come from. It also means one tenant’s segfault takes down every other tenant on the box.

Stop Running PHP on tokio's spawn_blocking - Own Your Thread Pool

Why ePHPm moved PHP execution off tokio's generic blocking pool onto threads it owns. How the switch rolled out behind a knob and what it did to memory, throughput and overload.

10-Minute Read

Bar chart of server RSS at 32, 128 and 512 concurrent clients: spawn_blocking grows with concurrency, the owned pool stays flat

For most of its life, ePHPm ran every PHP request on tokio’s spawn_blocking pool. It works. It passes tests and it serves benchmarks fine. It’s the first thing anybody reaches for when they need to call blocking code from async Rust. It’s also quietly wrong for a language runtime. The problems only show up under overload or after a crash. Over the last three weeks it got replaced with a pool of OS threads that ePHPm owns. That pool shipped first behind a config flag, then as…

Middleware in PHP: Compiling It With elephc, Then Not Needing To

Fitting PHP into ePHPm's native middleware ABI with an ahead-of-time compiler, and what that experiment said about where PHP middleware should actually run.

9-Minute Read

Timeline of an ePHPm request showing the native middleware lane before the body read and php: mounts inside the PHP request

ePHPm middleware runs before PHP. It’s a small versioned C ABI, loaded with dlopen, that gets to look at a request and reject it before PHP boots or the request body is read. That’s the right shape for a gateway. It’s the wrong shape for most PHP shops, who want to write their middleware in PHP. This is the story of compiling PHP into a native .so to fit that ABI and finding exactly where it broke. Then I built the lane that should have existed in the first place.

OPcache on Windows: Two Silent Failures

An ASLR fatal on Windows looked like a memory-size lottery. Separately, Windows PHP 8.3 builds never had an opcode cache, and the fix sat unpicked for three weeks.

8-Minute Read

Two ePHPm processes computing the same OPcache shared-memory name; the second reattaches and dies on the ASLR check

Windows is the platform where ePHPm gets the least traffic and the most surprises. Last week closed out two OPcache bugs that only exist there. Both were quiet in their own way. One killed the server at startup with nothing in our own logs. The other didn’t kill anything. It just ran PHP 8.3 with no opcode cache at all. For three weeks after a fixed SDK was published, every check stayed green.

What Does One More WordPress Site Cost?

I measured what each extra WordPress tenant on one ePHPm instance costs in RAM, file descriptors, CPU and disk. The sweep ran from 10 to 1000 sites.

9-Minute Read

Line chart of ePHPm steady RSS against tenant site count: linear at about 2.2 MB per site with every site open, flat past the default LRU cap of 256

ePHPm v0.7.0 shipped per-site multi-tenancy. Point [server] sites_dir at a directory and every Host maps to its own docroot and its own Turso database file. One process serves all of them. The first question anybody asks about that is “how many sites fit on a box?” The honest way to answer it is to turn the question around. What does one more site cost? If you know the marginal RAM, file descriptors, CPU and disk for a tenant, the box size falls out of arithmetic.

What an Overloaded PHP Server Actually Returns

Closed-loop benchmarks can't show you overload. An open-loop load generator with an error taxonomy showed ePHPm returned nothing at all, and then verified the fix.

11-Minute Read

Bar chart of what 48,001 arrivals at 800 req/s got back: before the fixes almost everything was a client timeout, after them every arrival got a 200 or a fast 503

Ask most people what their PHP server does when it’s overloaded and you’ll hear “it returns 503s.” Ask them how they know and it usually comes down to a config setting they read about. I was in that camp for ePHPm. We had a [server.limits] max_connections knob, and the docs said it returned 503 at the cap. Every benchmark we had ever run reported zero errors under heavy load. Then I wrote a load generator that could produce overload. The honest answer was that an…

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes