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


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


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

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

今回は、事前に動画のエンコード済みパターンを作っておくのではなく、さくらの[imageflux](https://www.sakura.ad.jp/services/imageflux/)のように、**リアルタイムで動画のサイズを命令通りに変換しよう**っていう試みです。

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

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

## image-resizer-service

まず、aws-serverless-application-repositoryに[image-resizer-service](https://serverlessrepo.aws.amazon.com/applications/arn:aws:serverlessrepo:us-east-1:526515951862:applications~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を使ったモジュール](https://github.com/lovell/sharp)を同梱しようとしたり...っていうのをゴニョゴニョやっていましたが、最近は**この手の作業に時間を使うのはなんか違うんじゃないか**という気持ちもムクムクあったので、せっかくなのでLambdaのRuntimeの耐用年数高そうなGoで試しにこのアプリのクローンを組んでみることにしました。


## image-resizer-service-go

[成果物](https://github.com/wano/image-resizer-service-go)

```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](github.com/disintegration/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](https://github.com/h2non/bimg)もあるようなので、こちらを使うともっと画像変換が速いかもしれません。 (未検証です)