はじめに
こんにちは、DROBE の都筑です。 この記事では Go 言語によって動的に画像を生成する Micro service の開発について解説します。
モチベーション
Web サービスを運用していると、メディアサイトなどで SNS の共有のための OG Image の生成などを行うために、動的に画像を生成したいというニーズが出てくるがあると思います。
DROBE でも通知などに使うために動的な画像生成のニーズがあります。
画像の生成方法
画像を動的に生成するには技術的にはいくつかの選択肢がありますが、画像処理系のライブラリを利用して画像生成を行うというのがまず思いつくと思います。
php であれば GD や ImageMagic などを使う形になります。
Go であれば、image package を使うなどが考えられます。
画像生成の課題
画像生成の時にはデザイン性のある画像を作りたいというニーズがありますが、画像処理系のライブラリを使う場合には x, y 座標を考えて調整していく必要があります。
そのため、ワークフローとして
- デザイナーがデザイン
- エンジニアが実装 (ここが中々重いのと、デザインの再現度はエンジニアの力量に依存する)
- デザイナーが実装結果を確認
- 必要があれば 2 と 3 を繰り返す
Headless Browser による画像生成サービスの実装
モチベーション
画像処理系のライブラリを使う以外の方法として Headless Browser に CSS 描画を行い、スクリーンショットを撮り画像として保存する、とう手法があります。
この手法の場合は Headless Browser を準備し操作するという面倒さはありますが、デザインは CSS で行えるため、デザイナーによるデザインの実装をイテレーション無しで高い再現度で実現できます。
DROBE ではデザイナーとの作業効率やデザインの再現性の高さを重視して Haedless Browser による手法を採用しました。
構成
Headless Browser をメインのアプリケーションコンテナ内部で準備する事はコンテナサイズの肥大化や管理コストの増大などを招くため、画像生成のサービスをマイクロサービスとする事にしました。コンテナの内部で chromium が動く環境を準備し、それを制御する go のプログラムを走らせる形です。
全体の構成は以下のようになります。
ここで golang による chromium の制御は chromedp というライブラリに依存しています。
中身の解説
golang の中で 2 つの net.http サーバーを動かしています。
func initialize() { // create a WaitGroup wg := new(sync.WaitGroup) // add two goroutines to `wg` WaitGroup wg.Add(2) // chronium がアクセスする用のサーバー staticServerRouter := chi.NewRouter() staticImageHandler := controller.NewStaticImageHandler() staticServerRouter.Handle("/", staticImageHandler) go func() { log.Println("start static image server on :8080") http.ListenAndServe(":8080", staticServerRouter) wg.Done() }() // 外部が叩く用のサーバー apiServerRouter := chi.NewRouter() interactor := interactor.NewCreateImageInteractor( imaging.NewChromedpClient(), storage.NewS3Client(), ) apiHandler := controller.NewApiHandler( interactor, ) apiServerRouter.Handle("/", apiHandler) go func() { log.Println("start api server on :8081") http.ListenAndServe(":8081", apiServerRouter) wg.Done() }() // wait until WaitGroup is done wg.Wait() }
http:8080 サーバーの方は内部で chromium がアクセスするサーバーです。
デザインをしていた html と css を使ってWebページを表示しスクショを取る構造になっています。
外部が叩く api server は一般的な clean architecture のイメージで書いています。
処理の流れは以下のようになります。
- 外部からリクエストが来る
- 8081 で動いている api server で処理を受け付ける
- api server は chromedp を操作し、8080 にWebページを表示、スクショを取る
- 取ったスクショを S3 にあげる (S3 バケットは公開設定にしておく)
- S3 の url を api response として返す
chromedp を制御する部分は infra レイヤーに書いています。
package imaging import ( "context" "fmt" "io/ioutil" "log" "time" "github.com/chromedp/chromedp" ) type ChromedpClient struct { } func NewChromedpClient() *ChromedpClient { return &ChromedpClient{} } func (c *ChromedpClient) TakeScreenshot(url string) (string, error) { opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.DisableGPU, chromedp.WindowSize(1500, 1500), ) allocCtx, cancel1 := chromedp.NewExecAllocator(context.Background(), opts...) ctx, cancel2 := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) ctx, cancel3 := context.WithTimeout(ctx, 5*time.Second) // set timeout for _, cancel := range []context.CancelFunc{cancel1, cancel2, cancel3} { defer cancel() } var buf []byte task := chromedp.Tasks{ chromedp.Navigate(url), chromedp.WaitVisible("#target", chromedp.ByID), chromedp.Screenshot("#target", &buf, chromedp.NodeVisible), } if err := chromedp.Run(ctx, task); err != nil { return "", err } fileName := fmt.Sprintf("%d.png", time.Now().UnixNano()) if err := ioutil.WriteFile(fileName, buf, 0644); err != nil { return "", err } return fileName, nil }
コンテナとして動かす際の注意点
この構成でサーバーを Deploy しましたが、一つ大きくハマってしまったポイントがあるのでご紹介します。
具体的には memory 使用量がどんどん増え続けていくという挙動が観測されました。
典型的なメモリーリークの挙動に見えたので pprof などを利用して golang のサーバーでの memory leak などを疑いましたが特に leak は観測されませんでした。
go 側で leak していないとすると、コンテナ内部で他の process が悪さをしていることが想定されます。
実際に top で process を確認してみると zombie process が発生している事を確認できました。
Mem: 3810340K used, 197548K free, 4352K shrd, 135288K buff, 2115840K cached CPU: 5% usr 3% sys 0% nic 90% idle 0% io 0% irq 0% sirq Load average: 0.44 1.55 0.87 2/570 150 PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND 64 1 root S 4802m 122% 1 0% dlv exec --listen=:2345 --headless=tr 69 64 root S 716m 18% 0 0% /go/src/build/main 1 0 root S 698m 18% 1 0% air -c .air.toml 31 0 root S 1672 0% 0 0% ash 150 31 root R 1600 0% 0 0% top 109 1 root Z 0 0% 0 0% [chromium] 100 1 root Z 0 0% 1 0% [chromium] 82 1 root Z 0 0% 0 0% [chromium] 83 1 root Z 0 0% 1 0% [chromium] 136 1 root Z 0 0% 0 0% [chromium]
zombi process の chromium はサーバーを使えば使うほど増えていくことも確認できました。
Zombie process への対策
この blog を参考に PID1 が zombie を kill してくれるように —init
flag をつけて docker を起動したところ screen shot を取った後に zombie process が生まれない事が確認できました。
終わりに
go を用いた画像を動的に生成するマイクロサービスをご紹介しました。
特にメモリー周りはあまり知見もなかったので同じように chromedp をコンテナで動かそうと考えている方の参考になれば幸いです。