Help us understand the problem. What is going on with this article?

リアルタイム画像リサイズAPIをGo + Serverless Application Modelで作った時の感想

ちょっと遅れましたすみません。
fushimiと申します。投稿させていただきます。

Api Gateway + Lambda + Go + S3 で リアルタイム画像変換処理

Goで画像リサイズをやる機会があったので記述します。

今回は、事前に動画のエンコード済みパターンを作っておくのではなく、さくらのimagefluxのように、リアルタイムで動画のサイズを命令通りに変換しようっていう試みです。

数年前初めてこのアイディアを聞いたときは、「キャッシュに乗らないエンコードパターンいくらでもパラメータで作り放題だけど不正アクセスとか大丈夫かなあ...」と思ったものですが、各社なんだかんだやっていっている試みがあるようです。類似のものは結構見つかるかと思います。

サービスの画像が重くて困り始めた社内の人間から、「うちもLambdaとかでサクッと作れない...?」と聞かれたのもあり、試しにやってみました。

image-resizer-service

まず、aws-serverless-application-repositoryにimage-resizer-serviceというnodejs製のやつを見つけました。

1clickでLambda,AplGatewayが立ち、リアルタイムの画像変換サービスがサクッと使えたので、「これでいいじゃん」となりましたが、nodeのversionが2020年2月にランタイムサポートを切られる8系でした。
じゃ、forkして12系にするか~~と思ったところ、このアプリケーションはRuntime上のimagemagickに依存していることが判明。また、LambdaのnodejsのRuntimeには10-系からimagemagickが同梱されていないことも判明。

まずはforkしたアプリケーションをいじり「imagemagickを同梱しようとしたり」 webpackから一部のモジュールを同梱したり除外したりしてサイズをチューニングしたり、公式の例にあるlibvipsを使ったモジュールを同梱しようとしたり...っていうのをゴニョゴニョやっていましたが、最近はこの手の作業に時間を使うのはなんか違うんじゃないかという気持ちもムクムクあったので、せっかくなのでLambdaのRuntimeの耐用年数高そうなGoで試しにこのアプリのクローンを組んでみることにしました。

image-resizer-service-go

成果物

package main

import (
    "bytes"
    "encoding/base64"
    "encoding/json"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/disintegration/imaging"
    "github.com/labstack/gommon/log"
    _ "image/gif"
    _ "image/jpeg"
    _ "image/png"
    "net/http"
    "os"
)

var BUCKET string

func main() {
    BUCKET = os.Getenv(`IMAGE_BUCKET`)
    lambda.Start(serveFunc)
}

func serveFunc(request events.APIGatewayProxyRequest) (resp events.APIGatewayProxyResponse, err error) {
    log.Info(`start`)
    sess, err := session.NewSession()
    if err != nil {
        log.Error(err)
        return
    }

    s3Sdk := s3.New(sess)
    path := request.Path

    obj, err := s3Sdk.GetObject(&s3.GetObjectInput{
        Bucket: &BUCKET,
        Key:    &path,
    })
    if err != nil {
        log.Error(err)
        return
    }

    original := new(bytes.Buffer)
    _, err = original.ReadFrom(obj.Body)
    if err != nil {
        log.Error(err)
        return
    }

    mimeAndDecodeType, err := getMimeAndDecodeType(original)
    if err != nil {
        log.Error(err)
        return
    }

    dst := []byte{}
    mimeType := mimeAndDecodeType.ContentType

    const LAMBDA_MAX_RESPONSE = 1024 * 1024 * 5

    params := getParams(request.QueryStringParameters)
    if params.HasOptions() {
        dst, mimeType, err = imagProcess(original, params, *mimeAndDecodeType)
        if err != nil {
            return
        }

    } else if *obj.ContentLength > LAMBDA_MAX_RESPONSE {
        forceResize := 1920
        dst, mimeType, err = imagProcess(original, Params{
            Width: &forceResize,
        }, *mimeAndDecodeType)
        if err != nil {
            return
        }

    } else {
        dst = original.Bytes()
    }

    sEnc := base64.StdEncoding.EncodeToString(dst)

    resp = events.APIGatewayProxyResponse{
        StatusCode: 200,
        Headers: map[string]string{
            `Content-Type`: mimeType,
        },
        //MultiValueHeaders: nil,
        Body:            sEnc,
        IsBase64Encoded: true,
    }

    return resp, nil
}

