ランサーズ(Lancers)エンジニアブログ > AWS > API Gateway > API GatewayとLambdaとGolangで作るサムネイル生成システム

API GatewayとLambdaとGolangで作るサムネイル生成システム

blog_admin|2019年08月26日
API Gateway

SREチームの金澤です。

ランサーズのサムネイル生成をAPI Gateway + Lambdaのシステムにリニューアルしました。
今回、その内容について書きたいと思います。

以前のサムネイル生成処理

今までのサムネイル生成処理は、Appサーバー内でImageMagickを起動して行っていました。
img.lancers.jpのURLにアクセスしたタイミングで以下の処理を実行していました。

  • S3から元画像をダウンロード
  • ImageMagickのconvertコマンドでサムネイルを生成して表示
  • その結果をCloudFrontにキャッシュ

※これらの処理については以前のブログに詳しく書いています。

以前のサムネイル生成処理が引き起こしていた問題

このImageMagickによるサムネイル生成処理は、サービスリリース直後に実装されたもので、ほとんど手が入らないまま10年以上運用されていました。

しかし、近年、以下の問題が目立つようになってきていました。

メモリ、ストレージの逼迫問題

アップロードのファイルサイズ制限が緩和され、巨大な画像がアップロードされることが多くなりました。
その結果、Appサーバーのメモリ、ストレージが逼迫し、それが原因でサーバーが落ちることも多くなりました。

ImageMagickは大きな画像になるとメモリやストレージを大幅に消費します。
policy.xmlの設定次第では、メモリを1GB以上消費します。
また、変換時に/tmp/に生成されるファイルが数GBになることもあります。

一度重い変換処理が走ると、画像が変換に時間がかかり、その間に再び同じ処理が発生します。
これが繰り返されることで、プロセスが溜まりAppサーバーのストレージが逼迫することが多くなりました。

セキュリティ問題

ImageMagickは脆弱性が数多く報告されており、その中には極めて深刻な脆弱性も含まれています。
Appサーバーをその脆弱性から守るためには、常に最新バージョンに保ち、policy.xmlをメンテナンスする必要がありますが、またいつ脆弱性が報告されてもおかしくない状態です。

安定性の問題

同じ画像を一度に複数サムネイル化すると、高い確率でサムネイル化に失敗する現象が起きていました。

例えば、ユーザープロフィールの画像を更新した場合、以下の3か所が全て更新されるはずが、1つ以上失敗してしまう現象が発生していました。

原因は完全には突き止められませんでしたが、S3からダウンロードされる元画像が同じために上書きが発生し、それが問題を引き起こしていた可能性が高いです。

API Gateway + Lambda によるサムネイル生成

これらの問題を解決するためにサムネイル生成システムのリニューアルにとりかかりました。
API Gateway + Lambdaを採用した理由は以下になります。

サーバー分離によるセキュリティの確保

サムネイル生成処理をAppサーバーと分離することで、サムネイル生成に乗じた攻撃に関して、Appサーバーへ影響を回避できます。

そもそも、サムネイル処理は専用のサーバーで行うべきという意見は以前からありました。
しかしながら、専用サーバーを構築するコスト、冗長性の確保を含むサーバーコスト、メンテナンスコストなどの増大の懸念もあり、対策が後回しになっていました。

Lambdaでサーバーレス化することで、これらのコスト問題をクリアできる見込みができました。

また、サムネイル生成処理がlambdaのコンテナ単位、ms単位での処理となることで、仮に脆弱性を突かれてもその影響を最小限に食い止めることができます。

メモリ、ストレージ逼迫問題の解決

サムネイル処理がAppサーバーから分離され、Appサーバーのメモリ、ストレージ逼迫問題が解決します。
また、Lambdaではコンテナ単位で実行されるため、複数実行されても独立してメモリ、ストレージが確保されます。

安定、確実な生成処理

Lambdaではそれぞれの処理が隔離されたコンテナで実行されるため、S3のダウンロード時にファイルが上書きされるようなことは起こらなくなります。
処理が各コンテナで閉じられるため、安定した処理が期待できました。

Golangを選択した理由

LambdaのランタイムにはGolangを採用しました。
理由は以下になります。

ストレージの制限

Lambda関数を起動するコンテナにはImageMagickがインストールされています。
そのため、ImageMagickを利用する選択もありました。

Lambdaには以下の制限があります。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/limits.html

特に、/tmpディレクトリが512MBまでしか使えないという点が厳しく、ImageMagickだと今回解決させたい問題の1つである、ストレージ逼迫問題が再発する可能性がありました。

