构建 AWS Lambda Python Docker 镜像

AWS 的 Lambda 在 2020-12-01 开始支持用 Docker 镜像存放代码,见 New for AWS Lambda - Container Image Support。AWS Lambda 最初的对发布包的限制是 50M, 解压后(因为执行前需要解压缩)不能超过 250M,对于压缩比小于 1/5 的包来说,要突破 50M 部署包的限制就要用 2018-11-29 推出的层(layer), 即把 Lambda 的依赖可以组织为层,每个 Lambda 可引用最多 5 个层,但最终 Lambda 加上层所解压后的大小仍然有 250 M 的限制。

对于使用了大量依赖的 Lambda,比如 Python 中用了 Pandas 之类的数学分析包,250M 的大小是不够的,所以才有了 Docker 镜像化的 Lambda, 镜像的大小限制一下蹦到 10G,要构建出一个 10G Lambda 用的 Linux 镜像, 那绝对是个巨兽,至少目前是超越我的想像力,除非往里面塞入大量的业务数据。关于 Lambda 有哪些限制,请参阅 Lambda quotas

介绍完 Lambda 引入 Docker 镜像的背景后,本文接下来只关注如何构建一个 Python Lambda 镜像,对于如何部署 Docker 化的 Lambda, 不在本文的范围之内。主要的参考文档为 AWS Lambda 官方的 Deploy Python Lambda functions with container images.

构建一个简单的 Lamda 镜像

最简单的构建一个 Lambda 用的 Python Docker 镜像的方式就是从 AWS 提供的基础镜像开始,Amazon 有一个公开的像 Dockhub 类似的镜像仓库,叫做 Amazon ECR Public Gallery。下面是两个 Lambda 镜像相关的链接

  1. https://gallery.ecr.aws/?operatingSystems=Linux&searchTerm=lambda: 包括所有 Lambda 所支持语的运行时相关镜像
  2. https://gallery.ecr.aws/lambda/python: Python 相关的 Lambda 基础镜像

我们将以 public.ecr.aws/lambda/python:3.9 以基础构建一个 Lambda 镜像。首先准备一个 Lambda 代码  app.py
1import sys
2def handler(event, context):
3    return 'Hello from AWS Lambda using Python' + sys.version + '!'

假如我们要安装第三方的 Python 依赖(这通常是必须的,否则也就没有必要为 Lambda 选择 Docker) 放在 requirements.txt, 内容为
1bounded-executor

接下来基本的 Dockerfile 内容为
1FROM public.ecr.aws/lambda/python:3.9
2
3COPY app.py requirements.txt ${LAMBDA_TASK_ROOT}/
4
5RUN  pip3 install -r requirements.txt --target "${LAMBDA_TASK_ROOT}" --no-cache-dir
6
7CMD [ "app.handler" ]

我们用命令
$ docker build -t lambda-docker-demo .
构建好后,在部署到 AWS 之前我们可进行本地测试,先运行容器
$ docker run -p 9080:8080 lambda-docker-demo
time="2021-11-08T16:27:43.262" level=info msg="exec '/var/runtime/bootstrap' (cwd=/var/task, handler=)"
测试
$ curl -XPOST "http://localhost:9080/2015-03-31/functions/function/invocations" -d '{}'
"Hello from AWS Lambda using Python3.9.6 (default, Oct 25 2021, 08:38:55) \n[GCC 7.3.1 20180712 (Red Hat 7.3.1-13)]!"
同时在 Docker 容器中会打印
time="2021-11-08T16:28:29.09" level=info msg="extensionsDisabledByLayer(/opt/disable-extensions-jwigqn8j) -> stat /opt/disable-extensions-jwigqn8j: no such file or directory"
time="2021-11-08T16:28:29.09" level=warning msg="Cannot list external agents" error="open /opt/extensions: no such file or directory"
START RequestId: 025033dc-1361-42f4-8325-4abc97e3f689 Version: $LATEST
END RequestId: 025033dc-1361-42f4-8325-4abc97e3f689
REPORT RequestId: 025033dc-1361-42f4-8325-4abc97e3f689 Init Duration: 0.77 ms Duration: 78.07 msBilled Duration: 79 ms Memory Size: 3008 MB Max Memory Used: 3008 MB
这是一个标准的 Lambda 的后台输出