func imagProcess(buf *bytes.Buffer, params Params, md MimeAndDecodeType) (encoded []byte, mimeType string, err error) {

    decodeOptions := []imaging.DecodeOption{}

    if params.AutoRotate {
        decodeOptions = append(decodeOptions, imaging.AutoOrientation(true))
    }

    img, err := imaging.Decode(buf, decodeOptions...)
    if err != nil {
        log.Error(err)
        return nil, "", err
    }

    rctSrc := img.Bounds()

    w, h := func() (int, int) {
        if params.Width == nil && params.Height == nil {
            return rctSrc.Dx(), rctSrc.Dy()
        }

        if params.Width != nil && params.Height != nil {
            return *params.Width, *params.Height
        }

        if params.Width != nil {
            ratio := float64(rctSrc.Dy()) / float64(rctSrc.Dx())
            return *params.Width, int(float64(*params.Width) * ratio)
        }

        ratio := float64(rctSrc.Dx()) / float64(rctSrc.Dy())
        return int(float64(*params.Height) * ratio), *params.Height

    }()

    dst := new(bytes.Buffer)
    imgDst := imaging.Resize(img, w, h, imaging.Lanczos)

    err = imaging.Encode(dst, imgDst, md.Format)
    if err != nil {
        log.Error(err)
        return nil, "", err
    }
    return dst.Bytes(), mimeType, nil

}

type Params struct {
    Width      *int `json:"width,string,omitempty"`
    Height     *int `json:"height,string,omitempty"`
    AutoRotate bool `json:"auto_rotate,string,omitempty"`
}

func (self *Params) HasOptions() bool {
    if self.Width != nil {
        return true
    }
    if self.Height != nil {
        return true
    }
    return false
}

func getParams(m map[string]string) Params {
    p := Params{}
    if len(m) == 0 {
        return p
    }

    j, err := json.Marshal(m)
    if err != nil {
        log.Error(err)
    }

    err = json.Unmarshal(j, &p)
    if err != nil {
        log.Error(err)
    }

    return p
}

func getMimeAndDecodeType(b *bytes.Buffer) (*MimeAndDecodeType, error) {

    // todo: be lightweight
    bb := b.Bytes()

    contentType := http.DetectContentType(bb)

    f := func() imaging.Format {
        switch contentType {
        case `image/jpeg`:
            return imaging.JPEG
        case "image/gif":
            return imaging.GIF
        case "image/png":
            return imaging.PNG
        case "image/tiff":
            return imaging.TIFF
        default:
            return imaging.JPEG
        }

    }()

    return &MimeAndDecodeType{
        ContentType: contentType,
        Format:      f,
    }, nil
}

type MimeAndDecodeType struct {
    ContentType string
    Format      imaging.Format
}

Goの場合、元のをサクッとコピーするだけならコード部分は実質200行程で済んでしまいました。かつビルドすると何も依存がいらないところがいいですね。

オリジナルの実装に加えて「EXIFの自動画像回転」オプションもつけておきました。このへんを雑にやらせてくれたimaging ライブラリに感謝。

変換の流れ

元のnodejsの実装そのままなのですが、

  • (1) 事前に画像が格納されているS3 Bucketを指定しておき、
  • (2) https://xxxx/com/production/image.jpg?width=512&auto_rotate=true パラメータつきでアクセスすると画像変換Lambdaが実行され、リアルタイムにS3から指定の画像をGetしてきて、指定した通りのサイズ表示に変換して返します。
  • (3) あとは結果をCDNでキャッシュするなどしておけばOK.

感想

実装

まあS3から落としてきて、パラメータ通りにリサイズして返すというだけだったんですが、pure goな物だけで画像を扱う処理がかなりできるということをあまり知らなかったので驚きです。こういうリアルタイム処理に限らず、「imagemagickをサクッと代替できるんじゃないか」という手応えも得られました。

画質

客観的な検証は今回行っていません。ユーザー向けサイトでは mozjpegやpngquantやwebpフォーマットを利用したゴリゴリのチューニングが必要なケースもあるかと思いますが、弊社サービスでのアセット構築など、これで十分なケースも多いかと思われます。

パフォーマンス

今回のgoバイナリはzip時で3MBあり、nodejs (+ runtime上のimagemagick)版(350kb)と比べてどうか不安だったのですが、結果的にはコールドスタート時も10msも違わないほどの差でした。

問題点

  • 今回気になった事ととして、Lambdaで返せるサイズが6MBまでであるという学びを得ました。それを超えると、Lambdaがエラーを吐いてしまいます。 大抵のケースでweb向けに数MBの画像ファイルをわざわざ返すことはなかなかないんじゃないかという気もしますが、注意が必要そうです。
  • 公式のnodejs版画像リサイズで紹介されていたsharpと同じくlibvips を使用したモジュールbimgもあるようなので、こちらを使うともっと画像変換が速いかもしれません。 (未検証です)
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away