🥹

コンテナを使ったLambda(Go)でImageMagick(HEIFあり)を使った画像変換をする with SAM

2024/12/11に公開

オンプレやVPS時代にちょっとしたインフラを構築してたけど、AWSは自分でしっかりと構築したことがなかった私です。
個人的に画像変換のLambdaを構築したら思いのほか大変だったので記録に残したいと思います。

最初に本題を記載しつつ、そこに至るまでの紆余曲折は余談として後半に残していく感じでいきますね。

前提

あまり気にする必要がないはずですが、一応プロジェクトの構成など前提を記載します。

  • projectはモノレポでフロントコード以外全部入ってる
  • lambdaはGoで記載している
  • この構成で動くものにはなるが、安全で安定した構成かは筆者に判断するだけの知識がない

Lambda用のコンテナイメージを作成する

まずはImageMagickをインストールしたLambda用のイメージを作成します。
つまりDockerfileを書いていきます。

実はDockerfile書くの初めてでした。
docker-composeしか書いたことなかったし、構築済みのイメージばっかり使ってたんですよねぇ。

では書いていきます。

docker/functions/Dockerfile
########## 関数のビルドステージ ##########
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 --from=builder /functions/imageresizer ./main

ENTRYPOINT [ "./main" ]

実行アーキテクチャにamd64を指定していますが、Labmdaはarm64のほうがお得なのでARCHarm64がおすすめです。
私がamd64を指定してるのは、開発用マシンがamd64だからです。
arm64のEC2を立ち上げてそこでImageを作ったりしてECRのuriを指定するほうがコスト的に良いのかもしれません。

ImageMagickの最新版はaptdnfでは入れられないので、自前でmakeしています。
またHEIF/HEICは読み取れれば良くて、書き出す予定はないので書き出し用のライブラリは省いています。
書き出しも行いたい場合は別途書き出しに必要なlibもインストールしてからmakeしましょう。

これでコンテナイメージが用意できました。

policy.xml

途中で以下のようにpolicy.xmlというのをコピーしていますね。

COPY ./docker/functions/policy.xml /usr/local/etc/ImageMagick-7/policy.xml

ImageMagickは画像的な存在はなんでも読み込んで変換できてしまうのですが、必要ないものは取り込めないほうがセキュアです。
それを制御するためにこの設定ファイルがあるので、これも設定しておきましょう。

docker/functions/policy.xml
<?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で起動するので、それを受け取っています。

presentation/serverless/function/imageresize/main.go
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_PATHlayer/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