分析 Lambda 镜像

我们可以用 docker inspect 来查看所生成的镜像以及容器,基础镜像的 Dockerfile 可见 aws/aws-lambda-base-images.
$ docker inspect public.ecr.aws/lambda/python:3.9
我们得如下值得关注的内容
 1"Env": [
 2    "LANG=en_US.UTF-8",
 3    "TZ=:/etc/localtime",
 4    "PATH=/var/lang/bin:/usr/local/bin:/usr/bin/:/bin:/opt/bin",
 5    "LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib",
 6    "LAMBDA_TASK_ROOT=/var/task",
 7    "LAMBDA_RUNTIME_DIR=/var/runtime"
 8],
 9"Cmd": null,
10"Image": "",
11"Volumes": null,
12"WorkingDir": "/var/task",
13"Entrypoint": [
14    "/lambda-entrypoint.sh"
15],

环境变量 LAMBDA_TASK_ROOT 为 /var/task, 即 Lambda 的工作目录,它的入口为 /lambda-entrypoint.sh, 构建镜像 lambda-docker-demo 的 CMD 的内容将作为该 Entrypoint 的参数,即最终镜像的启动命令相当于
$ cd /var/task
$ /lambda-entrypoint.sh app.handler
如果我们用 docker ps --no-trunc 也能看到该完整命令
1docker ps --no-trunc --format "table {{.Image}}\t{{.Command}}"
2IMAGE                COMMAND
3lambda-docker-demo   "/lambda-entrypoint.sh app.handler"

下面我们通过覆盖 --entrypoint 参数进到该容器
$ docker run -it -p 9080:8080 --entrypoint /bin/sh lambda-docker-demo
sh-4.2# 
手动执行 /lambda-entrypoint.sh app.handler 后,同样可以在本地测试通过。

我们尽量收集多的一些 Lambda 启动的信息
 1sh-4.2# pwd
 2/var/task
 3sh-4.2# ls
 4app.py  bounded_executor  bounded_executor-0.0.5.dist-info  __pycache__  requirements.txt
 5sh-4.2# python -c 'import sys; print("\n".join(sys.path))'
 6
 7/var/lang/lib/python39.zip
 8/var/lang/lib/python3.9
 9/var/lang/lib/python3.9/lib-dynload
