编译 PHP for AWS Lambda

通过 AWS Lambda,无需配置或管理服务器即可运行代码。您只需按消耗的计算时间付费 – 代码未运行时不产生费用。
借助 Lambda,您几乎可以为任何类型的应用程序或后端服务运行代码,而且全部无需管理。只需上传您的代码,Lambda 会处理运行和扩展高可用性代码所需的一切工作。您可以将您的代码设置为自动从其他 AWS 服务触发,或者直接从任何 Web 或移动应用程序调用。

前言

AWS Lambda 支持的语言老实说已经比竞品多了不少,有Node.js (JavaScript), Python, Java (Java 8), Go, 以及 C# (.NET),但对于不同的应用场景我们可能还需要其他语言的支持,比如PHP, Perl, Ruby等,所幸 AWS Lambda 运行限制较为宽松,可以通过支持的语言调用shell命令来变相运行 AWS Lambda 所不支持的语言代码。

本篇以编译 PHP 为例,描述如何自行编译 PHP binary 变相执行 PHP 代码。

具体流程

AWS Lambda 并没有禁用 System() 等执行shell命令的函数,因而可以通过将预编译好的脚本语言解释器的 binary 文件塞进 Lambda 代码包里,然后通过中间语言(必须是 Lambda 官方支持的语言)调用 shell 命令执行解释器,通过将参数从 stdin 传入,结果从 stdout 传出并由中间语言返回给 Lambda

官方实现

在 AWS Lambda 的官方文档中已经给出了如何变相执行其他脚本语言 Scripting Languages for AWS Lambda: Running PHP, Ruby, and Go ,过程也较为详细,但奶冰最终并没有采取官方文档的实现,原因有几种(以 PHP 为例)

  1. 官方文档中要求编译环境为 AMI Linux,但这种 AWS 魔改的 Linux 发行版仅存在于 AWS EC2 中,意味着要编译 PHP for AMI Linux 就必须要在 AWS EC2 中启动一个 AMI Linux 实例之后再在其中进行编译操作,步骤较为复杂

  2. 官方文档中 PHP 的编译参数并没有禁用动态库(shared library),导致在 AWS Lambda 运行环境中找不到 php 运行所必须的库文件(比如编译参数里若启用了 OpenSSL 支持,生成的 php binary 丢到 AWS Lambda 上运行会报 libcrypto.so not found 错误)

因而奶冰采用的是下述方法,不过由于官方的文档具有启发性所以在此也丢一份备用

第三方实现

奶冰最后采用的是 Github 上一个 repo 的 docker 脚本,项目地址在此
araines/serverless-php

这个 repo 的目的是包装一个 composer 库用来直接模拟 AWS Lambda 对 PHP 的官方支持(就是通过API获取参数而不是stdin,代码风格上更优雅)
不过对于我们来说,目的应该是这个 repo 的 php binary 编译脚本,所以(请先安装好docker)

git clone https://github.com/araines/serverless-php
cd serverless-php
sh buildphp.sh

稍等片刻,等到 docker 退出之后就会发现当前文件夹下有一个 php 文件,这就是可以用于 AMI Linux 的 php binary


啥,这么简单就结束了喵? 那奶冰还要水(划掉)一篇博文干嘛

解析原理

既然人是会思考的芦苇,那么我们肯定会想看看是如何实现的

打开buildphp.sh

#!/bin/sh

# This script builds a docker container, compiles PHP for use with AWS Lambda,
# and copies the final binary to the host and then removes the container.
#
# You can specify the PHP Version by setting the branch corresponding to the
# source from https://github.com/php/php-src

PHP_VERSION_GIT_BRANCH=php-7.2.0 //指定编译的 PHP 版本

echo "Build PHP Binary from current branch '$PHP_VERSION_GIT_BRANCH' on https://github.com/php/php-src"

docker build --build-arg PHP_VERSION=$PHP_VERSION_GIT_BRANCH -t php-build -f dockerfile.buildphp . //由 dockerfile 直接构建镜像

container=$(docker create php-build)

docker -D cp $container:/php-src-$PHP_VERSION_GIT_BRANCH/sapi/cli/php . //把编译完毕的 php 文件从 docker 里拖出来

docker rm $container //毁灭一切吧

由 buildphp.sh 脚本不难看出一切所谓玄幻都来自 dockerfile.buildphp,所以我们也来 dockerfile.buildphp 里一探究竟

# Compile PHP with static linked dependencies
# to create a single running binary

# Lambda is based on 2017.03
# * don't grab the latest revion of the amazonlinux image. 
FROM amazonlinux:2017.03

ARG PHP_VERSION

