Testing a local PHP-powered AWS Lambda

If you need more castle here then you'll need to work out how to scale the cliff.

I've been following serverless for a while now. I like the idea of scaling apps purely based on my usage, not my ability to forecast my expected usage and then adding a bit more to cover the fuck-up factor.

Granted, it can get expensive if you suddenly become popular, but that's better than it going down and crashing because you became popular. Add in AWS's relatively generous free tier and you can do a lot of playing around with new ideas completely  (most of the time) without cost.

AWS Lambda's been around for years now, but the choice of languages has always been quite limited but back in that misty time of the pandemic (around the start of 2021) they allowed you to start using Docker containers, which means as a result we can use whatever language we can convince to run in a Docker container. You want COBOL on a Lambda, go ahead.

I've been using PHP since 2002 and 20 years on it's still my main language at work and still my "go to" language for playing around (and also also for cli scripts but that's a little weird). PHP's simple, single-threadness makes it really simple to build synchronous processes, which is kind of perfect for Lambda as you're building them to do one thing and then stop, and then do that thing again with possibly different input.

There are ways to build an entire framework with PHP and AWS Lambda (see Bref), but most of the time I want my Lambda to be nice and simple. AWS wrote about how to get PHP working in AWS Lambda, but they only covered PHP 7.4, and I find their documentation tends to skimp on the interesting detail a bit.

Where to start?

We're going to need a docker container, so we're going to need a Dockerfile to define it. We'll use AWS's default one as a basis, but alter it for our needs.

The Dockerfile will call a PHP (well bash, but we convert it to PHP) file at runtime/bootstrap. There's a little more to this code than that, but that's the basics. 

As a little spoiler for further ahead, this code can support using AWS Gateway inputs / outputs, but we're going to use a new feature of AWS Lambda for accessing our code - Function URLs.

Dockerfile

This Dockerfile is based off of Amazon's PHP Dockerfile but with a couple of key differences. Unlike Amazon's, this one can be used for multiple versions of PHP. All you need to do is change the php_version argument to a version of PHP that works with your code. 

I've tested it with PHP versions 7.3.33, 7.4.28, and 8.1.4 but I'm confident it'll work for any 7.3, 7.4, 8.0, and 8.1 build.

# Dockerfile
# Lambda base image Amazon Linux
FROM public.ecr.aws/lambda/provided as builder

# Set desired PHP Version
ARG php_version="8.1.4"

# Install software required to build PHP
RUN yum clean all && \
    yum install -y autoconf \
        bison \
        bzip2-devel \
        gcc \
        gcc-c++ \
        git \
        gzip \
        libcurl-devel \
        libxml2-devel \
        make \
        oniguruma-devel \
        openssl-devel \
        re2c \
        sqlite-devel \
        tar \
        unzip \
        zip

# Download the PHP source, compile, and install both PHP and Composer
RUN curl -sL https://github.com/php/php-src/archive/php-${php_version}.tar.gz | tar -xvz && \
    cd php-src-php-${php_version} && \
    ./buildconf --force && \
    ./configure --prefix=/opt/php-bin/ --with-openssl --with-curl --with-zlib --without-pear --enable-bcmath --with-bz2 --enable-mbstring --with-mysqli && \
    make -j 5 && \
    make install && \
    /opt/php-bin/bin/php -v && \
    curl -sS https://getcomposer.org/installer | /opt/php-bin/bin/php -- --install-dir=/opt/php-bin/bin/ --filename=composer

# Prepare runtime files
COPY runtime/bootstrap /lambda-php-runtime/
RUN chmod 0755 /lambda-php-runtime/bootstrap

# Install Guzzle, prepare vendor files
COPY composer.json /lambda-php-vendor/composer.json
RUN cd /lambda-php-vendor && \
    /opt/php-bin/bin/php /opt/php-bin/bin/composer require guzzlehttp/guzzle && \
    /opt/php-bin/bin/php /opt/php-bin/bin/composer install

###### Create runtime image ######
FROM public.ecr.aws/lambda/provided as runtime
# Layer 1: PHP Binaries
COPY --from=builder /opt/php-bin /var/lang
# Layer 2: Runtime Interface Client
COPY --from=builder /lambda-php-runtime /var/runtime
# Layer 3: Vendor
COPY --from=builder /lambda-php-vendor/vendor /opt/vendor

RUN yum install -y autoconf \
        oniguruma-devel \
        vim \
        ImageMagick

COPY fonts/ /usr/share/fonts/
COPY src/ /var/task/

CMD [ "index" ]

This Dockerfile works by creating a builder layer, installing PHP's source code and the related packages onto it, and then copying it to a clean, Lambda-friendly runtime image. Both the images used are the same to stop any issues with compatibility, but the main difference is that we throw all but the built PHP version out.

This does mean to get any software on the layer we need to yum install it (again if it was also required on the first layer). This can be seen from line 56.

PHP files

The other main file is the runtime/bootstrap file. This is called when you call Lambda so we need to tell the file to call PHP and run what we need.

As touched on before, the bootstrap file uses a #! to automatically run all it's contents through the PHP interpreter. Also, this code is designed for PHP 8.1. If you're going to use a lower version then you may need to tweak some bits.

Mine also has a built in autoloader as I'm expecting my code to always become more complex than a simple script. This path for this matches line 63 in the Dockerfile.

I've ripped out a lot of the original parts of the do / while loop as well. It seemed overly complex for what's needed here, although I've then added my own class called Index which lives as src/Index.php as I like classes in my code.

#!/var/lang/bin/php

# runtime/bootstrap

<?php
require_once '/opt/vendor/autoload.php';

use GuzzleHttp\Client;

spl_autoload_register(function ($class) {
    include '/var/task/' . str_replace('\\', '/', $class) . '.php';
});

do {
    $request = getNextRequest();
    $response = (new Index())->handle($request['payload']);
    sendResponse($request['invocationId'], $response);
} while (true);

function getNextRequest(): array
{
    $client = new Client();
    $response = $client->get('http://' . $_ENV['AWS_LAMBDA_RUNTIME_API'] . '/2018-06-01/runtime/invocation/next');

    return [
      'invocationId' => $response->getHeader('Lambda-Runtime-Aws-Request-Id')[0],
      'payload' => json_decode((string) $response->getBody(), true)
    ];
}

function sendResponse(string $invocationId, ApiResponse $response): void
{
    $headers = [
        "Content-Type" => "application/json",
        "Access-Control-Allow-Origin" => "*",
        "Access-Control-Allow-Headers" => "Content-Type",
        "Access-Control-Allow-Methods" => "OPTIONS,POST",
    ];

    $responseBody = json_encode([
        "statusCode" => $response->statusCode,
        "headers" => $headers,
        "body" => $response->body,
    ]);

    $client = new Client();
    $client->post(
    'http://' . $_ENV['AWS_LAMBDA_RUNTIME_API'] . '/2018-06-01/runtime/invocation/' . $invocationId . '/response',
       ['body' => $responseBody]
    );
}

The bootstrap file has an upgraded response object so that the API response is mainly dealt with here to keep things clean. This uses a new class called src/ApiResponse.php. In C# this would be a simple struct but as PHP it's a class or nothing, a class it is here. It's job is simply to streamline passing the response body and status code around.

# src/ApiResponse.php

<?php
class ApiResponse
{
    public string|array|object $body = [];
    public int $statusCode = 200;

    public function __construct(string|array|object $body, int $statusCode = 200)
    {
        $this->body = $body;
        $this->statusCode = $statusCode;
    }
}

The last file required for this to work is the aforementioned src/Index.php. This code expects either the input in JSON inside a "queryStringParameters" key through GET, or through POST with a JSON body and a "queryStringParameters" key.

It then takes that input and returns it for this demo version.

# src/Index.php

<?php
class Index
{
    public function handle(array $data): ApiResponse
    {
        $data = !$data['queryStringParameters'] ? json_decode($data['body'], true) : $data;

        return new ApiResponse($data);
    }
}

Testing the code

Next time I'll go into how to make the Lambda live and what permissions are useful in AWS for it, but for now I'll just cover testing it locally. This blog's already a little too long as it is.

The following will build our docker image and assign it to the tag php-lambda-test and then run the image, mapping port 8080 to a local port of 9202. These docker images provided by AWS for Lambda already have the 8080 side of it set up to push everything at the runtime/bootstrap file.

export DOCKER_BUILDKIT=0
export COMPOSE_DOCKER_CLI_BUILD=0

docker build -t php-lambda-test .

docker run -d --name php-lambda-test-instance -p 9202:8080 php-lambda-test:latest

docker logs -f php-lambda-test-instance

The final line with docker logs allows us to read all the output from the container so we can use var_dump() and echo to see what's going on for debugging.

With the docker up and running now, we can use the following curl for testing. The first bit uses our port of 9202 together with the lambda'y path bits.

The body contains some valid JSON with has our queryStringParameters from above and an additional requestContext. That requestContext is mainly used when running through API Gateway.

curl "http://localhost:9202/2015-03-31/functions/function/invocations" -d '{"queryStringParameters": {"hallo":"world"},"requestContext": {"http": {"method": "GET"}}}'

That's it for now. The test code for this can be found github.com/dittto/php-lambda-test/tree/blog-1.