10/var/lang/lib/python3.9/site-packages
11sh-4.2# cat /lambda-entrypoint.sh
12#!/bin/sh
13# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
14
15if [ $# -ne 1 ]; then
16  echo "entrypoint requires the handler name to be the first argument" 1>&2
17  exit 142
18fi
19export _HANDLER="$1"
20
21RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
22if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
23  exec /usr/local/bin/aws-lambda-rie $RUNTIME_ENTRYPOINT
24else
25  exec $RUNTIME_ENTRYPOINT
26fi
27sh-4.2# bash -x /lambda-entrypoint.sh app.handler
28+ '[' 1 -ne 1 ']'
29+ export _HANDLER=app.handler
30+ _HANDLER=app.handler
31+ RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
32+ '[' -z '' ']'
33+ exec /usr/local/bin/aws-lambda-rie /var/runtime/bootstrap
34INFO[0000] exec '/var/runtime/bootstrap' (cwd=/var/task, handler=)
35^CINFO[0001] Received signal                               signal=interrupt
36INFO[0001] Shutting down...
37WARN[0001] Reset initiated: SandboxTerminated
38sh-4.2# cat /var/runtime/bootstrap
39#!/bin/bash
40# Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
41
42export AWS_EXECUTION_ENV=AWS_Lambda_python3.9
43
44if [ -z "$AWS_LAMBDA_EXEC_WRAPPER" ]; then
45  exec /var/lang/bin/python3.9 /var/runtime/bootstrap.py
46else
47  wrapper="$AWS_LAMBDA_EXEC_WRAPPER"
48  if [ ! -f "$wrapper" ]; then
49    echo "$wrapper: does not exist"
50    exit 127
51  fi
52  if [ ! -x "$wrapper" ]; then
53    echo "$wrapper: is not an executable"
54    exit 126
55  fi
56    exec -- "$wrapper" /var/lang/bin/python3.9 /var/runtime/bootstrap.py
57fi
58sh-4.2# echo $LAMBDA_RUNTIME_DIR
59/var/runtime
60sh-4.2# ls /var/runtime
61awslambdaric              __pycache__
62awslambdaric-1.2.2.dist-info  python_dateutil-2.8.2.dist-info
63bin               runtime_client.cpython-39-x86_64-linux-gnu.so
64bootstrap             runtime-release
65bootstrap.py              s3transfer
66boto3                 s3transfer-0.5.0.dist-info
67boto3-1.18.55.dist-info       simplejson
68botocore              simplejson-3.17.2-py3.9.egg-info
69botocore-1.21.55.dist-info    six-1.16.0.dist-info
70dateutil              six.py
71jmespath              urllib3
72jmespath-0.10.0.dist-info     urllib3-1.26.6.dist-info
73layer_bootstrap

关键信息整理如下:

  1. 第三方依赖我们用 pip install --target /var/task 目录下,不指定 --target  安装到  site-packages 中也行
  2. 从执行入口 /lambda-entrypoint.sh app.handler,在本地测试时,最后执行的代码是
    export _HANDLER=app.handler
    exec /usr/local/bin/aws-lambda-rie /var/runtime/bootstrap
  3. /usr/local/bin/aws-lambda-rie 是一个二进制文件,专为测试用的
  4. 在 /var/runtime 是 Lambda 的一些必需的模块,主要是 boto3,不用太关注
  5. 通过后面的回溯,本地测试时没有 AWS_LAMBDA_RUNTIME_API,执行的是 exec /usr/local/bin/aws-lambda-rie /var/runtime/bootstarp, 部署到 AWS  环境后有了 AWS_LAMBDA_RUNTIME_API, 执行的是 /var/runtime/bootstrap
    起决定性的脚本是
    1export _HANDLER=app.handler<br/><br/>
    2RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
    3if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
    4  exec /usr/local/bin/aws-lambda-rie $RUNTIME_ENTRYPOINT
    5else
    6  exec $RUNTIME_ENTRYPOINT
    7fi

如果我们只基于一个 Python 基础镜像来构建自己的 Lambda 镜像, 只要达到以上 #5 的要求就行,所创建的镜像可同时运行于本地及 AWS 环境。实际上要达成 /var/runtime/bootstrap 的启动方式会比较复杂,所以后面未采此种方法 awslambdaricaws-lambda-rie 来启动 Lambda 的。

创建 Python 为基础的 Lambda 镜像

如果按照当前版本的 AWS 官方文档 To create an image using an alternative base image 来创建一个 Lambda 镜像,在本地启动的时候会出现错误
$ docker run -p 9080:8080 lambda-docker-demo1
Traceback (most recent call last):
    File "/usr/local/lib/python3.10/runpy.py", line 196, in _run_module_as_main
        return _run_code(code, main_globals, None,
    File "/usr/local/lib/python3.10/runpy.py", line 86, in _run_code
        exec(code, run_globals)
    File "/function/awslambdaric/__main__.py", line 20, in <module>
        main(sys.argv)
    File "/function/awslambdaric/__main__.py", line 14, in main
        lambda_runtime_api_addr = os.environ["AWS_LAMBDA_RUNTIME_API"]
    File "/usr/local/lib/python3.10/os.py", line 679, in __getitem__
        raise KeyError(key) from None
KeyError: 'AWS_LAMBDA_RUNTIME_API'
但部署后 AWS 上是没问题的
原因是因为本地启动容器时不存 AWS_LAMBDA_RUNTIME_API 环境变量值,而在 AWS 环境中是有的。

 如果我们在 app.py 中加输出 os.environ 可以看到以下所有的环境变量
 1'_AWS_XRAY_DAEMON_PORT': '2000'
 2'_AWS_XRAY_DAEMON_ADDRESS': '169.254.79.129'
 3'_HANDLER': 'app.handler'
 4'AWS_LAMBDA_FUNCTION_VERSION': '$LATEST'
 5'PATH': '/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
 6'AWS_LAMBDA_LOG_STREAM_NAME': '2021/11/08/[$LATEST]2ee4dc9a1a334fae8096f73424ad06ac'
 7'AWS_LAMBDA_INITIALIZATION_TYPE': 'on-demand'
 8'PYTHON_GET_PIP_URL': 'https://github.com/pypa/get-pip/raw/3cb8888cc2869620f57d5d2da64da38f516078c7/public/get-pip.py'
 9'LAMBDA_TASK_ROOT': '/var/task'
10'AWS_REGION': 'us-east-1'
11'AWS_LAMBDA_RUNTIME_API': '127.0.0.1:9001'
12'AWS_LAMBDA_FUNCTION_NAME': 'test-lambda'
13'PYTHON_PIP_VERSION': '21.2.4'
14'PYTHON_GET_PIP_SHA256': 'c518250e91a70d7b20cceb15272209a4ded2a0c263ae5776f129e0d9b5674309'
15'PYTHON_SETUPTOOLS_VERSION': '57.5.0'
16'AWS_XRAY_DAEMON_ADDRESS': '169.254.79.129:2000'
17'AWS_SESSION_TOKEN': '......'
18'AWS_LAMBDA_FUNCTION_MEMORY_SIZE': '128'
19'GPG_KEY': 'A035C8C19219BA821ECEA86B64E628F8D684696D'
20'AWS_LAMBDA_LOG_GROUP_NAME': '/aws/lambda/test-lambda'
21'AWS_EXECUTION_ENV': 'AWS_Lambda_Image'
22'AWS_XRAY_CONTEXT_MISSING': 'LOG_ERROR'
23'AWS_ACCESS_KEY_ID': '......'
24'LAMBDA_RUNTIME_DIR': '/var/runtime'
25'AWS_DEFAULT_REGION': 'us-east-1'
26'LANG': 'C.UTF-8'
27'PYTHON_VERSION': '3.10.0'
28'AWS_SECRET_ACCESS_KEY': '......'
29'_X_AMZN_TRACE_ID': 'Root=1-618968c7-7f745c050fc6b61d59cf2280;Parent=790e20cb2f0228fe;Sampled=0'

AWS_LAMBDA_RUNTIME_API 为 127.0.0.1:9001

不过即使我们在本地启动 docker 时加上该环境变量也无济于事
$ docker run -e AWS_LAMBDA_RUNTIME_API=127.0.0.1:9001 -p 9080:8080 lambda-docker-demo1
Traceback (most recent call last):
    File "/usr/local/lib/python3.10/runpy.py", line 196, in _run_module_as_main
[ERROR] [123456789088] LAMBDA_RUNTIME Failed to get next invocation. No Response from endpoint
return _run_code(code, main_globals, None,
    File "/usr/local/lib/python3.10/runpy.py", line 86, in _run_code
        exec(code, run_globals)
    File "/function/awslambdaric/__main__.py", line 20, in <module>
        main(sys.argv)
    File "/function/awslambdaric/__main__.py", line 16, in main
        bootstrap.run(app_root, handler, lambda_runtime_api_addr)
    File "/function/awslambdaric/bootstrap.py", line 399, in run
        event_request = lambda_runtime_client.wait_next_invocation()
    File "/function/awslambdaric/lambda_runtime_client.py", line 68, in wait_next_invocation
        response_body, headers = runtime_client.next()
RuntimeError: Failed to get next
通过对前面的实践,我才逐步了解到 AWS Lambda  给我们提供了两个运行接口

  1. Runtime interface emulator, 即 RIE, 本地测试用的执行入口
  2. Runtime interface clients, 即 RIC, 实际 Lambda 部署到 AWS 环境的执行入口

通常是检测是否有 AWS_LAMBDA_RUNTIME_API 环境变量来选择入口,本地测试时没有该环境变量,所以进入 RIE

创建不含 RIE 的 Docker 镜像

如果不考虑本地测试的 RIE,  Dockerfile 内容如下
 1FROM python:3.9-slim
 2
 3ENV LAMBDA_TASK_ROOT=/var/task
 4RUN mkdir -p ${LAMBDA_TASK_ROOT} 
 5COPY app.py requirements.txt ${LAMBDA_TASK_ROOT}/
 6
 7WORKDIR ${LAMBDA_TASK_ROOT}
 8
 9RUN  pip install awslambdaric --target ${LAMBDA_TASK_ROOT} --no-cache-dir && \
10     pip install -r requirements.txt --target "${LAMBDA_TASK_ROOT}" --no-cache-dir
11
12ENTRYPOINT python -m awslambdaric app.handler

注:由于选择的是 python:3.9-slim 基础镜像,所以要进行 AWS 操作的话,应该在 requirements.txt 文件中加上 boto3 依赖。

ENTRYPOINT 可用命令,参数数组的形式
1ENTRYPOINT [ "python", "-m", "awslambdaric" ]
2CMD [ "app.handler" ]

那么是否就无法在本地测试不带 RIE 的镜像呢?问题总有解决它的办法, 下载 aws-lambda-rie 在宿主机上,映射卷再替换 entrypoint
$ mkdir -p ~/.aws-lambda-rie
$ wget -P ~/.aws-lambda-rie https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie
$ chmod +x ~/.aws-lambda-rie/aws-lambda-rie
然后启动时用命令
$ docker run -v ~/.aws-lambda-rie:/aws-lambda -p 9080:8080 --entrypoint /aws-lambda/aws-lambda-rie \
    lambda-docker python -m awslambdaric app.handler
镜像名 lambda-docker 后面整串 python -m awslambdaric app.handler 是作为 entrypoint 的参数,所以启动 Lambda 的命令是
/aws-lambda/aws-lambda-rie python -m awslambdaric app.handler

最后用一样的命令测试
$ curl -XPOST "http://localhost:9080/2015-03-31/functions/function/invocations" -d '{}'

创建整合了 RIE   的 Docker 镜像

现在要构建一个整合了 RIE 的 Lambda 镜像,既能方便本地测试同时又能部署到 AWS 上运行的 Docker 镜像的方法如下:

先建立一个自己的 entrypoint 脚本 entry_script.sh, 内容为
1#!/bin/sh
2if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
3  exec aws-lambda-rie python -m awslambdaric $@
4else
5  exec python -m awslambdaric $@
6fi     

$@ 表示入口命令与参数,即 ENTRYPOINT + CMD

再用命令把它改为可执行(想在 Dockerfile 中 RUN 下面的命令也行)
$ chmod +x entry_script.sh
接下来就是如何把 RIE 包打入 Docker 镜像内部, 见如下完整的 Dockerfile 内容
 1FROM python:3.9-slim
 2
 3ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/local/bin
 4
 5RUN chmod +x /usr/local/bin/aws-lambda-rie
 6
 7ENV LAMBDA_TASK_ROOT=/var/task
 8RUN mkdir -p ${LAMBDA_TASK_ROOT}
 9COPY app.py requirements.txt ${LAMBDA_TASK_ROOT}/
10COPY entry_script.sh /
11
12WORKDIR ${LAMBDA_TASK_ROOT}
13
14RUN  pip install awslambdaric --target ${LAMBDA_TASK_ROOT} --no-cache-dir && \
15     pip install -r requirements.txt --target "${LAMBDA_TASK_ROOT}" --no-cache-dir
16
17ENTRYPOINT [ "/entry_script.sh" ]

为了保持更清晰的结构,没有把上面的多个 RUN 用 && 合并,所以会产生更多的 Docker 层。

这样打出来的包既能用
$ docker run -it -p 9080:8080 lambda-docker
启动来进行本地测试,本地测试时控制台出现
IndexError: list index out of range
WARN[0009] First fatal error stored in appctx: Runtime.ExitError
WARN[0009] Process 14(python) exited: Runtime exited with error: exit status 1
ERRO[0009] Init failed InvokeID= error="Runtime exited with error: exit status 1"
WARN[0009] Reset initiated: ReserveFail
WARN[0009] Cannot list external agents error="open /opt/extensions: no such file or directory"

无须担心,不会影响到 Lambda 的执行。

而且这个 Docker 镜像部署到 AWS 上也是可执行的。

链接:

  1. Testing Lambda container images locally
  2. Deploy Python Lambda functions with container images
  3. Creating Lambda container images
永久链接 https://yanbin.blog/build-aws-lambda-python-docker-image/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。