This issue is as a result of this thread: https://twitter.com/aarondfrancis/status/1412539823139721223.
I got ~Octane working with Vapor and told Taylor that I'd toss the code to y'all to potentially include as a first-party option.
The Theory
The theory is to use Octane to boot the application and then reuse that booted app over and over again. You can do this because Lamba will freeze the container, memory and all. So you boot the application, serve the request, and then Lambda freezes the application exactly as it is.
When the next request comes in, Lambda will give you a warm container with the booted app from your last invocation. You're leveraging Octane to do the booting and cleaning, but not running any kind of server in the background.
In Vapor's current handler code, you're already taking advantage of this saved invocation-to-invocation state to keep FPM "running". Obviously it doesn't run the entire time, but Lambda freezes it and picks it back up as if it had been running that whole time.
My suggestion is to further take advantage of this unique Lambda quirk by keeping the application in (frozen) memory between invocations.
The Code
I took an extremely simple Laravel application that runs on Vapor and overwrote the Vapor FPM runtime to use Octane instead of FPM.
This is just a proof of concept, there are definitely better ways to accomplish parts of this. For example, I pulled in all of bref/bref
just to get access to a function that takes a Lambda Event and turns it into a PSR7 Request.
The Octane Runtime
The Octane Runtime is kind of an unholy union of the fpmRuntime
from Vapor and the roadrunner-worker
from Octane.
This file goes in the base directory as octaneRuntime.php
.
<?php
use Laravel\Octane\ApplicationFactory;
use Laravel\Octane\Worker;
use Laravel\Vapor\Runtime\Fpm\FpmLambdaResponse;
use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract;
use Laravel\Vapor\Runtime\LambdaContainer;
use Laravel\Vapor\Runtime\LambdaRuntime;
use Laravel\Vapor\Runtime\Secrets;
use Laravel\Vapor\Runtime\StorageDirectories;
fwrite(STDERR, 'Preparing to add secrets to runtime' . PHP_EOL);
$secrets = Secrets::addToEnvironment(
$_ENV['VAPOR_SSM_PATH'],
json_decode($_ENV['VAPOR_SSM_VARIABLES'] ?? '[]', true),
__DIR__ . '/vaporSecrets.php'
);
with(require 'bootstrap/app.php', function ($app) {
StorageDirectories::create();
$app->useStoragePath(StorageDirectories::PATH);
fwrite(STDERR, 'Caching Laravel configuration' . PHP_EOL);
$app->make(ConsoleKernelContract::class)->call('config:cache');
});
$invocations = 0;
$lambdaRuntime = LambdaRuntime::fromEnvironmentVariable();
$vapor = new \App\OctaneVaporClient;
try {
$worker = new Worker(new ApplicationFactory(__DIR__), $vapor);
$worker->boot();
} catch (Throwable $e) {
fwrite(STDERR, $e->getMessage());
exit(1);
}
while (true) {
$lambdaRuntime->nextInvocation(function ($invocationId, $event) use ($vapor, $worker) {
[$request, $context] = $vapor->marshalRequestFromEvent($event);
$response = null;
$vapor->captureResponseUsing(function (FpmLambdaResponse $captured) use (&$response) {
$response = $captured;
});
$worker->handle($request, $context);
return $response->toApiGatewayFormat();
});
LambdaContainer::terminateIfInvocationLimitHasBeenReached(
++$invocations, (int)($_ENV['VAPOR_MAX_REQUESTS'] ?? 250)
);
}
The idea here is to create and boot the worker outside of the loop, and then use it over and over in the event loop.
The OctaneVaporClient
The app\OctaneVaporClient.php
is a tiny little client that doesn't do a whole lot. This is where I pull in the Psr7Bridge
from bref, despite it being marked as internal only. If this were to pass the PoC stage, that would need to be looked at.
<?php
namespace App;
use Bref\Context\Context;
use Bref\Event\Http\HttpRequestEvent;
use Bref\Event\Http\Psr7Bridge;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Laravel\Octane\Contracts\Client;
use Laravel\Octane\MarshalsPsr7RequestsAndResponses;
use Laravel\Octane\OctaneResponse;
use Laravel\Octane\RequestContext;
use Laravel\Vapor\Runtime\Fpm\FpmLambdaResponse;
use Throwable;
class OctaneVaporClient implements Client
{
use MarshalsPsr7RequestsAndResponses;
/**
* @var callable
*/
public $captureResponse;
public function captureResponseUsing($callback)
{
$this->captureResponse = $callback;
}
public function marshalRequestFromEvent($event)
{
$request = new HttpRequestEvent($event);
// @TODO ?
$context = new Context('', 0, '', '');
return $this->marshalRequest(new RequestContext([
'psr7Request' => Psr7Bridge::convertRequest($request, $context)
]));
}
public function marshalRequest(RequestContext $context): array
{
// Exactly the same as the RoadRunnerClient
return [
$this->toHttpFoundationRequest($context->psr7Request),
$context,
];
}
public function respond(RequestContext $context, OctaneResponse $response): void
{
/** @var Response $response */
$response = $response->response;
// Reusing the FpmLambdaResponse, just for now.
$response = new FpmLambdaResponse($response->status(), $response->headers->all(), $response->getContent());
call_user_func($this->captureResponse, $response);
}
public function error(Throwable $e, Application $app, Request $request, RequestContext $context): void
{
// @TODO: Implement error() method.
}
}
Overwriting the FPM Runtime
The last thing to do is muck around with Vapor to make it use the octaneRuntime instead of the fpmRuntime.
For this proof of concept, I have a command that overwrites fpmRuntime.php
in vapor-core. Obviously the goal would be for vapor-core to be updated to support Octane, but that only matters if this idea actually ends up working.
For now, the app\Console\Commands\OctaneRuntime.php
file is as follows:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class OctaneRuntime extends Command
{
protected $signature = 'octane:runtime';
protected $description = 'Overwrite the FPM handler with the Octane Handler';
/**
* Execute the console command.
*/
public function handle()
{
file_put_contents(
base_path('vendor/laravel/vapor-core/stubs/fpmRuntime.php'),
file_get_contents(base_path('octaneRuntime.php'))
);
}
}
In your vapor.yml
add php artisan octane:runtime
as a build step. This will activate the new runtime! Now you can deploy and give it a test.
Results
In my testing on my very simple app, I was able to get cold boots from 1500ms down to 350ms, and invocation duration from 20-50ms down to 4ms. My app doesn't use much (it's just my personal website), but I'd imagine the gains would be bigger for bigger apps.
Also memory usage dropped by ~30mb.
Caveats
This currently doesn't handle the warming ping, it will error out because it's not a proper Lambda Event. You'll see Fatal error: Uncaught Bref\Event\InvalidLambdaEvent: This handler expected to be invoked with a API Gateway or ALB event. Instead, the handler was invoked with invalid event data
in the logs. Should be easy to fix, I just didn't think it was necessary at the moment.
Taylor also mentioned something about database connections that might be an issue: https://twitter.com/taylorotwell/status/1412542866220584961
Let me know what y'all think! I'd be happy to help where I can.
enhancement