[2024年5月暫定版]ryeでパッケージ管理しているPython Lambda FunctionをAWS CDKでデプロイする
boto3など以外のサードパーティーライブラリを含むPythonのLambdaのデプロイはそこそこ面倒である。
cdkであれば、experimental扱いであるが、
というモジュールが存在し、2024年5月現在ではPipenvおよびPoetry:
を使ってパッケージ管理されているLambdaはよしなにデプロイまでやってくれる。
ただ、最近は更に後発のパッケージ管理ツールとしてrye:
が出てきている。現在進行形で活発に開発がなされており、逆に言うと安定しきっているとは言えないかもしれないが、
後発な分機能は洗練されており非常に使い心地が良い。
こちらを使いたい場合、先述のaws-lambda-python-alpha
ではサポートされていないので、cdkにおけるビルド・デプロイ方法は自分で考える必要があり、やってみたというメモ。
雑な結論
改善点は残っているが、一応作ってみたものを置いておく。
cdk部分は↓
深い意味は無いが、1つのLambdaにライブラリを全部含めるものと、
ついででサードパーティライブラリをLambda Layerに分離する構成のものと2パターンを作っている。
詳細部分は後述するとして、
要点としてはcdkのlambda.Code.fromDockerBuild
:
を使うことにより、Dockerfileを書けば非常に高い自由度でLambdaもしくはLambda Layerの内容をカスタマイズできる、というもの。
Lambdaのある程度の仕様の理解とDockerfileを書く技術があればPythonやryeに限らずあらゆる構成でLambdaを作ることができ、
その一環としてryeを使う場合もカバー出来る、という感じ。
ここで、ryeのlockファイルからLambdaおよびLambda Layerのアセットを作成するにあたり、
ryeのlockファイルがほぼ普通の(いわゆる)requirements.txt
の形式になっていることを利用し、Dockerfile内で一部編集を加えた上でpipコマンドで所定のパスにライブラリを配置することでLambda内でのPathに含まれるようにしている。
解説など
1. Layerなどを使わない単一のLambdaでやる場合
シンプルな方。
注意点としては
などにあるように、2024年5月現在では各関数およびLayerに関してデプロイパケージサイズ:
- 圧縮済みで50MB以下
- 解凍後で250MB以下
の制約条件に収まる必要がある。
もしパッケージサイズが上限を超えそうであればLayerなどに分割していくなど工夫する必要がありそう。
とりあえずcdkで記載したLambdaの記載部分は↓のような感じ:
import * as cdk from 'aws-cdk-lib';
import { aws_lambda as lambda } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
// 他、適当にimportなど
export class CdkAppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
new lambda.Function(this, 'python-lambda', {
functionName: 'hello-world-function', // 名前などは適当に変える
runtime: lambda.Runtime.PYTHON_3_12, // 実際使うバージョンに合わせて適当に変える
handler: 'index.handler', // Lambdaのファイル名とハンドラーに使っている関数名に合わせて変える(ここではindex.pyのhandlerを使用)
code: lambda.Code.fromDockerBuild( // ★lambda.Code.fromDockerBuildにより、Lambdaにデプロイする内容をDockerfileで記述する
path.join(__dirname, '../python-lambda/hello-world'), // Dockerfileが置いてあるパスを指定
{ file: 'Dockerfile' },
),
architecture: lambda.Architecture.X86_64,
// (その他オプションを適宜追加)
});
// (中略)
}
}
上記のように、Lambdaの内容をDockerfileで作成するようにしている。
Dockerfileの置き場所は↓
ryeによりプロジェクト作成・管理がされており、大体以下のようなファイル構成になっている:
tree -a --gitignore
.
├── Dockerfile
├── .dockerignore
├── .gitignore
├── index.py # Lambdaのエントリーポイント
├── lib # 自作のライブラリやモジュールなど
├── pyproject.toml
├── .python-version
├── requirements-dev.lock # dev-dependenciesのみに入っているパッケージはLambdaに入れないようにする
└── requirements.lock # ★これを使ってDockerfile内でライブラリをインストール・配置する
Lambdaの内容はナンセンスな適当なものだが、とりあえずpydanticで適当なスキーマをデシリアライズしたものをレスポンスで返すようにしている。
Dockerfileの内容は以下:
FROM docker.io/library/python:3.12-slim
# ソースコードなどを/asset以下に配置する(不要なものは.dockerignoreで含まれないようにしておく)
COPY ./ /asset/
WORKDIR /asset
# ref: https://github.com/astral-sh/rye/discussions/239
RUN sed '/^-e/d' /asset/requirements.lock > /asset/requirements.txt && \
pip install -r /asset/requirements.txt --no-cache-dir -t /asset && \
rm /asset/requirements* && \
rm -rf __pycache__
lambda.Code.fromDockerBuild
において、
デフォルトでは作成したイメージの/asset
以下にあるファイル・ディレクトリが抽出される。参考:
By default, the asset is expected to be located at
/asset
in the image.
というわけで、冒頭のCOPY ./ /asset/
でソースコード一式を/asset
以下に配置している。
このとき余計なものが入らないように.dockerignore
などを書いておく。
内容はケースバイケースな部分もあるが、今回は↓のような感じ:
*.pyc
*.pyo
.venv
Dockerfile
# EDIT below
README.md
pyproject.toml
requirements-dev.lock
次に、これでは肝心のサードパーティライブラリが/asset
に含まれていないので追加していく。
ここで、ryeによって作成されるlockfileであるrequirements.lock
は例えば以下のような内容:
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: false
# with-sources: false
-e file:.
annotated-types==0.6.0
# via pydantic
pydantic==2.6.4
# via hello-world
pydantic-core==2.16.3
# via pydantic
typing-extensions==4.10.0
# via pydantic
# via pydantic-core
ここで、-e file:.
部分が不要だが、そこを除けばRequirements File Format:
になっている。そのため、requirements.lock
ファイルを少しだけ手を加えて(ここではsed
コマンドによるワンライナー)requirements file formatに直し、pipコマンドを使ってインストールを行っている。
ライブラリはパスが通っているところに置く必要があるが、
によると、
Lambda でコードを実行するには、ハンドラーコードとすべての関数の依存関係を含む.py ファイルを.zip ファイルのルートにインストールする必要があります。
(...)
.zip ファイルは、次のように関数のハンドラーコードとすべての依存関係フォルダがルートにインストールされた、フラットなディレクトリ構造である必要があります。
などと記載があり、要はプロジェクトのルートの階層に置けばいいので、pipコマンドのオプションを調整して/asset
直下にインストールするようにしている。
(ここで、pipコマンドを使える必要があるため、LambdaのPythonランタイムと同じバージョンのPythonが使えるベースイメージ: python:3.12-slim
を使っている)
動作確認は、例えばsam:
を使って、
cdk synth
# -tで指定しているテンプレート名および最後の引数の関数名は適宜変更する
sam local invoke -t cdk.out/CdkAppStack.template.json python-lambda
などとすると実行できる。
cdk.out
以下のディレクトリを漁るとAWSにデプロイされる関数のアセット内容なども入っているので確認してみると良いかもしれない。
2. Layerにパッケージを分割する方法
- までで書くことは大体書いているが、一応補足しておく。
cdkコード部分の抜粋は以下:
import * as cdk from 'aws-cdk-lib';
import { aws_lambda as lambda } from 'aws-cdk-lib';
import type { Construct } from 'constructs';
// その他importなど
export class CdkAppStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// (中略)
// Lambda Layer(pythonのサードパーティライブラリを入れる)
const dependenciesLayer = new lambda.LayerVersion(
this,
'LambdaLayer', // 名前などは適当に変える
{
layerVersionName: 'python-dependencies-layer-example', // 名前などは適当に変える
code: lambda.Code.fromDockerBuild( // 関数同様、Layerもlambda.Code.fromDockerBuildで作成できる
path.join(__dirname, '../python-lambda/hello-world-with-layer'),
{
file: 'dependencies-layer.Dockerfile',
},
),
compatibleRuntimes: [lambda.Runtime.PYTHON_3_12],
compatibleArchitectures: [lambda.Architecture.X86_64],
},
);
// ↑で作ったLayerを組み合わせる
new lambda.Function(this, 'python-lambda-with-layer', {
functionName: 'hello-world-function-with-layer',
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'index.handler',
code: lambda.Code.fromAsset( // Lambdaの内容として、特定のディレクトリの内容を指定できる
path.join(__dirname, '../python-lambda/hello-world-with-layer'),
{ // ↓Lambdaに含めないものを指定して、余計なものがアップロードされないようにする
ignoreMode: cdk.IgnoreMode.GIT,
exclude: [
'*.Dockerfile',
'*.md',
'requirements*',
'pyproject.toml',
'.venv',
'.gitignore',
],
},
),
layers: [dependenciesLayer], // 作成したLambda Layerを指定している
architecture: lambda.Architecture.X86_64,
// その他オプションを適宜追加
});
}
}
参照されているLambdaのプロジェクトは↓:
関数本体はlambda.Code.fromAsset
:
を使ってローカルのディレクトリの内容を元に作成しており、AssetOptions:
によって不要なファイルが入らないようにしている。
PythonのライブラリはLayerの方のlambda.Code.fromDockerBuild
によって作成している。
FROM docker.io/library/python:3.12-slim
COPY requirements.lock /workspace/
WORKDIR /workspace
# ref:
RUN sed '/^-e/d' /workspace/requirements.lock > /workspace/requirements.txt && \
mkdir /workspace/tmp && \
pip install -r /workspace/requirements.txt --no-cache-dir --prefix=/workspace/tmp && \
rm -rf /workspace/tmp/bin && \
mkdir -p /asset/python && \
mv /workspace/tmp/lib -t /asset/python
sed
を使ったワンライナーによってrequirements.lock
を編集しているのは先ほどと同様である。
ここで、
参照すると、
関数にレイヤーを追加すると、Lambda はレイヤーのコンテンツをその実行環境の
/opt
ディレクトリに読み込みます。Lambda ランタイムごとに、PATH 変数には/opt
ディレクトリ内の特定のフォルダパスがあらかじめ含まれます。PATH 変数がレイヤーコンテンツを取得できるようにするには、レイヤーの .zip ファイルの依存関係が次のフォルダーパスにある必要があります。
python
-
python/lib/python3.x/site-packages
- (xはpython3のマイナーバージョン)
とのことなので、Layerを使ってライブラリを含める場合はPython3.12の場合、Dockerfileで
/asset/python
/asset/python/lib/python3.12/site-packages
のいずれかにライブラリが配置するように出来れば良いことがわかる。
後者はPythonのバージョンを変更するときに一緒に変えないといけなくなりメンテナンス性が微妙なので、今回は前者を採用している。
今回はpip install
で/workspace/tmp
に一旦インストールしておいて、不要そうなファイルやディレクトリを削除した上で/asset/python
に置き直している。
こちらも動作確認ではsam localなどを使って
cdk synth
# -tで指定しているテンプレート名および最後の引数の関数名は適宜変更する
sam local invoke -t cdk.out/CdkAppStack.template.json python-lambda-with-layer
などとするとローカルでlambdaが動作することを確認できる。
(内容に特に意味はないので割愛していたが、こちらのLambdaではサードパーティライブラリとしてhttpリクエストを投げるためにrequests
を入れており、 http://checkip.amazonaws.com にアクセスしてIPアドレスを返す)
なお、ここでは雑にパッケージを単一のLayerに全て突っ込んでいるため、
全章で書いたような関数・レイヤー毎のサイズ上限のクォータの問題を直接解決は出来ていないが、参考にはなると思うので一応記載した。
改善点など
boto3をどうするか問題
boto3を使う場合、こちらはPythonのLambdaではデフォルトで含まれているため、
特定のバージョンを指定して使いたい場合を除けば自身でアップロードする必要はない。(容量の無駄になる)
だが、boto3の依存ライブラリまで含めて考えるとrequirements.lock
からうまく選んで除去するのは結構難しい。
boto3はdev-dependenciesに入れて管理するという方法も考えられるが、ソースコードでimportして使うライブラリをdev-dependenciesに入れないといけないのはちょっと気持ち悪い気もする。。。
ランタイムのバージョン管理
現状、PythonのLambdaのランタイムバージョンはcdkのコードで指定している部分に加えて、アセットを作るためのDockerfileやryeのプロジェクト中の.python-version
ファイルなどに分散して記載されている。
バージョンを変更する場合、これらを全て整合が取れるように手で変えないといけない。
(ただ、この辺の辛さは@aws-cdk/aws-lambda-python-alpha
を使ってもほとんど変わらない問題なような気もする)
その他参考
Discussion