そのため、サムネイル処理をGolangのimageパッケージで行うことにしました。

※後述しますが、一部の処理ではImageMagickを利用しています。

高速な処理

Lambdaは100ms単位の課金のため、費用面からもできるだけ処理は早く終了させたいところです。
できるだけ100ms以内に終了させたく、処理速度が高速なGolangに期待しました。

構築時のTips

以下、構築時に対応したポイントをいくつか紹介します。

Lambda

画像を返すために必要な設定

Base64で返す必要があります。

    return events.APIGatewayProxyResponse{
        StatusCode:      200,
        Body:            string(getBase64("/tmp/" + s3FileInfo.GetFileName())),
        IsBase64Encoded: true,
        Headers: map[string]string{
            "Content-Type": "image/jpeg",
        },
    }, nil

画像をBase64で返す関数は以下のように実装しました。

func getBase64(srcFileName string) string {
    file, err := os.Open(srcFileName)
    if err != nil {
        err = errors.Wrap(err, "Failed to os.Open")
        panic(err)
    }
    defer file.Close()

    fi, err := file.Stat()
    if err != nil {
        err = errors.Wrap(err, "Failed to file.Stat")
        panic(err)
    }

    size := fi.Size()
    data := make([]byte, size)
    b, err := file.Read(data)
    if err != nil {
        fmt.Println(string(b))
        err = errors.Wrap(err, "Failed to file.Read")
        panic(err)
    }

    return base64.StdEncoding.EncodeToString(data)
}

ImageMagickとの併用

サムネイル生成処理の99%以上はGolangのImageパッケージで行っていますが、以下の場合に限り、ImageMagickでjpgに変換する処理を行っています。

  • Golangのimageパッケージが対応していない画像のとき
  • Golangのimageパッケージの処理が失敗したとき

※Lambda関数のコンテナ内でも、convertコマンドを直接起動できます。
以下、実装例です。

cmd := exec.Command("convert", "-background", "white", "-flatten", srcFileName, dstFileName)
err := cmd.Start()
...
err := cmd.Wait()

API Gateway

jpegを返すための設定

バイナリメディアタイプにimage/jpegを追加する必要があります。

CloudFront

jpegを返すための設定

Origin Custom Headersに以下の設定を追加する必要があります。

サーバー費用の見積りとメモリ設定

Lambdaの料金は以下に記載されています。
https://aws.amazon.com/jp/lambda/pricing/

月間1000万回、すべて100ms以内に処理が終了したと仮定して概算してみました。
(※1$=120円で計算)

メモリ 100ms単位の価格 1000万回の価格 平均処理時間
128MB 約0.0000250円 約250円 約1800ms
256MB 約0.0000500円 約500円 約800ms
3008MB 約0.0005000円 約5875円 約400ms

巨大画像の処理を想定すると、メモリはなるべく多めに確保しておきたいところです。
また、Lambdaのメモリを増やすと、終了時間も早くなるため、単純に費用は比例せず、ある程度削減されます。
メモリ3008MBに設定したときは、半分以上の処理が100msを切るようになりました。

最大に設定しても大幅に費用が跳ね上がることはないと判断し、Lambdaのメモリは最大の3008MBに設定しました。
(タイムアウト値は20秒に設定)

結果

巨大画像の処理時間とメモリ使用量

15917 x 16344の画像で検証してみました。

この画像はImageMagickで変換すると10秒以上かかり、CPU、メモリを大きく消費していました。
また、/tmp/ ディレクトリに数GBのファイルを作成します。
そのため、複数プロセス同時に起動すると処理が返らずに暴走し、Appサーバーを不安定にさせる要因になっていました。

新システムでは、メモリ使用量1313MB、処理時間約1100msでサムネイル化することができました。
処理時間はほぼ同じですが、新システムは、Appサーバーから分離されたため不安定にさせる要因がなくなりました。
また、Lambdaでコンテナ単位で処理が隔離されたため、複数同時に起動しても確実に生成できるようになりました。

Lambda起動数、平均処理時間、成功率

以下は1週間分の計測結果です。

平均処理時間は約220msくらいです。
成功率は概ね99.9%以上ですが、1日に数回頻度でタイムアウト値の20秒を超えてしまうものもありました。

最後に

API Gateway + Lambdaの仕組みに移行することで、サムネイル処理に伴う問題を一通り解決させることができました。

今後、失敗した画像を分析し、さらに成功率を高めていきたいです。
また、条件が合えばLambda@Edgeへの置き換えも検討していきたいと思います。