# Lambda is based on 2017.03
# * dont' grab the latest revisions of development packages.
RUN yum --releasever=2017.03 install \
    autoconf \
    automake \
    libtool \
    bison \
    re2c \
    libxml2-devel \
    openssl-devel \
    libpng-devel \
    libjpeg-devel \
    curl-devel -y

RUN curl -sL https://github.com/php/php-src/archive/$PHP_VERSION.tar.gz | tar -zxv

WORKDIR /php-src-$PHP_VERSION

RUN ./buildconf --force

RUN ./configure \
    --enable-static=yes \
    --enable-shared=no \
    --disable-all \
    --enable-hash \
    --enable-json \
    --enable-libxml \
    --enable-mbstring \
    --enable-phar \
    --enable-soap \
    --enable-xml \
    --with-curl \
    --with-gd \
    --with-zlib \
    --with-openssl \
    --without-pear \
    --enable-ctype

RUN make -j 5

由 dockerfile 可看出,先是基于 amazonlinux 镜像再用 yum 安装必要文件,下载 php 源代码而后自动编译
所以如果想给 php 编译参数里加点其他的内容,只需要修改这个文件里 php compile line 即可

同时也可以观察出,该 php 编译参数里禁用了动态库且将所有静态库编译入 php binary 中才得以规避了动态库找不到的问题
也正因为此,如果想要php支持一些第三方拓展便成了一个比较麻烦的问题,必须要将拓展代码同样以静态库的方式编译进 php binary 中才可调用,所以便有了下一个部分

编译第三方拓展(以Imagick为例)

本段记录了奶冰折腾的全过程,所以会较为啰嗦,如果您想直接看最后的解决方案请移动滚轮至本段末


一开始根据 Google 上的教程,最佳方案应该是在拓展内部执行 phpize 之后编译,将拓展编译成 .so 文件后再在php.ini里添加extension一行
但是由于在这种应用场景下我们希望能尽量保持一个php文件的数量,不想再因此多添加文件
所以必须要设置好在编译 php 文件的同时直接将第三方拓展编译进 php 文件里

为了方便调试,奶冰希望在全新的 AMI Linux 环境下找出所需的依赖文件,所以可以将 dockerfile.buildphp 复制一份为 test.buildphp ,只保留如下内容

# Compile PHP with static linked dependencies
# to create a single running binary

# Lambda is based on 2017.03
# * don't grab the latest revion of the amazonlinux image. 
FROM amazonlinux:2017.03

ARG PHP_VERSION

# Lambda is based on 2017.03
# * dont' grab the latest revisions of development packages.
RUN yum --releasever=2017.03 install \
    autoconf \
    automake \
    libtool \
    bison \
    re2c \
    libxml2-devel \
    openssl-devel \
    libpng-devel \
    libjpeg-devel \
    curl-devel -y

而后在shell里执行(以 PHP 7.2.0 为例)

docker build --build-arg PHP_VERSION=7.2.10 -t php-build -f test.buildphp .

完成后只是生成了一个没有运行的镜像而已,所以接下来执行

docker run -i -t --entrypoint /bin/bash php-build

在此之后我们得到了一个 AMI Linux 内部的 shell ,此时就可以模拟成在 AMI Linux 内部操作了

由教程得,如果要在编译的时候将第三方拓展编译进 PHP binary 中就必须

  1. 将拓展源代码下载进源代码下的ext文件夹中
  2. 将包含拓展源代码的文件夹重命名为其拓展名
  3. 重新执行 ./buildconf –force (如果之前有执行过)
  4. 最后在 ./configure 的参数中 enable 该拓展

以 Imagick 为例
在 shell 中执行

export PHP_VERSION=php-7.2.0
curl -sL https://github.com/php/php-src/archive/$PHP_VERSION.tar.gz | tar -zxv
cd /php-src-$PHP_VERSION/ext
curl -sL https://pecl.php.net/get/imagick | tar -zxv #下载Magick源码并解压
mv imagick* imagick #去除Magick后面的版本号
cd ..
./buildconf --force
./configure \
    --enable-static=yes \
    --enable-shared=no \
    --disable-all \
    --enable-hash \
    --enable-json \
    --enable-libxml \
    --enable-mbstring \
    --enable-phar \
    --enable-soap \
    --enable-xml \
    --with-curl \
    --with-gd \
    --with-zlib \
    --with-openssl \
    --enable-ctype \
    --with-imagick #请注意这里要加上with-imagick

在编译过程中发现缺少 Imagick 头文件,在找到这些文件隶属于的软件包之后悄悄记小本本上后面要用到
再次编译,编译成功后可以直接在 AMI Linux 中测试是否可用

./php -r "phpinfo();" | grep Imagick

如果有输出即代表编译成功


