AWS Lambdaへのデプロイにマルチステージビルドのコンテナイメージを使う【Ruby】

# はじめに
AWS LambdaへRubyのコードをデプロイする時にマルチステージビルドのコンテナイメージを使ったので、そのサンプルを紹介します。

# やりたかったこと

- RubyのコードをAWS Lambdaにデプロイしたい
- Dockerコンテナイメージをアップロードしてデプロイしたい
- Gemネイティブエクステンションを使いたい

# エラーが出た

AWS Lambda向けに、AWSのRubyベースイメージを使ってbundleインストールしたらエラーが出た。

```shell
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
```
**Gemネイティブエクステンションのビルドに失敗**

# 解決方法

Dockerのマルチステージビルドを使います。

:::note info
Gemネイティブエクステンションを使わないのであればマルチステージビルドは不要
:::

# Gemネイティブエクステンションとは？
Gemのコードの中に、Rubyで書かれた部分と、CやC++などのネイティブコードで書かれた部分の両方を含んでいるGemのこと。
特に処理速度が必要とされるようなGemで用いられます。

”nokogiri”などがGemネイティブエクステンションにあたります。

GemネイティブエクステンションのビルドにはCやC++をビルドするためのツールが必要になります。


# ３つのサンプル

1. 超基本のサンプル（Lambda向けのただのDockerイメージ）
2. Gemネイティブエクステンションを使おうとしてエラーになるサンプル
3. エラーを解消したサンプル

:::note info
Macのローカルでテスト実行するまでのサンプルです。
実際のLambdaへのデプロイ方法は割愛しています。
:::

# 1. 超基本のサンプル

Dockerイメージをビルドしてローカルでテスト実行するまでのサンプルです。

AWS Rubyベースイメージを使います。
このサンプルではGemネイティブエクステンションは使っていません。

```ruby:lambda_function.rb
module LambdaFunction
  class Handler
    def self.process(event:,context:)
      "Hello from Lambda!"
    end
  end
end
```

```Docker:Dockerfile
FROM public.ecr.aws/lambda/ruby:3.3

COPY Gemfile Gemfile.lock ./

RUN gem install bundler:2.4.20 && \
    bundle config set --local path 'vendor/bundle' && \
    bundle install

COPY lambda_function.rb ./    

CMD [ "lambda_function.LambdaFunction::Handler.process" ]
```


```text:Gemfile
source 'https://rubygems.org'
gem 'aws-sdk-lambda'
```

```text:Gemfile.lock
空っぽのファイルで良い
```

イメージビルド
```shell
docker build --platform linux/amd64 -t docker-image:test .
```

コンテナ実行
```shell
docker run --platform linux/amd64 -p 9000:8080 docker-image:test
```

lambda関数呼び出し
コンテナ実行後、別のターミナルで実行してください。

```shell
curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
```

実行結果
```shell
"Hello from Lambda!"
```

# 2. Gemネイティブエクステンションを使おうとしてエラーになるサンプル

Gemネイティブエクステンションである"nokogiri"をrequireしています。

```ruby:lambda_function.rb
require 'nokogiri'

module LambdaFunction
  class Handler
    def self.process(event:,context:)
      html = "<html><head><title>Hello Nokogiri!</title></head>/html>"
      document = Nokogiri::HTML.parse(html)
      document.title 
    end
  end
end
```

Gemネイティブエクステンションである"nokogiri"を指定しています。

```text:Gemfile
source 'https://rubygems.org'
gem 'aws-sdk-lambda'
gem 'nokogiri'
```

Gemfile.lock、Dockerのファイルの内容は１つ目のサンプルのまま

イメージのビルド
```shell
docker build --platform linux/amd64 -t docker-image:test .
```

ビルドするとこんなエラーが出る
```shell
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
```


# 3. エラーを解消したサンプル

マルチステージビルドを使ったDockerファイル

```Docker:Dockerfile
#
# Gemネイティブエクステンションのビルド用イメージ
#
FROM ruby:3.3.2-slim-bookworm AS builder

# 開発ツールとパッケージをインストール
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    build-essential \
    libssl-dev \
    zlib1g-dev \
    libreadline-dev \
    libxml2-dev \
    libxslt1-dev \
    libffi-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN gem install bundler

WORKDIR /app
COPY Gemfile Gemfile.lock ./

RUN bundle config set --local path '/app/vendor/bundle' && \
    bundle install

#
# AWSのLambda用イメージ
#
FROM public.ecr.aws/lambda/ruby:3.3

# ビルドした環境からGemファイルをこちらのイメージにコピー
COPY --from=builder /app/vendor/bundle /var/task/vendor/bundle

# ベースディレクトリがrubyイメージと異なる
WORKDIR /var/task
COPY lambda_function.rb ./

CMD [ "lambda_function.LambdaFunction::Handler.process" ]
```
Gemfile、Gemfile.lock、lambda_function.rbは２つ目のサンプルから変更ありません。

イメージのビルド
```shell
docker build --platform linux/amd64 -t docker-image:test .
```
エラーにならずビルドできるはずです。


コンテナ実行

```shell
docker run --platform linux/amd64 -p 9000:8080 docker-image:test
```

Lambda関数呼び出し
コンテナ実行後、別のターミナルで実行してください。

```shell
curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
```

実行結果
```shell
"Hello Nokogiri!"
```
nokogiriが使えるようになりました。


# Dockerファイル説明

Dockerのマルチステージビルドを利用して２つのイメージを作ります。

## １つ目のイメージ

### FROM ruby:3.3.2-slim-bookworm AS builder
AWS　Lambda用のイメージではない一般的なRubyのDockerイメージを用いてGemネイティブエクステンションのビルドを行います。ビルドに必要なライブラリをapt-getでインストールし、GemとGemネイティブエクステンションをビルドします。

## ２つ目のイメージ

### FROM public.ecr.aws/lambda/ruby:3.3
AWS Lambda用のRubyベースイメージを使います。１つ目のイメージで作ったGemを２つ目のイメージにコピーしてきます。その後 lambda_functionを実行しています。

```Docker
COPY --from=builder /app/vendor/bundle /var/task/vendor/bundle
```

## 補足1

AWS　Lambda用のRubyベースイメージにGemネイティブエクステンションのビルドに必要なライブラリをインストールすれば良いのでは？という疑問を持つかもしれません。

Rubyベースイメージにあらかじめインストールされているのは、apt-getではなくdnfというパッケージマネージャーです。このdnfを使ってビルドに必要なライブラリをインストールすることも可能です。

しかし、Gemのビルドのためだけに必要なツールが残ったままのイメージをLambdaにデプロイするのはリソースの無駄使いになってしまいます。これを避けるためにマルチステージビルドを利用しています。

## 補足2

Lambdaに使うことができるDockerコンテナイメージには次のものがあります。

- AWSベースイメージ（Node.js、Python、Java、Go、.NET、Rubyなど）
- AWS OS専用ベースイメージ（Amazon Linux 2023、Amazon Linux 2など）
- 非AWSベースイメージ

AWSベースイメージ以外は、エンドポイントに応答するための設定が必要です。
AWSベースイメージには、Lambdaで利用するための必要最低限のものが組み込まれていて効率的です。


# 参考


[AWS公式のコンテナイメージのデプロイの記事](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/ruby-image.html#ruby-image-instructions)
[AWS公式のコンテナイメージの最適化の記事](https://aws.amazon.com/jp/blogs/news/optimizing-lambda-functions-packaged-as-container-images/)