DROBEプロダクト開発ブログ

DROBEのプロダクト開発周辺の知見や考え方の共有をしていきます

golang で Headless Browser によって動的に画像を生成する

はじめに

こんにちは、DROBE の都筑です。 この記事では Go 言語によって動的に画像を生成する Micro service の開発について解説します。

モチベーション

Web サービスを運用していると、メディアサイトなどで SNS の共有のための OG Image の生成などを行うために、動的に画像を生成したいというニーズが出てくるがあると思います。

DROBE でも通知などに使うために動的な画像生成のニーズがあります。

画像の生成方法

画像を動的に生成するには技術的にはいくつかの選択肢がありますが、画像処理系のライブラリを利用して画像生成を行うというのがまず思いつくと思います。

php であれば GD や ImageMagic などを使う形になります。

www.php.net

Go であれば、image package を使うなどが考えられます。

pkg.go.dev

画像生成の課題

画像生成の時にはデザイン性のある画像を作りたいというニーズがありますが、画像処理系のライブラリを使う場合には x, y 座標を考えて調整していく必要があります。

そのため、ワークフローとして

  1. デザイナーがデザイン
  2. エンジニアが実装 (ここが中々重いのと、デザインの再現度はエンジニアの力量に依存する)
  3. デザイナーが実装結果を確認
  4. 必要があれば 2 と 3 を繰り返す

Headless Browser による画像生成サービスの実装

モチベーション

画像処理系のライブラリを使う以外の方法として Headless Browser に CSS 描画を行い、スクリーンショットを撮り画像として保存する、とう手法があります。

この手法の場合は Headless Browser を準備し操作するという面倒さはありますが、デザインは CSS で行えるため、デザイナーによるデザインの実装をイテレーション無しで高い再現度で実現できます。

DROBE ではデザイナーとの作業効率やデザインの再現性の高さを重視して Haedless Browser による手法を採用しました。

構成

Headless Browser をメインのアプリケーションコンテナ内部で準備する事はコンテナサイズの肥大化や管理コストの増大などを招くため、画像生成のサービスをマイクロサービスとする事にしました。コンテナの内部で chromium が動く環境を準備し、それを制御する go のプログラムを走らせる形です。

全体の構成は以下のようになります。

Headless browser を利用した画像生成サーバー

ここで golang による chromium の制御は chromedp というライブラリに依存しています。

github.com

中身の解説

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 は観測されませんでした。

pkg.go.dev

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 が生まれない事が確認できました。

blog.phusion.nl

終わりに

go を用いた画像を動的に生成するマイクロサービスをご紹介しました。

特にメモリー周りはあまり知見もなかったので同じように chromedp をコンテナで動かそうと考えている方の参考になれば幸いです。


DROBE開発組織の紹介
組織情報ポータル