ちょっと遅れましたすみません。
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もあるようなので、こちらを使うともっと画像変換が速いかもしれません。 (未検証です)