コンテナを使ったLambda(Go)でImageMagick(HEIFあり)を使った画像変換をする with SAM
オンプレやVPS時代にちょっとしたインフラを構築してたけど、AWSは自分でしっかりと構築したことがなかった私です。
個人的に画像変換のLambdaを構築したら思いのほか大変だったので記録に残したいと思います。
最初に本題を記載しつつ、そこに至るまでの紆余曲折は余談として後半に残していく感じでいきますね。
前提
あまり気にする必要がないはずですが、一応プロジェクトの構成など前提を記載します。
- projectはモノレポでフロントコード以外全部入ってる
- lambdaはGoで記載している
- この構成で動くものにはなるが、安全で安定した構成かは筆者に判断するだけの知識がない
Lambda用のコンテナイメージを作成する
まずはImageMagickをインストールしたLambda用のイメージを作成します。
つまりDockerfile
を書いていきます。
実はDockerfile書くの初めてでした。
docker-composeしか書いたことなかったし、構築済みのイメージばっかり使ってたんですよねぇ。
では書いていきます。
########## 関数のビルドステージ ##########
FROM golang:1.22 as builder
COPY ./ /workspace/project
WORKDIR /workspace/project
ENV ARCH="amd64"
RUN go mod download && \
GOOS=linux GOARCH=${ARCH} CGO_ENABLED=0 go build -ldflags '-w -s' -trimpath -o /functions/imageresizer -tags lambda.norpc ./presentation/serverless/function/imageresize/main.go
########## ImageMagickのビルド/実行ステージ ##########
########## jpg,png,tiff,webp,heic対応 ##########
FROM public.ecr.aws/amazonlinux/amazonlinux:2023
# ImageMagickのビルド開始
RUN dnf update -y && \
dnf groupinstall -y "Development Tools" && \
dnf install -y \
wget \
tar \
gzip \
openssl-devel \
cmake \
libpng-devel \
libjpeg-turbo-devel \
libtiff-devel \
libwebp-devel \
pkgconfig \
ninja-build \
which
# Set working directory
WORKDIR /tmp
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig:$PKG_CONFIG_PATH
ENV LD_LIBRARY_PATH=/usr/local/lib:/usr/local/lib64:$LD_LIBRARY_PATH
# Build and install libde265 (HEVC decoder)
RUN wget https://github.com/strukturag/libde265/releases/download/v1.0.15/libde265-1.0.15.tar.gz && \
tar xvzf libde265-1.0.15.tar.gz && \
cd libde265-1.0.15 && \
mkdir build && \
cd build && \
cmake .. \
-GNinja && \
ninja && \
ninja install && \
cd ../..
# Build and install latest libheif (1.19.5) using CMake
RUN wget https://github.com/strukturag/libheif/archive/refs/tags/v1.19.5.tar.gz && \
tar xvzf v1.19.5.tar.gz && \
cd libheif-1.19.5 && \
mkdir build && \
cd build && \
cmake --preset=release .. \
-GNinja \
-DENABLE_MULTITHREADING=ON \
-DWITH_LIBDE265=ON && \
ninja && \
ninja install && \
cd ../..
# Download and install ImageMagick
RUN wget https://imagemagick.org/archive/ImageMagick.tar.gz && \
tar xvzf ImageMagick.tar.gz && \
cd ImageMagick-* && \
./configure --with-jpeg=yes \
--with-png=yes \
--with-heic=yes \
--with-webp=yes \
--with-tiff=yes && \
make -j$(nproc) && \
make install
COPY ./docker/functions/policy.xml /usr/local/etc/ImageMagick-7/policy.xml
# workdirを戻しつつビルドした関数をコピー
WORKDIR /
COPY /functions/imageresizer ./main
ENTRYPOINT [ "./main" ]
実行アーキテクチャにamd64
を指定していますが、Labmdaはarm64
のほうがお得なのでARCH
はarm64
がおすすめです。
私がamd64を指定してるのは、開発用マシンがamd64だからです。
arm64のEC2を立ち上げてそこでImageを作ったりしてECRのuriを指定するほうがコスト的に良いのかもしれません。
ImageMagickの最新版はapt
やdnf
では入れられないので、自前でmakeしています。
またHEIF/HEICは読み取れれば良くて、書き出す予定はないので書き出し用のライブラリは省いています。
書き出しも行いたい場合は別途書き出しに必要なlibもインストールしてからmakeしましょう。
これでコンテナイメージが用意できました。
policy.xml
途中で以下のようにpolicy.xml
というのをコピーしていますね。
COPY ./docker/functions/policy.xml /usr/local/etc/ImageMagick-7/policy.xml
ImageMagickは画像的な存在はなんでも読み込んで変換できてしまうのですが、必要ないものは取り込めないほうがセキュアです。
それを制御するためにこの設定ファイルがあるので、これも設定しておきましょう。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policymap [
<!ELEMENT policymap (policy)*>
<!ATTLIST policymap xmlns CDATA #FIXED "">
<!ELEMENT policy EMPTY>
<!ATTLIST policy xmlns CDATA #FIXED "">
<!ATTLIST policy domain NMTOKEN #REQUIRED>
<!ATTLIST policy name NMTOKEN #IMPLIED>
<!ATTLIST policy pattern CDATA #IMPLIED>
<!ATTLIST policy rights NMTOKEN #IMPLIED>
<!ATTLIST policy stealth NMTOKEN #IMPLIED>
<!ATTLIST policy value CDATA #IMPLIED>
]>
<policymap>
<policy domain="delegate" rights="none" pattern="*" />
<policy domain="filter" rights="none" pattern="*" />
<policy domain="coder" rights="none" pattern="*" />
<policy domain="coder" rights="read|write" pattern="{PNG,JPEG,JPG,TIFF,HEIC,WEBP}" />
</policymap>
ホワイトリスト方式で対応しています。
read|writeまとめて対応してるので少し雑ですが、まぁいいでしょう...
handler関数
いらないと思うんですが、一応実行されるlambdaのざっくりコードを記載します。
今回はEventBridgeEventで起動するので、それを受け取っています。
package main
...
type eventDetail struct {
Bucket struct {
Name string `json:"name"`
} `json:"bucket"`
Object struct {
Key string `json:"key"`
} `json:"object"`
}
func handler(ctx context.Context, event events.EventBridgeEvent) error {
...
var detail eventDetail
if err := json.Unmarshal(event.Detail, &detail); err != nil {
return errors.WithStack(err)
}
...
return nil
}
func main() {
lambda.Start(handler)
}
ここでの学び
余談っぽいものなので興味のない方は読み飛ばしもろて...
ざっくりとした理解を得ただけで細かいところまでは理解してないのですが、個人的にこう思ったみたいなのを記載しています。
Dockerはレイヤー構造
今までDockerはなんか知らんけど爆速なVMだと思っていました。それが正しくないのは理解しつつも、何が違うのかよく分かっていませんでした。
ですが、今回今更Dockerfileを書いて試行錯誤をしたことでDockerはレイヤー構造
なんだというのが理解できました。
Dockerfileの各行で実行されるコマンドによってレイヤーが追加されて、レイヤー単位で管理されるのでレイヤーごとにキャッシュされたり、構築失敗時にも失敗したレイヤーから次の構築が開始できるんですね。
またコマンド単位でレイヤーが構築されるためにRUN
コマンドで行う処理をやたらと&&
で繋いで記載するというのがわかりました。
一定の意味のある単位で一連の処理をレイヤー化するために、意味のある単位で全部のコマンドを繋ぎたかったんですねぇ。
Dockerfileのマルチステージビルドで容量削減
一つのDockerfileに複数のImage構築用の設定を書いて、必要な方だけを抽出して使える、みたいなものだと理解しました。
また同一ファイル内では相互のイメージデータをコピーして持ち込むこともできる。
というか、それのおかげでビルドした後の成果物だけを取り出してビルドにツールなどをImageかた取り除いて容量が圧縮できるという感じですね。
これもなんかDockerがレイヤー構造でVMとは違うのを感じました。
.dockerignoreというものでCOPYコマンドの対象から外すファイルを設定できる
.gitignoreと似たような文法でCOPY
コマンドの対象に含む・含まないを制御することが出来ます。
例えば以下のような感じです。
docker/
document/
bin/
.git
.aws-sam
!docker/functions/policy.xml
私の場合docker/以下にdocker-composeで利用しているDB関連のファイルがマウントされているため、それをexcludeしています。
ただdocker用のファイルであるpolicy.xmlはその中に入ってしまっているので!
記法で個別に対象に取っています。
.dockerignoreはファイルマッチでコツコツファイルをチェックしてるみたいなので、あまり細かくいっぱい書くとコピー自体の性能が結構劣化するようですので、細かい指定はせずにデッカイのだけ捨てるようにすると良いみたいですよ。
Dockerのビルドコンテキストは大事
DockerfileのCOPY
コマンドや.dockerignore
ファイルの配置場所はDockerfileのビルドコンテキストを基準にしています。
今回の僕のディレクト構成だとDockerfileはdocker/functions/Docerfile
に存在していますが.dockerignore
はプロジェクトルートに配置してあります。
これはCOPY
コマンドがコンテキストを基準に、ホスト側のディレクトリを遡れないことからそうなっています。
docker/functions/
がコンテキストになってしまうと、プログラムファイルをコピーできなくなっちゃうんですよね。
なので、以下のようにビルドして使うようにしています。
$ docker build -f docker/functions/Dockerfile -t lambda-container-test .
これをプロジェクトルートで実行することで.
がコンテキストになってプロジェクト全体がCOPY
できるようになります。
また.dockerignore
はコンテキストルートに存在することが必須のため、プロジェクトルートに配置することになるというわけですね。
地味にハマりました。
ImageMagickは脆弱性が多くて危ないは別にそうでもない
以下の記事詳細で参考になりました。
「さようなら ImageMagick」の考察
ImageMagickはそもそも歴史が長く巨大で、対応しているフォーマットが多すぎるので多少バグがあるのは当然だという感覚なんですけどね。
policy.xmlで対象に取るファイルを絞り込めば特に気にする必要はなさそうです。
package mainじゃないとmain関数は意図した動きをしない
当たり前すぎるんですが、モノレポで適当にフォルダを掘ってLabmda用のmain.go
を作っていたので、packageをmain以外にしてずっと悩んでいました。
なんどビルドしてもhandlerが起動できなくて、エラーもそれだとわかるものが出なくて全然わかりませんでした。
みなさんLambda関数を作るときにはpackageに気をつけてください
SAMのtemplateを作成してデプロイする
Imageを作るためのDockerfileが出来たので、それを使って実際にLambdaをデプロイ出来るようにしていきます。
template.yamlを書いていく
さっそく書いていきます。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
serverless functions
Parameters:
StartUpMode:
Type: String
Default: development
Globals:
Function:
Timeout: 5
MemorySize: 128
Resources:
ImageResizerFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: ImageResizer
PackageType: Image
Architectures:
- x86_64
Role: !GetAtt ImageResizerRole.Arn
Environment:
Variables:
STARTUP_MODE: !Ref StartUpMode
Metadata:
Dockerfile: ./docker/functions/Dockerfile
DockerContext: .
DockerTag: v1
ImageResizerRole:
Type: AWS::IAM::Role
Properties:
RoleName: ImageResizerRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: LambdaS3AndLogsAccessPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
Resource: "*"
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: "*"
ImageResizerEventRule:
Type: AWS::Events::Rule
Properties:
Name: ImageResizerEventRule
Description: Trigger image resize when a new image is uploaded to S3
State: ENABLED
EventPattern:
detail-type:
- Object Created
source:
- aws.s3
detail:
bucket:
name:
- your-bucket-name
object:
key:
- suffix:
equals-ignore-case: .jpeg
- suffix:
equals-ignore-case: .jpg
- suffix:
equals-ignore-case: .png
- suffix:
equals-ignore-case: .heic
Targets:
- Arn: !GetAtt ImageResizerFunction.Arn
Id: ImageResizerFunctionTarget
PermissionForEventsToInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !GetAtt ImageResizerFunction.Arn
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt ImageResizerEventRule.Arn
正直細かいことをコメントできるほど細かいとこがよくわかりません。
やってることはたぶん以下
- コンテナを利用するLambdaを定義
- S3のファイル参照・書き込みを行うため、IAMも一緒に作成&紐付け
- Lambda実行時などにCloudWatchのロググループ作成やログ出力があるため、その権限も付与・紐付け
- EventBridge経由でS3イベントからLabmdaを起動するためにEventRuleを作成
- 特定のバケットの特定のキーを持つものだけを対象にする
- Targetで今回作成するFunctionを指定して、起動対象を画像変換のLambdaにする
- EventBridgeからのLambda起動を許可するパーミッションを設定
まぁyamlに書いてあることそのままなんですが...
ちなみにおそらく足りてない設定として、lambdaで無限ループが起きてしまったときの検知と停止設定などがあると思われます。
samconfig.toml
参考程度ですが、samも置いておきます。
version = 0.1
[default.deploy.parameters]
stack_name = "your-stack-name"
resolve_s3 = true
s3_prefix = "your-stack-name"
region = "ap-northeast-1"
capabilities = "CAPABILITY_NAMED_IAM"
confirm_changeset = true
image_repositories = ["repository"]
[production.deploy.parameters]
stack_name = "your-stack-name"
resolve_s3 = true
s3_prefix = "your-stack-name"
region = "ap-northeast-1"
capabilities = "CAPABILITY_NAMED_IAM"
confirm_changeset = true
image_repositories = ["repository"]
parameter_overrides = [
"StartUpMode=production",
]
これで以下の感じでproduction設定でのデプロイが出来るようになりますね。
$ sam deploy --config-env production
この構成のちょっとイマイチだと感じているところ1
LambdaのMetadataにDockerfileを指定してDockerfileからImageを作成するようにしているのですが、ここがちょっと微妙だと思っています。
これlambda関数を修正したときにも毎回Dockerfileのビルドが走るのとデプロイのたびにImageに変更がなくても新しいイメージとしてファイルがアップロードされてしまうんですよね。
僕の使い方が変なのかもしれませんが、それだと面倒なので以下のどちらかにしたほうが使い勝手がいい気がします。
- localのイメージを紐付ける
- ECRのImageUriを指定する
ただどちらにしても、SAMだけでビルドやデプロイが完結しなくなるのがそれはそれでネックで...
この手の関数は一回運用に乗ったら変更することなんてほぼないので、気にせずこのままというのもアリかなぁとも思います。
この構成のちょっとイマイチだと感じているところ2
EventPatternの書き方がちょっと良くないと思っています。
s3にファイルを配置するときにmeta情報を付与しておいて、そのmetaを持っているものだけを処理対象にする、みたいに書けるのが一番いいんじゃないかなと思います。
ここでの学び
コンテナのLambdaでも環境変数は普通にEnvironmentで渡せる
僕はコンテナイメージを利用したLambdaではEnvironment: Variables
が使えないと思いこんでいました。
普通に使えました。
コンテナのImageはすでに作成済みなのにどうやってんだよ! という感じですが、たぶんLambdaを起動するときにAWS上でdocker run
を実行してて、そのときにargs
として渡してるんですかね??
DockerBuildArgsから渡してDockerfileで展開しようかな?とか思いましたが、それじゃあレイヤーに記録されるので普通にDockerfileに直接書いてるのと変わらないし、どうしたらいいんだ...と途方に暮れてしまっていました。
EventPatternは1つのアトリビュートに対してAND条件を書けない
たぶん書けない。
最初EventPattern
を以下のようにしていました。
EventPattern:
detail-type:
- Object Created
source:
- aws.s3
detail:
bucket:
name:
- your-bucket-name
object:
key:
- wildcard: path/*/to_image/*
- suffix:
equals-ignore-case: .jpeg
- suffix:
equals-ignore-case: .jpg
- suffix:
equals-ignore-case: .png
- suffix:
equals-ignore-case: .heic
Lambdaでは画像を.webp
に変換しているのですが、これだと無限ループで酷いことになります。
それはkey
に対する設定がOR
条件になるため、全部wildcardに吸われて処理対象に取られてしまうからです。
たしかにAND条件になるんだとしたらsufix2個書いた時点で何も対象に取れなくなりますもんねぇ...
parameter_overridesは見やすく書ける
parameter_overridesは以下の2つの書き方があるようです。
下のやつのほうが見やすいですね。
parameter_overrides = "key1=\"value1\" key2=\"value2\"
parameter_overrides = [
"key1=value1",
"key2=value2",
]
余談
lambda layerを諦めた話
今回最初はコンテナを使うのではなくてLambda Layerで設定をしようとしていました。
ただHEIF対応のLayerをどうしても作れなくて、諦めたんですよね。
コンテナ内ではHEIF対応したImageMagickがビルドできたんですが、コンテナの外に一連のファイルを抜き出すとlibheifが正しく見つけられないようでした。
なぜなんでしょうね...
誰かわかる人がおいたら教えてほしいです
layerやろうとしたDockerfile
FROM amazonlinux:2023
# Install build dependencies
RUN dnf update -y && \
dnf group install -y "Development Tools" && \
dnf install -y \
wget \
tar \
gzip \
openssl-devel \
cmake \
pkgconfig \
ninja-build \
which
# Set working directory
WORKDIR /tmp
# Build and install zlib statically
RUN wget https://www.zlib.net/zlib-1.3.1.tar.gz && \
tar xvzf zlib-1.3.1.tar.gz && \
cd zlib-1.3.1 && \
./configure --prefix=/opt/imagemagick && \
make -j$(nproc) && \
make install && \
cd ..
# Build and install libpng statically
RUN wget https://download.sourceforge.net/libpng/libpng-1.6.44.tar.gz && \
tar xvzf libpng-1.6.44.tar.gz && \
cd libpng-1.6.44 && \
./configure --prefix=/opt/imagemagick \
CPPFLAGS="-I/opt/imagemagick/include" \
LDFLAGS="-L/opt/imagemagick/lib" && \
make -j$(nproc) && \
make install && \
cd ..
# Build and install libjpeg-turbo statically
RUN wget https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/3.0.4/libjpeg-turbo-3.0.4.tar.gz && \
tar xvzf libjpeg-turbo-3.0.4.tar.gz && \
cd libjpeg-turbo-3.0.4 && \
cmake -B build \
-DCMAKE_INSTALL_PREFIX=/opt/imagemagick \
-DCMAKE_INSTALL_LIBDIR=lib \
-DCMAKE_BUILD_TYPE=Release && \
cmake --build build -j$(nproc) && \
cmake --install build && \
cd ..
# Build and install libde265 (HEVC decoder)
RUN wget https://github.com/strukturag/libde265/releases/download/v1.0.15/libde265-1.0.15.tar.gz && \
tar xvzf libde265-1.0.15.tar.gz && \
cd libde265-1.0.15 && \
mkdir build && \
cd build && \
cmake .. \
-GNinja \
-DCMAKE_INSTALL_PREFIX=/opt/imagemagick \
-DCMAKE_INCLUDE_PATH=/opt/imagemagick/include \
-DCMAKE_LIBRARY_PATH=/opt/imagemagick/lib \
-DCMAKE_INSTALL_LIBDIR=lib \
-DBUILD_SHARED_LIBS=OFF && \
ninja && \
ninja install && \
cd ../..
# Build and install latest libheif (1.19.5) using CMake
RUN wget https://github.com/strukturag/libheif/archive/refs/tags/v1.19.5.tar.gz && \
tar xvzf v1.19.5.tar.gz && \
cd libheif-1.19.5 && \
mkdir build && \
cd build && \
cmake --preset=release .. \
-GNinja \
-DCMAKE_INSTALL_PREFIX=/opt/imagemagick \
-DCMAKE_INCLUDE_PATH=/opt/imagemagick/include \
-DCMAKE_LIBRARY_PATH=/opt/imagemagick/lib \
-DCMAKE_INSTALL_LIBDIR=lib \
-DWITH_EXAMPLES=OFF \
-DENABLE_MULTITHREADING=ON \
-DBUILD_SHARED_LIBS=OFF \
-DWITH_LIBDE265=ON && \
ninja && \
ninja install && \
cd ../..
# Download and install ImageMagick
RUN wget https://imagemagick.org/archive/ImageMagick.tar.gz && \
tar xvzf ImageMagick.tar.gz && \
cd ImageMagick-* && \
PKG_CONFIG_PATH=/opt/imagemagick/lib/pkgconfig:/opt/imagemagick/lib64/pkgconfig \
./configure --prefix=/opt/imagemagick \
--with-jpeg=yes \
--with-png=yes \
--with-heic=yes && \
make -j$(nproc) && \
make install
# Create the layer directory structure
RUN mkdir -p /opt/layer/bin && \
mkdir -p /opt/layer/lib && \
cp -r /opt/imagemagick/bin/* /opt/layer/bin/ && \
cp -r /opt/imagemagick/lib/* /opt/layer/lib/ && \
cd /opt/layer && \
zip -r9 /tmp/imagemagick-layer.zip .
これでホスト側に一連のファイルを持ってきてLD_LIBRARY_PATH
をlayer/lib
にして試して動いたらOKだと思ったんですけど、検証の仕方の方に問題あるんですかねぇ。
jpg,pngでは動くのに何故...
localstackを諦めた話
この設定ですがAWS本体では動くけど、localstackでは動きません。
ローカルで検証したくてlocalstackを使っているのにlocalstackを動かすために苦労することが多くて(俺は何をしてんだ??)ってなることが多発しました。
検証用のlambdaやs3などなどなんて、たいして費用かからないどころから10円以下で済むきもするので、localstackはそんなに積極的に採用しなくてもいいのかな...という気持ちになりました。
Goのテストコードからs3を操作する処理を検証するときとかくらいに留めるのがいいんでしょうかね??
AIが凄すぎる話
dockerfileやsamを書くときに、あまりにわからないのでClaudeさん(AI)に色々と相談しました。
100点の解答はくれませんが、70点くらいの解答をしてくれるのでファイルのたたき台を作るのに最高でした。
コーディングに関しては「俺はコードを書きたくてコードを書いてるんだからAIは邪魔するな!」って思ってたんですが、ファンになりそうです。
今後terraformも書いていく予定なので、そこもがっつりAIに助けてもらおうと思います。
でも苦手な分野だと、嘘を疲れても気づきづらいというのも結構致命的なので、そこは勘を研ぎ澄ませたいところですね
まとめ
記事にするととても短いですね。長い方ではあるんでしょうけど。
僕はここにたどり着くのに、なんと1.5ヶ月ほどかかっていたようです。
答えが分かった今はなんでそんなところで...みたいなハマり方をしていたんですが、知らないときは本当にしんどいですね。
この記事が誰か自分みたいな人の助けになればという気持ちです。
Discussion