Test workload: Apache and PHP-FPM as two containers in one pod. PHP 8.4, three configuration profiles, 42.7 million requests in the main runs, plus an optimisation ablation, an overload test and a cross-check on a second VM.
The short version
Yes, a Unix socket is faster than TCP loopback between two containers in the same pod. Here it is measured with Apache and PHP-FPM. Whether that matters to you depends on a few things.
Both variants run in the same Kubernetes pod (httpd:2.4-alpine + php:8.4-fpm-alpine) and differ only in how Apache talks to PHP-FPM: 127.0.0.1:9000 or /var/run/php-fpm/wordpress.sock. Load: wrk -t12 -c100 -d30s, 3 runs per cell.
This is a "hello world" test. The PHP script is
<?php echo "bench"; ?>: no database, no framework, no application logic. That isolates the connection between Apache and PHP-FPM, and that is all the percentages below describe. They are not the speed-up a real application will see. The socket's only added value is less CPU and less time spent handing each request between the two services.
| Configuration | TCP req/s | Socket req/s | Socket vs TCP | CPU per request |
|---|---|---|---|---|
| Stock image configs | 38,763 | 44,571 | +15 % | −17 % |
| Tuned configs | 75,056 | 96,158 | +28 % | −25 % |
| Tuned + persistent FastCGI connections | 107,003 | 113,006 | +6 % | −5 % |
Where the difference comes from. By default Apache opens a new connection to PHP-FPM for every request. Over TCP that means a handshake, a teardown, an ephemeral port and a TIME_WAIT entry each time: about 33 µs of CPU per request, against 12 µs for a Unix socket. Moving the data itself differs by only ~3 µs.
What it depends on:
- How much work your requests do. The socket saves a fixed ~25 µs of CPU and ~17 µs of response time per request. It does not make PHP code or SQL queries faster. For a site with 100k visits a month that is 2.4 s of CPU per month. For an SPA making 10 API calls per page view, 24.5 s. To save one whole vCPU you would need ~40,800 requests per second, sustained.
-
Whether you use persistent connections. With
enablereuse=oninmod_proxy_fcgi, TCP stops paying for the handshake on every request and the gap shrinks to 6 %. - Whether PHP-FPM must live in its own pod. A socket needs a shared filesystem, so Apache and PHP-FPM must share a pod. If FPM has to scale or deploy separately, use TCP.
- Your configuration. Tuning PHP-FPM and Apache doubled throughput for both transports. That is far more than the transport choice.
Also worth knowing:
- Memory: no measurable difference between the transports.
- The stock
php:8.4-fpm-alpineimage listens on[::]:9000, all interfaces. A socket has no network listener at all. - Under deliberate overload, TCP produced 1 s stalls and connection resets; the socket produced none.
- The socket's downsides: one shared pod, socket permissions that are easy to get wrong, and less standard probes and debugging.
Recommendation. If Apache and PHP-FPM already share a pod, use the socket: it is cheaper per request and has no network exposure. If PHP-FPM needs its own Deployment, use TCP with enablereuse and a correctly sized pool. Either way, tune the pools first, and do not expect your application to become 28 % faster: on a real page the saving is a fraction of a percent.
The rest of this article is the evidence.
Test environment
| Layer | Details |
|---|---|
| Hardware | Mac mini (Mac16,10), Apple M4, 10 cores (4 performance + 6 efficiency), 16 GB RAM, AC power |
| Host OS | macOS 27.0.1 (26A434) |
| VM | OrbStack 2.2.4: 10 vCPU / 8 GiB, kernel 7.2.9-orbstack, cgroup v2 |
| Second VM | Docker Desktop 4.95.0 (kernel 7.0.14-linuxkit), idle during the main runs, then used for the cross-check |
| Kubernetes | kind v0.29.0, one node, Kubernetes v1.33.1, containerd 2.1.1, kube-proxy (iptables) |
| Software | Apache 2.4.69 (httpd:2.4-alpine, sha256:3440c39d8d6f…), PHP 8.4.26 with OPcache (php:8.4-fpm-alpine, sha256:78cd8de9970a…), wrk 4.2.0 (elswork/wrk, sha256:529f8fe35924…) |
| Kernel net (pod) |
ip_local_port_range 32768–60999, tcp_tw_reuse = 2, tcp_fin_timeout = 60
|
Method
-
Load:
wrk -t12 -c100 -d30s --latencyfrom a pod in the same cluster, through the ClusterIP Service. wrk divides connections per thread, so this opens 96 connections (verified on the server). - Repetitions: 3 runs per cell, each with a 5 s warm-up and a 10 s cool-down. The TCP/socket order alternated on every repetition. Tables show the median and the min–max range.
-
CPU: cgroup v2
cpu.statread before and after each run, divided by completed requests, split into httpd and PHP-FPM. -
Memory: container working set (
memory.current − inactive_file), idle and under load. -
Validity: both services must return exactly
bench. Main runs: 0 non-2xx responses and 18 socket read errors, all in the stock profile (explained below). -
Why not
kubectl top: metrics-server lags 12–27 s and averages over 15 s. A sample 15 s into a 30 s run showed 1 millicore for the pod, while cgroup counters showed 7.3 cores.
The three profiles
| baseline | optimized | optimized-reuse | |
|---|---|---|---|
| httpd | stock httpd.conf (22 modules + proxy/rewrite, dynamic mpm_event pool, access log) |
9 modules, fixed pool 4 × 64 threads, no access log | = optimized |
| vhost | WordPress-style: AllowOverride All + .htaccess
|
rewrite rules inline, AllowOverride None
|
= optimized |
| Client keep-alive | MaxKeepAliveRequests 100 |
MaxKeepAliveRequests 0 |
= optimized |
| PHP-FPM |
pm = dynamic, max 5 children, access log |
pm = static, 16 children, no access log, listen.backlog = 1024
|
= optimized |
| php.ini | stock |
opcache.validate_timestamps=0, larger realpath cache, expose_php=Off
|
= optimized |
| FastCGI connections | new per request | new per request |
persistent (enablereuse=on, 4 × max=4 = 16) |
In every profile the socket is created as www-data:www-data 0660, and Apache's workers run as www-data (uid 82, the image default).
fsGroup: 82alone does not fix "Permission denied" on the socket. With Apache running asdaemonandfsGroup: 82, every request failed with(13)Permission denied/ HTTP 503. Apache'sinitgroups()drops the supplementary group the kubelet added. Socket access comes from the worker user and the socket's owner and mode.
<Proxy "unix:/var/run/php-fpm/wordpress.sock|fcgi://localhost">
ProxySet enablereuse=on max=4 acquire=3000 timeout=30 # optimized-reuse only
</Proxy>
<FilesMatch "\.php$">
SetHandler "proxy:unix:/var/run/php-fpm/wordpress.sock|fcgi://localhost"
</FilesMatch>
[www]
listen = /var/run/php-fpm/wordpress.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = static
pm.max_children = 16
Detailed results
Throughput and CPU
| Profile | TCP req/s (min–max) | Socket req/s (min–max) | TCP µs/req (httpd + FPM) | Socket µs/req (httpd + FPM) |
|---|---|---|---|---|
| baseline | 38,763 (38,121–38,994) | 44,571 (44,501–44,880) | 117.5 (83.3 + 34.2) | 97.8 (65.7 + 31.7) |
| optimized | 75,056 (74,819–75,360) | 96,158 (95,244–97,323) | 97.2 (66.3 + 30.6) | 72.7 (46.7 + 26.4) |
| optimized-reuse | 107,003 (106,725–107,035) | 113,006 (108,335–113,801) | 63.9 (42.8 + 21.1) | 60.7 (40.4 + 20.3) |
CPU per request is the most stable metric (at most 3.8 % spread within a cell). In the optimized profiles the pod was CPU-bound (≈7.3 of 10 vCPU), so throughput ≈ cores ÷ CPU per request: 7.32 cores ÷ 97.2 µs ≈ 75 k req/s.
Latency
| Profile | TCP p50 / p99 | Socket p50 / p99 |
|---|---|---|
| baseline | 2.39 / 3.65 ms | 2.09 / 3.13 ms |
| optimized | 0.96 / 5.42 ms | 0.75 / 3.81 ms |
| optimized-reuse | 0.72 / 3.04 ms | 0.69 / 2.78 ms |
With 96 connections the backend is saturated, so latency is mostly queueing. wrk also corrects for coordinated omission, which inflates p99 after short stalls. Treat p99 as indicative.
Memory
| Profile | TCP working set | Socket working set |
|---|---|---|
| baseline | 58.9 MiB | 53.3 MiB |
| optimized | 40.9 MiB | 45.9 MiB |
| optimized-reuse | 40.3 MiB | 44.3 MiB |
- Transport: no consistent effect (±5.6 MiB, the sign flips between profiles).
- Configuration: stock Apache uses 12–19 MiB more than the minimal config; 64 FPM workers use ~52 % more than 16.
- Not in these numbers: kernel socket memory is not charged to the pod on this kernel. The ~14,100 TIME_WAIT sockets of the TCP variant take about 3.3 MiB (248 B each).
Which optimisations mattered
Each step adds one change (median of 2 × 30 s):
| Step (cumulative) | TCP req/s | Δ | Socket req/s | Δ | Socket vs TCP |
|---|---|---|---|---|---|
| baseline | 39,268 | 45,084 | +14.8 % | ||
+ FPM pm=static, 16 workers, no access log |
59,576 | +52 % | 71,884 | +59 % | +20.7 % |
| + minimal httpd (fixed pool, no access log) | 68,957 | +16 % | 84,615 | +18 % | +22.7 % |
+ no .htaccess
|
74,378 | +8 % | 95,230 | +13 % | +28.0 % |
| + php.ini | 76,556 | +3 % | 96,338 | +1 % | +25.8 % |
+ enablereuse
|
104,689 | +37 % | 113,727 | +18 % | +8.6 % |
The FPM pool was the first bottleneck and Apache the second. The php.ini tweaks were within noise. Connection reuse helped TCP twice as much as the socket.
PHP-FPM pool size
pm.max_children |
4 | 8 | 16 | 32 | 64 |
|---|---|---|---|---|---|
| TCP req/s | 47,128 | 63,142 | 75,273 | 74,027 | 71,563 |
| Socket req/s | 58,267 | 77,925 | 94,103 | 93,323 | 90,078 |
| Pod memory | 37.0 MiB | 40.6 MiB | 42.3 MiB | 50.6 MiB | 64.2 MiB |
16 workers was best on 10 vCPUs; more only added context switches and memory.
Why the stock Apache produced read errors
With the stock httpd.conf, wrk reported read errors on both transports. I changed one setting at a time (tuned FPM, 2 × 30 s, errors summed):
Stock httpd.conf plus |
TCP errors | Socket errors |
|---|---|---|
| nothing | 766 | 595 |
MaxKeepAliveRequests 0 |
12 | 1 |
| fixed worker pool | 0 | 8 |
| both | 0 | 0 |
The stock pool stops idle children, and a stopping child closes its keep-alive connections under in-flight requests. MaxKeepAliveRequests 100 adds constant reconnects. This is an Apache configuration issue, not a transport one.
Cross-check on a second VM
The same matrix on Docker Desktop (kernel 7.0.14-linuxkit, fresh cluster, 2 × 30 s, 24.9 M requests):
| Profile | OrbStack: socket vs TCP (req/s, CPU) | Docker Desktop: socket vs TCP (req/s, CPU) |
|---|---|---|
| baseline | +15.0 %, −16.8 % | +12.0 %, −16.1 % |
| optimized | +28.1 %, −25.2 % | +29.6 %, −23.0 % |
| optimized-reuse | +5.6 %, −5.0 % | +7.9 %, −7.6 % |
Absolute numbers were worse on Docker Desktop (~28 % more CPU per request); the socket's advantage was the same.
Where the difference comes from
Without reuse, mod_proxy_fcgi opens a new connection per request and PHP-FPM closes it after the response. Over TCP loopback each request pays for:
-
connect()with an ephemeral port search among thousands of recent TIME_WAIT entries; - the three-way handshake, data segments with ACKs and the FIN/ACK teardown, all through the IP and TCP layers;
- a TIME_WAIT socket. The TCP pod held ~14,100 of them at 38 k–75 k req/s, half of the 28,232 ephemeral ports. Ports were only reusable because
tcp_tw_reuse = 2(the default) allows it for loopback;TcpExt: TWRecycledgrew by 191,390 over a few runs.
A Unix socket connect() looks up a path and links two sockets; data goes straight into the peer's receive queue. There are no ports, headers, handshake or TIME_WAIT.
| µs per request (optimized) | TCP, new conn | Socket, new conn | TCP, reused | Socket, reused |
|---|---|---|---|---|
| httpd (client) | 66.3 | 46.7 | 42.8 | 40.4 |
| PHP-FPM (server) | 30.6 | 26.4 | 21.1 | 20.3 |
| Total | 97.2 | 72.7 | 63.9 | 60.7 |
Connection setup and teardown cost ≈ 33 µs over TCP and ≈ 12 µs over the socket; the transfer itself differs by ≈ 3 µs. So ~86 % of the socket's advantage is the per-request connection lifecycle.
In the stock profile the cause is slightly different: throughput was capped by 5 FPM workers, and each worker is occupied for the whole connection lifecycle. A shorter lifecycle lets the same 5 workers serve 15 % more requests.
Ruled out: conntrack (0 entries in the pod's network namespace) and the Service hop (pod IP vs ClusterIP within 1.5 %).
Persistent connections and other socket advantages
Unix socket connections can be persistent too: enablereuse=on works for both transports. I counted 16 persistent connections for each during the run, and the 6 runs (19.7 M requests) had 0 errors. What remains is the TCP/IP stack itself: ~3 µs and +6 %.
Beyond speed:
-
No network exposure. The stock FPM image listens on
[::]:9000, so without a NetworkPolicy any pod in the cluster can reach it. An open FastCGI port is a known remote-code-execution vector (PHP_VALUE). A socket is guarded by file permissions. - No ephemeral ports or TIME_WAIT.
-
Behaviour under overload (measured). With 4 FPM workers,
listen.backlog = 4and 96 connections, TCP dropped handshake packets: p99 = 1,010 ms (the SYN retransmit), connection resets, ~260 socket errors and ~190 non-2xx per 20 s run. The socket had 0 errors, p99 = 9 ms and 36 % more req/s. This is an artificial overload, but it shows the transports fail differently.
With persistent connections, keep httpd processes × max ≤ pm.max_children. Each pooled connection pins one FPM worker.
Disadvantages of the socket
- Same pod only. PHP-FPM cannot run as its own Deployment, scale separately or serve several Apache pods.
-
Permissions are easy to get wrong. Mistakes show up only at runtime, as 503s. The common "fix"
listen.mode = 0666lets any process that sees the file run PHP. - More moving parts. A shared volume, the same path in two configs, and a socket file that appears only after FPM starts and is left behind on a crash. FPM recreated it on its own after a hard kill.
-
Probes and tooling. No
tcpSocketprobe,ssortcpdump. You need anexecprobe such ascgi-fcgi.
TCP is the better choice when PHP-FPM must scale or deploy independently, when several web pods share one pool, or when the team prefers standard network tooling. With enablereuse it gets within ~6 %.
What this means for a real application
The saving is fixed per request (optimized profile): 24.5 µs of CPU (97.2 → 72.7 µs) and ~17 µs of response time. The response time was measured one request at a time (wrk -t1 -c1): TCP 89.5 µs vs socket 72.2 µs on average.
| Traffic (30-day month) | Requests | Avg load | CPU saved | Share of 1 vCPU | Value at $25/vCPU-month |
|---|---|---|---|---|---|
| 100k visits, 1 request each | 100,000 | 0.04 req/s | 2.4 s | 0.0001 % | < $0.001 |
| 100k SPA page views × 10 API calls | 1,000,000 | 0.4 req/s | 24.5 s | 0.001 % | < $0.001 |
| 1M page views × 10 calls | 10,000,000 | 3.9 req/s | 4.1 min | 0.009 % | ~$0.002 |
| 10M page views × 10 calls | 100,000,000 | 39 req/s | 41 min | 0.09 % | ~$0.02 |
For a visitor, a request is ~0.017 ms faster. With 10 API calls in parallel the page is ~0.02 ms faster, and with 10 sequential calls ~0.17 ms. A browser's round trip to the server is typically 10–100 ms.
The relative saving shrinks as requests do more work. This part is an estimate, not a measurement:
| PHP + DB work per request | Saved by the socket |
|---|---|
| none (this benchmark) | 25 % |
| 1 ms | ~2.2 % |
| 10 ms | ~0.24 % |
| 50 ms (typical uncached WordPress page) | ~0.05 % |
Every connection does save resources, and an SPA saves ten times as much per page. It only becomes significant at tens of thousands of small requests per second (API endpoints, health checks, cached responses served by PHP), or when many such pods share nodes and CPU is the bill. It will not rescue a slow application: if a page takes 50–200 ms, the time goes into PHP, the database and external calls. Fix those first.
Threats to validity
-
One machine, virtualised. An Apple M4 Mac mini; the VM's vCPUs were scheduled on performance and efficiency cores by macOS. Kernels:
7.2.9-orbstack, plus7.0.14-linuxkitfor the cross-check. - Load generator on the same node. wrk used ~0.4–0.6 vCPU.
-
Host noise.
fileproviderd(iCloud) used ~87 % of one core in 19 of 42 host samples; load average 5.2–7.8. - CPU-bound regime. In the optimized profiles the pod used ~7.3 of 10 vCPU. CPU per request is the more transferable number.
- Hello-world payload. A 5-byte response; real work dilutes the difference.
- Small sample. 3 × 30 s per cell (2 for ablation, sweep and diagnostics); medians, no significance tests. Spread under 2.5 % for 5 of 6 cells (4.8 % for socket + reuse).
-
Discarded runs. My first ablation, sweep and diagnostics overlapped with other work on the host (helper containers, a headless browser). I discarded them and re-ran everything strictly one at a time. During the main runs only a light host sampler (
topevery ~20 s) ran. -
"Stock" includes my choices, for example a WordPress-style
.htaccess. - wrk: closed-loop load, latency corrected for coordinated omission, 96 instead of 100 connections.
-
Memory blind spots: kernel socket memory is estimated from slab sizes; per-process PSS was not readable without
CAP_SYS_PTRACE. -
Kernel defaults favour TCP: without loopback
tcp_tw_reuse, TCP would run out of ports sooner. - No CPU limits, on purpose: CFS throttling would distort latency.
Conclusion
On a hello-world script, with no database and no application logic, a Unix socket between Apache and PHP-FPM gave 15 % (stock) to 28 % (tuned) more throughput and 17–25 % less CPU per request, with no memory cost. Nearly all of it comes from not opening and closing a TCP connection for every request.
That is the whole value: less load and faster handover between the two services, about 25 µs per request. On real pages that is a fraction of a percent, and for a site with 100k visits a month a few seconds of CPU. Use the socket when both containers share a pod. Use TCP with enablereuse when PHP-FPM needs to stand on its own. Tune your pools before either.
Reproduce
The script and the three manifests are in a public gist: https://gist.github.com/radeno/fb98fee7a24ad43b0d249d65024546af
git clone https://gist.github.com/radeno/fb98fee7a24ad43b0d249d65024546af ipc-bench
cd ipc-bench && chmod +x run-benchmark.sh
./run-benchmark.sh # optimized profile, one TCP vs socket comparison
PROFILE=baseline ./run-benchmark.sh # stock configs
PROFILE=optimized-reuse ./run-benchmark.sh # persistent FastCGI connections
You need kubectl pointed at any cluster. The script deploys both variants, runs wrk inside the cluster, samples CPU and memory from the cgroups, and prints the comparison table. It exits with 2 if the backend returned errors, so a broken run cannot pass for a result. Keep the machine otherwise idle while it runs.





Top comments (0)