はじめに
エンジニアバイト中に、AWS の Lambda を触っていました。
Python で画像処理のため、OpenCV というパッケージが必要でした。
OpenCV のパッケージと、依存している numpy パッケージは Python 標準のパッケージではない(外部パッケージ)ので、pip install
などでのパッケージインストールが必要です。
Lambda ではpip install
などのCLIでのパッケージ導入はできません。そこで、外部パッケージ用いる際には、パッケージを zip ファイルに固めて S3 にアップロードしたり、パッケージの zip ファイルを直接レイヤーに追加する必要があります。
OpenCV, numpy を zip 化し、いざレイヤーに登録しようとすると、以下のエラーが発生しました。
Layers consume more than the available size of 262144000 bytes
以下 AWS 公式のドキュメント。
表の 「デプロイパッケージ (.zip ファイルアーカイブ) のサイズ」の箇所には、レイヤーに登録するパッケージのサイズ上限について記載されています。
- zipファイル圧縮前でサイズが250MB以内
- zipファイル圧縮後でサイズが50MB以内
これら2つの制約を突破しなければいけません。先ほど記したエラーは、1つ目の圧縮前250MBの制限に引っかかっているという内容です。
また、レイヤーの数にも5つ以内という条件があります。
上のドキュメントでは、レイヤーの仕様として、デプロイパッケージの軽量化を挙げています。このことから、「Lambda で動かすプログラムは軽量であるべきだ」という Lambda の思想が伺えます。
とはいえ、Lambda で使いたい。EC2 で仮想サーバーを立てることで、pip install
などのコマンドも使えますし、レイヤーの制限もないのでいいのですが、コスト等を考慮すると、使用頻度によっては Lambda の方が適切である場合があります。今回はそのパターンでした。
そこで、レイヤーの上限を突破しつつ Lambda を使う方法がないか模索したところ、ECR (Elastic Container Registry) を用いる方法にたどり着きました。
本記事では、Lambda × ECR での画像処理について記述いたします。入力画像については、S3 の特定バケットにあるオブジェクトを、Lambda 側で一時ディレクトリにダウンロードして用意します。
環境
macOS Sequoia 15.0.1
aws-cli 2.18.10
Python 3.12
ECR の利用にあたり、AWS CLI が必須になります。IAMポリシーでの権限不足によるエラーにご注意を。
Python は Docker イメージで指定したバージョン。
ECR とは
まず、ECR (Elastic Container Registry) がどういうものかについて説明します。
ECR は、ソースコードと Dockerfile で構成したコンテナイメージをクラウド上に保存できるサービスです。環境変数、使用言語(のイメージ)など、Dockerfile に書き込んだ内容でイメージを作成できるので、イメージプッシュ時に外部パッケージのインストールもできます。
AWS の他サービスとも連携可能で、今回は Lambda と連携します。レイヤーの制限は Docker イメージのサイズには適用されません。
Lambda に登録するレイヤー用に外部パッケージの zip ファイル を用意する手間が必要ない、Lambda 以外のサービスでも使い回せるなどの利点があります。
コスト
ECR のコストについては、ほとんどがイメージサイズによるものです。
S3 など、他サービスとのデータ転送にもコストがかかりますが、微々たるものなので今回は省略。
イメージサイズに依存してかかるコストについては、リージョンにもよりますが、東京 (ap-northeast1) リージョンでは、2024年10月現在、0.10 USD/GB/月 でした。
ちなみに、今回私が作成したイメージのサイズは 500MB でした。
0.5GB × 1環境 × 1ヶ月 × 0.10 USD/GB/月 = 0.05USD = 7.5円 / 月 (1USD = 150円)
非常に安い。計算ミスを疑う。本当に間違っていたらごめんなさい。
S3 とは
S3 (Simple Storage Service) は、AWS におけるストレージサービスであり、AWS の他サービスとの連携が便利です。
今回は画像処理ということで、処理対象となる入力画像が必要です。また、場合によっては出力をファイルとして保存したい場合にも使えます。
導入まで
以下の順で進めていきます。
- AWS CLI の設定
- AWS CLI でのアカウント設定
- ローカルでの Docker イメージ作成
- Docker イメージの push
- Lambda 関数の作成
AWS CLI の設定
Docker イメージを AWS 上にアップロードするにあたり、CLI ツールが必要です。
AWS CLI のインストール
macOS では、Homebrew を用いてインストールするのが最も簡単でしょう。
brew install awscli
Windows, Linux をお使いの方は、以下の公式ドキュメントからインストールしましょう。macOS 版も載っているので、この記事で詰まった箇所がありましたら、こちらを参照ください。
インストールが終わったら、バージョン確認のコマンドで確認しましょう。
aws --version
以下のように反応があればインストール成功。
表示内容はそれぞれの環境によって変化すると思うので、まるまる同じじゃなくても大丈夫。
aws --version
aws-cli/2.18.10 Python/3.12.7 Darwin/24.0.0 source/arm64
AWS CLI でのアカウント設定
本記事でも簡単に解説いたしますが、すでに Qiita に AWS のアカウント設定を詳しく書いてくださっている方がいらっしゃいました。詳細はそちらの記事を参考にしてください。
セキュリティ認証情報 のページから、アクセスキーの作成をしましょう。
作成したら、アクセスキーIDとシークレットアクセスキーが表示されるので、保存しましょう。作成後、この画面を最後に再び見ることができません。
CSVをダウンロードできるので、それを保管するとよいでしょう。
アクセスキーの作成が終わったら、CLI でのアカウント設定に移ります。以下のコマンドを入力しましょう。
aws configure
コマンド入力後、上から順に各情報の入力を求められます。
上からアクセスキー、シークレットアクセスキー、リージョン、認証情報の出力形式を指します。
aws configure
AWS Access Key ID [None]: アクセスキー ID
AWS Secret Access Key [None]: シークレットアクセスキー
Default region name [None]: ap-northeast-1
Default output format [None]:
設定が終わったら、以下のコマンドで設定できているかを確認しましょう。
aws configure list
Value, Type, Location などに何かしらの値が入っていれば成功です。
ローカルでの Docker イメージ作成
ここからは、ECR に上げるイメージを作成していきます。
ベースイメージ (FROM句に書くもの) は、AWS が用意してくれている Python のイメージがあるので、それを使います。
Ubuntu のベースイメージに Python をダウンロードしてもよいと思いますが、それだと Ubuntu の分イメージサイズが大きくなってしまいます。イメージサイズが大きくなると、かかる費用が上がります。微々たる差ではありますが。
ディレクトリ構成
最終的なディレクトリ構成は、私はこのようになりました。tree -a
コマンドで出力。
Lambda で実行するファイルを lambda_function.py 、lambda_function.py でインポートする、別ファイルとして分割したファイルを partA.py, partB.py, partC.py としています。
.
├── .env
├── Dockerfile
├── docker-compose.yaml
├── invoke_lambda.sh
├── lambda_function.py
├── partA.py
├── partB.py
├── partC.py
└── requirements.txt
最初に、プロジェクトのディレクトリを作成しましょう。
mkdir lambda-ecr-project
cd lambda-ecr-project
Dockerfile
公式ドキュメントにテンプレートが載っているので、基本はそちらを参考に組みつつ、一部変更や OpenCV 導入にあたり必要なパッケージを追加しました。
今回私が作成した Dockerfile は以下のようになりました。
FROM public.ecr.aws/lambda/python:3.12
# Copy requirements.txt
COPY requirements.txt ${LAMBDA_TASK_ROOT}
# Install the specified packages
RUN pip install -r requirements.txt
# yum, apt が使えないので、dnfで代用
RUN dnf update
# OpenCV ライブラリの環境に必要
RUN dnf install -y mesa-libGL mesa-libGL-devel
# Copy function code
# lambda_function で import 文を書いている別ファイルもコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}
COPY partA.py ${LAMBDA_TASK_ROOT}
COPY partB.py ${LAMBDA_TASK_ROOT}
COPY partC.py ${LAMBDA_TASK_ROOT}
COPY .env ${LAMBDA_TASK_ROOT}
# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "lambda_function.lambda_handler" ]
いくつかに分けて解説いたします。
FROM public.ecr.aws/lambda/python:3.12
ここは Docker イメージのベースイメージを何にするかですね。OpenCV を使いますし、Python で。先述の通り、Lambda 側で用意してくれているものを使います。
# Copy requirements.txt
COPY requirements.txt ${LAMBDA_TASK_ROOT}
# Install the specified packages
RUN pip install -r requirements.txt
ここは、必要な外部パッケージをインストールするところです。
pip install
コマンドでインストールするパッケージを、requirements.txt というファイルにまとめると、Docker イメージ構築時にインストールしてくれます。
私が書いた requirements.txt はこちら。後ろに = をつければ、バージョンの指定も可能です。
opencv-python
opencv-contrib-python
numpy
pdf2image
各々、必要なパッケージの追記や不要なパッケージの削除をするとよいでしょう。numpy は OpenCV のインストール時に同時にインストールしてくれますが、一応書いています。
# yum, apt が使えないので、dnfで代用
RUN dnf update
# OpenCV ライブラリの環境に必要
RUN dnf install -y mesa-libGL mesa-libGL-devel
ここは、opencv_contrib という OpenCV の拡張モジュール郡使用のため、mesa-libGL, mesa-libGL-devel のパッケージが必要です。
apt install
, yum install
コマンドでインストールできるものですが、それら2つは Lambda が準備してくれている Python ベースイメージでは使えなかったので、dnf というものでインストールしています。
また、opencv-contrib は BSDライセンス に該当します。商用利用できますが、少し注意が必要なものです。
ライセンスについては、詳しく解説してくださっている記事があったので、こちらを参照ください。とはいえ、特許系はシビアですので、各自確認しておきましょう。
SIFT, SURF などの一部アルゴリズムを利用するモジュールは商用利用できませんでしたが、少し前に特許が切れたらしく、商用利用できるようになりました。
モジュールによってライセンスが異なるものもあるので、各自 opencv-contrib を用いる場合は特に、自分が使うモジュールのライセンスについて調査しましょう。
# Copy function code
# lambda_function で import 文を書いている別ファイルもコピーする
COPY lambda_function.py ${LAMBDA_TASK_ROOT}
COPY partA.py ${LAMBDA_TASK_ROOT}
COPY partB.py ${LAMBDA_TASK_ROOT}
COPY partC.py ${LAMBDA_TASK_ROOT}
COPY .env ${LAMBDA_TASK_ROOT}
ここは Lambda で実行する処理をまとめているところです。
先述の通り、イメージにはソースコードを含むので、イメージ構築時、処理に必要なコードをコピーしておく必要があります。
lambda_function.py で、partA.py, partB.py, partC.py の関数をインポートしている場合は、それらも同じくコピーが必要です。
同様に、環境変数を用いる場合は .env ファイルを作成し、それをコピーする必要があります。
ここで定義した環境変数は、Lambda 関数作成後に Lambda の方に別で書く必要があるので注意。
# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "lambda_function.lambda_handler" ]
ここは Lambda で実行する処理を定めています。今回は、lambda_function.py の、lambda_handler 関数を実行する、となります。ここは各自のファイル名や関数名によって変化する場合があります。
docker-compose.yaml
ここまでできたら、公式ドキュメントに沿ってイメージのビルドと実行、 curl のコマンドで動作確認できます。
イメージをビルドするコマンド。
docker build --platform linux/amd64 -t docker-image:test .
ビルドしたイメージを立ち上げるコマンド。
docker run --platform linux/amd64 -p 9000:8080 --read-only docker-image:test
curl で動作確認するコマンド。
curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
これらをひたすら、コードを変える度に実行するのは結構手間です。docker run
せずに curl コマンド実行してエラー吐くとか。
そこで、docker-compose.yaml というファイルでビルドと起動を同時に行い、curl コマンドをシェルファイルに書き込み、それを実行することで、コマンドを単純化できます。
docker-comose.yaml ファイルの内容は、こちらの記事が非常に参考になり、ほぼ同じ内容で動作したので、そちらを使わせてもらいます。
services:
compress-image:
container_name: compress-image
build: .
volumes:
- $HOME/.aws/:/root/.aws/
ports:
- "9000:8080"
env_file:
- .env
version: のところだけ削除。現在は非推奨になったためです。そのまま実行すると警告文が出ますが、特に動かないこともないです。対処法も、ただ消すだけでOKです。
本当は docker-compose.yaml というファイル名も、compose.yaml が推奨されているのですが、互換性があるようですし、警告等も出ないのでこちらで進めます。
yaml ファイル作成後は、以下のコマンドでビルドと起動を同時に行います。
docker-compose up --build
なお、ビルド時に Python のファイルをコピーしているので、Python ファイルの中身を変えた場合は、リビルドが必要です。Ctrl + C で終了し、再び同じコマンドを実行し、ビルドしなおしましょう。
curl コマンドを実行するシェルスクリプト
次は、シェルスクリプトを作成します。内容はコマンドそのままなので、引数複雑にしたりしないならそのままコマンドを履歴遡って入力するのもよし。
#!/bin/bash
curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
シェルスクリプトを作成したら、実行権限の付与を忘れずに。
chmod +x invoke_lambda.sh
以降は、このコマンドだけで実行できます。
./invoke_lambda.sh
lambda_function.py
プログラムの中身に入ります。私が作成したプログラムは公開できないものなので、サンプルのものを代わりに。
分割したファイル partA.py, partB.py, partC.py の処理についても、サンプルなので省略。
import os
import urllib.parse
import boto3
import cv2
# S3 へアクセスするオブジェクトを作成
s3 = boto3.client("s3")
# 環境変数の定義
BUCKET_NAME = os.environ["BUCKET_NAME"] # バケット名
INPUT_IMG_DIR = os.environ["INPUT_IMG_DIR"] # 入力画像のディレクトリ
OUTPUT_IMG_DIR = os.environ["OUTPUT_IMG_DIR"] # 出力画像のディレクトリ
def lambda_handler(event, context):
try:
bucket = event["Records"][0]["s3"]["bucket"]["name"]
key = urllib.parse.unquote_plus(event["Records"][0]["s3"]["object"]["key"])
# ファイルをS3から一時ファイルにダウンロード
file_name = f"/tmp/{os.path.basename(key)}"
s3.download_file(Bucket=bucket, Key=key, Filename=file_name)
# ファイルが存在しない場合の例外処理
if not os.path.isfile(file_name):
raise FileNotFoundError(f"The file '{file_name}' does not exist.")
except Exception as e:
print(f"Error: {e}")
return {"statusCode": 400, "body": f"Error occurred: {e}"}
# 画像読み込み
img_original = cv2.imread(file_name)
# BGR -> グレースケール
img_gray = cv2.cvtColor(img_original, cv2.COLOR_BGR2GRAY)
# 二値化処理、グレースケール -> 白黒
retval, img_bw = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 二値画像を一時ディレクトリに書き込み
binarized_file_name = f'/tmp/binarized_{os.path.basename(key)}'
cv2.imwrite(binarized_file_name, img_bw)
result_key = f'binary_{key}'
s3.upload_file(binary_tmp_file.name, bucket_name, result_key)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": "Image processed",
}
S3 の特定バケットにある画像のオブジェクトを対象に、画像処理を行い、処理後の画像を S3 にアップロードする処理です。
画像処理に関する処理は本題ではないので、コメントによる補足をもって解説とします。
# S3 へアクセスするオブジェクトを作成
s3 = boto3.client("s3")
# 環境変数の定義
BUCKET_NAME = os.environ["BUCKET_NAME"] # バケット名
INPUT_IMG_DIR = os.environ["INPUT_IMG_DIR"] # 入力画像のディレクトリ
OUTPUT_IMG_DIR = os.environ["OUTPUT_IMG_DIR"] # 出力画像のディレクトリ
S3オブジェクトを作成し、環境変数を定義しています。
ここで、 os.envirion の形で .env ファイルから環境変数を読み込んでいるのですが、ECR へプッシュするだけでは環境変数は使えないようです。
Lambda 側で再定義する必要があります。
def lambda_handler(event, context):
try:
bucket = event["Records"][0]["s3"]["bucket"]["name"]
key = urllib.parse.unquote_plus(event["Records"][0]["s3"]["object"]["key"])
# ファイルをS3から一時ファイルにダウンロード
file_name = f"/tmp/{os.path.basename(key)}"
s3.download_file(Bucket=bucket, Key=key, Filename=file_name)
変数 bucket には、event オブジェクトのバケット名を指定する箇所からバケット名が入ります。
変数 key も同様に、event オブジェクトからキーを取得しています。urllib.parse.unquote_plus 関数により、URLデコードしたキーを key に代入しています。
S3 にある画像を Lambda で処理するためには、画像のオブジェクトをダウンロードする必要があります。
一時ファイルのパスを定義し、S3 からバケット名、キー、ファイル名を指定してダウンロードしています。
以降はエラーハンドリングと、画像処理の処理内容が続きます。
AWS での実装
ここからは AWS での実装に移ります。
ECR のイメージプッシュ
まずは、Elastic Container Registry のプライベートリポジトリを開きましょう。パブリックリポジトリだと Lambda でイメージを選択できなかったため注意。
リポジトリ作成にあたり、設定することは特にありません。強いて言えば、一番上のリポジトリ名くらいです。他はそのままで大丈夫。
リポジトリの作成が終わったら、ECR 側ですることはほぼ終わったも同然です。
作成したリポジトリを開くと、イメージが表示されます。ここで、「プッシュコマンドを表示」を押すと、モーダルが開かれ、イメージをプッシュするまでに必要なコマンドが全て表示されます。
詳細なコマンドの内容としては、以下のようになります。
AWS CLI を用いた認証トークンの取得とレジストリへの Docker クライアントの認証
<region> には、ap-northeast-1 など、<private_registry_url> には、プライベートリポジトリのURL (https://.dkr.ecr..amazonaws.com) が入ります。
aws ecr get-login-password --region <region> | docker login --username AWS --password-stdin <private_registry_url>
Docker イメージの構築
<private_repository_name> は、プライベートリポジトリの名前が入ります。
docker build -t <private_repository_name> .
イメージへのタグ付け
<tag> はイメージに紐づけるタグ。
イメージはバージョン管理できるため、プッシュしたイメージにタグをつけることでどのイメージがどのバージョンだったかを後で特定することができます。例では latest となっており、2回目も同じタグをつけてプッシュすると、前のイメージのタグは - と設定され、最も新しいものにそのタグがつく仕様になっています。
docker tag <private_repository_name>:<tag> <private_registry_url>/<private_repository_name>:<tag>
イメージのプッシュ
docker push <private_registry_url>/<private_repository_name>:<tag>
コマンド4つを実行後、イメージの一覧画面に戻り、イメージがプッシュされているかどうかを確認しましょう。なければページをリロード。それでもなければどこかでコマンドを間違えていたり、順番が違ってエラーが起こっている可能性があります。
記事に書いてはいるものの、基本は「プッシュコマンドの表示」で出てきたコマンドをコピペするだけでいいはずです。
S3 のバケット作成
次は、入力画像と出力画像を保存するバケットを作成します。サービスから S3 を選択すると、以下の画面に遷移するので、「バケットの作成」ボタンでバケットを作成しましょう。
ボタンを押すと、色々と設定する箇所が出ますが、入力する項目はバケットの名前だけでよいです。他に書くこともないので画像は省略します。
注意点としては、バケット名やバケットの下に作るディレクトリ名について、コード側との整合性をとることくらいでしょうか。
Lambda の関数実装
最後に、Lambda の関数を実装します。とはいえ、実装と言うほどすることはありません。
ECR にイメージをプッシュする際に、ソースコードもコピーしているため、Lambda 側でコードをいじることはしません。逆に言えば、前述した通り、コードを更新する度にイメージのプッシュが必要です。
まずは、サービスの中から Lambda を選択しましょう。「関数を作成」というボタンを押して、関数を作成します。
以下の画面に遷移すると、オプションをラジオボタンで選択できます。ここでは、一番右のコンテナイメージを選択しましょう。
関数名を書いたら、コンテナイメージURIを入力します。「イメージを参照」を押すと、イメージのURIをプルダウンメニューで選択できるため、入力する手間が省けて楽です。
ここでの URI は URL とは微妙に違います。定義についての説明は省きますが、イメージの URL と書いた箇所では、<private_registry_url> を、URI では <private_registry_url>/<private_repository_name>:<tag> を指しています。
また、ここは各々の環境の違いがあるかもしれませんが、アーキテクチャは arm64 を選びましょう。
私の場合は x86_64 では動きませんでした。個人の環境に依存している可能性があるので、うまくいかなかったら逆の方を選択して関数を作り直す、くらいでいきましょう。
最後のロールの設定については、これも個人の環境に大きく依存するので省略。
その下の「関数を作成」ボタンを押すと、Lambda の関数作成が終わります。
さて、実はここで終わりではなく、環境変数の設定が残っています。私は ECR でイメージに .env ファイルもコピーしてプッシュしたのですが、どうやら Lambda 側では認識してくれないらしかったので、個別に設定が必要らしいです。
作成した関数を選択し、「設定」から「環境変数」へタブを移動して、「設定」のボタンを押すと、環境変数を定義できます。
以下の画面に遷移した後、「環境変数の追加」ボタンで環境変数を追加しましょう。注意点としては、ここでもコード側との整合性をとることくらいです。
ここまで終わると、晴れて実装完了です。お疲れ様でした。
おわりに
ここまでの閲覧ありがとうございました。
自分の間隔としてはかなり長い記事となってしまいましたが、ご容赦ください。
画像を入れたり、なるべくわかりやすくしたつもりです。
途中の画像処理をするプログラムについては、各々のコードに変えて、都度必要なサービスを組み合わせてください。
また、就活中にたまたま、とあるクラウド系の仕事を専門としている方とお話する機会をいただいたので、「AWS と Google Cloud どう違いますか、どっち派ですか」と聞いたところ、「使い分けた方がいいのでどちらが一概に優れているというわけではない」との回答をいただきました。
どうやら、それぞれをおもちゃに例えると、AWS はレゴで、Google Cloud は人形らしいです。他サービスとのかみ合わせという意味では AWS が優れているものの、簡単にある程度の形のものを作れるという意味では Google Cloud が優れているらしいです。
最初の方で記した通り、やはり Lambda に全ての処理を任せるのではなく、他サービスと負荷を分散した方がよいようです。
AWS には Step Functions というサービスがあるらしく、複数の Lambda 関数などをワークフローとしてつなげられるようです。
画像処理にプラスしてなにかをしたいという方は、そのサービスや他サービスの利用・連携を考慮するとよいかもしれません。