最后加上之前小本本上所记的缺少的依赖,奶冰将原来的 dockerfile.buildphp 修改为(缺少的依赖为 ImageMagick 和 ImageMagick-devel)

# Compile PHP with static linked dependencies
# to create a single running binary
# Here, this file is modified by Milkice (https://milkice.me)
# Lambda is based on 2017.03
# * don't grab the latest revion of the amazonlinux image.
FROM amazonlinux:2017.03

ARG PHP_VERSION

# Lambda is based on 2017.03
# * dont' grab the latest revisions of development packages.
RUN yum --releasever=2017.03 install \
    autoconf \
    automake \
    libtool \
    bison \
    re2c \
    libxml2-devel \
    openssl-devel \
    libpng-devel \
    libjpeg-devel \
    curl-devel \
    ImageMagick \
    ImageMagick-devel -y

RUN curl -sL https://github.com/php/php-src/archive/$PHP_VERSION.tar.gz | tar -zxv

WORKDIR /php-src-$PHP_VERSION/ext

RUN curl -sL https://pecl.php.net/get/imagick | tar -zxv

RUN mv imagick* imagick

WORKDIR /php-src-$PHP_VERSION

RUN ./buildconf --force

RUN ./configure \
    --enable-static=yes \
    --enable-shared=no \
    --disable-all \
    --enable-hash \
    --enable-json \
    --enable-libxml \
    --enable-mbstring \
    --enable-phar \
    --enable-soap \
    --enable-xml \
    --with-curl \
    --with-gd \
    --with-zlib \
    --with-openssl \
    --enable-ctype \
    --with-imagick  \
    --with-curl

RUN make -j 5

而后再次执行 sh buildphp.sh,脚本执行完毕所生成的 php binary 就支持 Imagick 啦

中间语言的实现

编译之后,奶冰得到了一个可以在 AMI Linux 下运行的 PHP file,然后呢?

在官方的文档里,是通过 Node.JS 调用 Spawn() 运行 PHP binary,通过 stdin 将参数转为 json 字符串传入 PHP 内部,从 stdout 接收返回数据
代码如下

process.env['PATH'] = process.env['PATH'] + ':' + process.env['LAMBDA_TASK_ROOT'];
const spawn = require('child_process').spawn;
exports.handler = function(event, context, callback){
    var proc = spawn('./php',['WhyNotDrinkACupOfMilkWithIce.php']); #在此处修改要执行的 php 文件名
    var output = "";
    console.log(JSON.stringify(event));
    //send the input event json as string via STDIN to php process
    proc.stdin.write(JSON.stringify(event));

    //close the php stream to unblock php process
    proc.stdin.end();

    //dynamically collect php output
    proc.stdout.on('data', function(data) {
          output+=data;
    });

    //react to potential errors
    proc.stderr.on('data', function(data) {
            console.log("STDERR: "+data);
    });
    
    proc.on('close', function(code) {
    //if (code !== 0) {
      //return callback(new Error(`Process error code ${code}: ${response}`));
    //}
        console.log(output);
        context.succeed(JSON.parse(output));
        //callback(null, JSON.parse(output)); #请判断在不同场合下使用context.succeed()或callback()其中之一来返回数据
    });
    
}

非常简洁明了,接下来就是 PHP 端的特殊处理了

<?php
$stdin = file_get_contents("php://stdin");
$data = json_decode($stdin,true);
//Do something with data
echo json_encode("Milkice"=>"Me0w");

需要注意的是 PHP 都是要以 json 字符串的形式输入输出,否则会出现Malformed Response(如果是使用 AWS API Gateway 作为Trigger的话)

最后

对于包含二进制文件的多文件部署,AWS 支持上传 zip 程序包或者将程序包上传至 S3 Bucket 再将直链填入 Lambda 部署地址
zip程序包的文件层次结构应该类似如下

LambdaProgram.zip
|- php
|- proxy.js
|- qwq.php

请注意,如果将中间语言文件改名(如上的proxy.js),请务必在处理程序(Handler)这边修改成相应的数据
比如如果 js 文件名是 proxy.js,那么此处就要改为 proxy.handler,如果是 blahblah.js 则改为 blahblah.handler
其他语言请根据官方文档修改

《编译 PHP for AWS Lambda》

上传完毕之后可以模拟数据传入测试,如果成功即可

《编译 PHP for AWS Lambda》

总结

虽然看起来是很折腾(明明不支持的语言通过歪门邪道强行实现),但是作为 Serverless 产品,AWS Lambda的好处还是很明显的

再也不需要折腾前端 Apache/Nginx 啦,让那些烦人的配置见鬼去吧哈哈哈哈哈哈哈哈哈哈哈

-EOF-

点赞