-
-
Notifications
You must be signed in to change notification settings - Fork 368
/
Copy pathFpmHandler.php
373 lines (324 loc) · 12.9 KB
/
FpmHandler.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
<?php declare(strict_types=1);
namespace Bref\FpmRuntime;
use Bref\Context\Context;
use Bref\Event\Http\HttpHandler;
use Bref\Event\Http\HttpRequestEvent;
use Bref\Event\Http\HttpResponse;
use Bref\FpmRuntime\FastCgi\FastCgiCommunicationFailed;
use Bref\FpmRuntime\FastCgi\FastCgiRequest;
use Bref\FpmRuntime\FastCgi\Timeout;
use Exception;
use hollodotme\FastCGI\Client;
use hollodotme\FastCGI\Exceptions\TimedoutException;
use hollodotme\FastCGI\Interfaces\ProvidesRequestData;
use hollodotme\FastCGI\Interfaces\ProvidesResponseData;
use hollodotme\FastCGI\SocketConnections\UnixDomainSocket;
use RuntimeException;
use Throwable;
/**
* Handles HTTP events coming from API Gateway/ALB by proxying them to PHP-FPM via FastCGI.
*
* Usage example:
*
* $event = [get the Lambda event];
* $phpFpm = new FpmHandler('index.php');
* $phpFpm->start();
* $lambdaResponse = $phpFpm->handle($event);
* $phpFpm->stop();
* [send the $lambdaResponse];
*
* @internal
*/
final class FpmHandler extends HttpHandler
{
private const SOCKET = '/tmp/.bref/php-fpm.sock';
private const PID_FILE = '/tmp/.bref/php-fpm.pid';
private const CONFIG = '/opt/bref/etc/php-fpm.conf';
/**
* We define this constant instead of using the PHP one because that avoids
* depending on the pcntl extension.
*/
private const SIGTERM = 15;
private ?Client $client;
private UnixDomainSocket $connection;
private string $handler;
private string $configFile;
/** @var resource|null */
private $fpm;
public function __construct(string $handler, string $configFile = self::CONFIG)
{
$this->handler = $handler;
$this->configFile = $configFile;
}
/**
* Start the PHP-FPM process.
*
* @throws Exception
*/
public function start(): void
{
// In case Lambda stopped our process (e.g. because of a timeout) we need to make sure PHP-FPM has stopped
// as well and restart it
if ($this->isReady()) {
$this->killExistingFpm();
}
if (! is_dir(dirname(self::SOCKET))) {
mkdir(dirname(self::SOCKET));
}
/**
* --nodaemonize: we want to keep control of the process
* --force-stderr: force logs to be sent to stderr, which will allow us to send them to CloudWatch
*/
$resource = @proc_open(['php-fpm', '--nodaemonize', '--force-stderr', '--fpm-config', $this->configFile], [], $pipes);
if (! is_resource($resource)) {
throw new RuntimeException('PHP-FPM failed to start');
}
$this->fpm = $resource;
$this->client = new Client;
$this->connection = new UnixDomainSocket(self::SOCKET, 1000, 900000);
$this->waitUntilReady();
}
/**
* @throws Exception
*/
public function stop(): void
{
if ($this->isFpmRunning()) {
// Give it less than a second to stop (500ms should be plenty enough time)
// this is for the case where the script timed out: we reserve 1 second before the end
// of the Lambda timeout, so we must kill everything and restart FPM in 1 second.
$this->stopFpm(0.5);
if ($this->isReady()) {
throw new Exception('PHP-FPM cannot be stopped');
}
}
}
/**
* @throws Exception
*/
public function __destruct()
{
$this->stop();
}
/**
* Proxy the API Gateway event to PHP-FPM and return its response.
*
* @throws FastCgiCommunicationFailed
* @throws Timeout
* @throws Exception
*/
public function handleRequest(HttpRequestEvent $event, Context $context): HttpResponse
{
$request = $this->eventToFastCgiRequest($event, $context);
// The script will timeout 1 second before the remaining time
// to allow some time for Bref/PHP-FPM to recover and cleanup
$margin = 1000;
$timeoutDelayInMs = max(1000, $context->getRemainingTimeInMillis() - $margin);
try {
$socketId = $this->client->sendAsyncRequest($this->connection, $request);
$response = $this->client->readResponse($socketId, $timeoutDelayInMs);
} catch (TimedoutException) {
$invocationId = $context->getAwsRequestId();
echo "$invocationId The PHP script timed out. Bref will now restart PHP-FPM to start from a clean slate and flush the PHP logs.\nTimeouts can happen for example when trying to connect to a remote API or database, if this happens continuously check for those.\nIf you are using a RDS database, read this: https://bref.sh/docs/environment/database.html#accessing-the-internet\n";
/**
* Restart FPM so that the blocked script is 100% terminated and that its logs are flushed to stderr.
*
* - "why restart FPM?": if we don't, the previous request continues to execute on the next request
* - "why not send a SIGUSR2 signal to FPM?": that was a promising approach because SIGUSR2
* causes FPM to cleanly stop the FPM worker that is stuck in a timeout/waiting state.
* It also causes all worker logs buffered by FPM to be written to stderr (great!).
* This takes a bit of time (a few ms), but it's faster than rebooting FPM entirely.
* However, the downside is that it doesn't "kill" the previous request execution:
* it merely stops the execution of the line of code that is waiting (e.g. "sleep()",
* "file_get_contents()", ...) and continues to the next line. That's super weird!
* So SIGUSR2 isn't a great solution in the end.
*/
$this->stop();
$this->start();
// Throw an exception so that:
// - this is reported as a Lambda execution error ("error rate" metrics are accurate)
// - the CloudWatch logs correctly reflect that an execution error occurred
// - the 500 response is the same as if an exception happened in Bref
throw new Timeout($timeoutDelayInMs, $context->getAwsRequestId());
} catch (Throwable $e) {
printf(
"Error communicating with PHP-FPM to read the HTTP response. Bref will restart PHP-FPM now. Original exception message: %s %s\n",
get_class($e),
$e->getMessage()
);
// Restart PHP-FPM: in some cases PHP-FPM is borked, that's the only way we can recover
$this->stop();
$this->start();
throw new FastCgiCommunicationFailed;
}
$responseHeaders = $this->getResponseHeaders($response);
// Extract the status code
if (isset($responseHeaders['status'])) {
$status = (int) (is_array($responseHeaders['status']) ? $responseHeaders['status'][0] : $responseHeaders['status']);
unset($responseHeaders['status']);
}
$this->ensureStillRunning();
return new HttpResponse($response->getBody(), $responseHeaders, $status ?? 200);
}
/**
* @throws Exception If the PHP-FPM process is not running anymore.
*/
private function ensureStillRunning(): void
{
if (! $this->isFpmRunning()) {
throw new Exception('PHP-FPM has stopped for an unknown reason');
}
}
/**
* @throws Exception
*/
private function waitUntilReady(): void
{
$wait = 5000; // 5ms
$timeout = 5000000; // 5 secs
$elapsed = 0;
while (! $this->isReady()) {
usleep($wait);
$elapsed += $wait;
if ($elapsed > $timeout) {
throw new Exception('Timeout while waiting for PHP-FPM socket at ' . self::SOCKET);
}
// If the process has crashed we can stop immediately
if (! $this->isFpmRunning()) {
// The output of FPM is in the stderr of the Lambda process
throw new Exception('PHP-FPM failed to start');
}
}
}
private function isReady(): bool
{
clearstatcache(false, self::SOCKET);
return file_exists(self::SOCKET);
}
private function eventToFastCgiRequest(HttpRequestEvent $event, Context $context): ProvidesRequestData
{
$request = new FastCgiRequest($event->getMethod(), $this->handler, $event->getBody());
$request->setRequestUri($event->getUri());
$request->setRemoteAddress('127.0.0.1');
$request->setRemotePort($event->getRemotePort());
$request->setServerAddress('127.0.0.1');
$request->setServerName($event->getServerName());
$request->setServerProtocol($event->getProtocol());
$request->setServerPort($event->getServerPort());
$request->setCustomVar('PATH_INFO', $event->getPath());
$request->setCustomVar('QUERY_STRING', $event->getQueryString());
$request->setCustomVar('LAMBDA_INVOCATION_CONTEXT', json_encode($context, JSON_THROW_ON_ERROR));
$request->setCustomVar('LAMBDA_REQUEST_CONTEXT', json_encode($event->getRequestContext(), JSON_THROW_ON_ERROR));
$contentType = $event->getContentType();
if ($contentType) {
$request->setContentType($contentType);
}
foreach ($event->getHeaders() as $header => $values) {
foreach ($values as $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', (string) $header));
$request->setCustomVar($key, $value);
}
}
return $request;
}
/**
* This method makes sure to kill any existing PHP-FPM process.
*
* @throws Exception
*/
private function killExistingFpm(): void
{
// Never seen this happen but just in case
if (! file_exists(self::PID_FILE)) {
unlink(self::SOCKET);
return;
}
$pid = (int) file_get_contents(self::PID_FILE);
// Never seen this happen but just in case
if ($pid <= 0) {
echo "PHP-FPM's PID file contained an invalid PID, assuming PHP-FPM isn't running.\n";
unlink(self::SOCKET);
unlink(self::PID_FILE);
return;
}
// Check if the process is running
if (posix_getpgid($pid) === false) {
// PHP-FPM is not running anymore, we can cleanup
unlink(self::SOCKET);
unlink(self::PID_FILE);
return;
}
// The PID could be reused by our new process: let's not kill ourselves
// See https://github.com/brefphp/bref/pull/645
if ($pid === posix_getpid()) {
unlink(self::SOCKET);
unlink(self::PID_FILE);
return;
}
echo "PHP-FPM seems to be running already. This might be because Lambda stopped the bootstrap process but didn't leave us an opportunity to stop PHP-FPM (did Lambda timeout?). Stopping PHP-FPM now to restart from a blank slate.\n";
// The previous PHP-FPM process is running, let's try to kill it properly
$result = posix_kill($pid, self::SIGTERM);
if ($result === false) {
echo "PHP-FPM's PID file contained a PID that doesn't exist, assuming PHP-FPM isn't running.\n";
unlink(self::SOCKET);
unlink(self::PID_FILE);
return;
}
$this->waitUntilStopped($pid);
unlink(self::SOCKET);
unlink(self::PID_FILE);
}
/**
* Wait until PHP-FPM has stopped.
*
* @throws Exception
*/
private function waitUntilStopped(int $pid): void
{
$wait = 5000; // 5ms
$timeout = 1000000; // 1 sec
$elapsed = 0;
while (posix_getpgid($pid) !== false) {
usleep($wait);
$elapsed += $wait;
if ($elapsed > $timeout) {
throw new Exception('Timeout while waiting for PHP-FPM to stop');
}
}
}
/**
* Return an array of the response headers.
*/
private function getResponseHeaders(ProvidesResponseData $response): array
{
return array_change_key_case($response->getHeaders(), CASE_LOWER);
}
public function stopFpm(float $timeout): void
{
if (! $this->fpm) {
return;
}
$timeoutMicro = microtime(true) + $timeout;
if ($this->isFpmRunning()) {
$pid = proc_get_status($this->fpm)['pid'];
// SIGTERM
@posix_kill($pid, 15);
do {
usleep(1000);
// @phpstan-ignore-next-line
} while ($this->isFpmRunning() && microtime(true) < $timeoutMicro);
// @phpstan-ignore-next-line
if ($this->isFpmRunning()) {
// SIGKILL
@posix_kill($pid, 9);
usleep(1000);
}
}
proc_close($this->fpm);
$this->fpm = null;
}
private function isFpmRunning(): bool
{
return $this->fpm && proc_get_status($this->fpm)['running'];
}
}