クラウドネイティブの時代にファイルをDBに保持することについて考える


はじめに

SQLアンチパターンの第11章「ファントムファイル(幻のファイル)」は、画像などのファイルを必ずストレージに保持するという固定観念を捨て、RDBにバイナリデータを保持することも検討すべし、といった内容である。
ファイルをストレージ、RDBに保持することのメリット/デメリットはこちらの記事にまとめられているので、参照されたい。

本記事では、自分が普段開発を行っているクラウドネイティブな環境で、「画像をRDBに保持する」という選択をした場合の実装方法について調査した内容をメモする。

クラウドネイティブアプリケーション

Microsoftのドキュメントにもあるように、「クラウドネイティブ」という言葉の定義は曖昧である。
ここでは、「クラウドネイティブ」アプリケーションは次のような特徴を持つ、スケーラブルなアプリケーションを指すこととする。

  • コンテナを用いたステートレスなWeb/APIサーバ
  • ロードバランサによる負荷分散
  • マネージドなRDBクラスタ、ファイルストレージ

画像のバイナリをストレージに、メタデータをDBに保存する、また画像を参照するURLを提供するという機能を持つアプリケーションの例を図示すると、次のようになる。

画像をRDBで保持する

先述した例のようなアプリケーションで、画像のバイナリをストレージではなく、メタデータと一緒にDBで保持する方法について考える。
ここで最もネックになるのが、画像をどのような参照可能な形式でユーザに渡すか?ということである。
ストレージで保持する例と同様に参照可能なURLを返却するには、DBに保存したバイナリをどこかにファイルという形式で配置する必要がある。
では画像をどこに配置すべきか?という問題に直面する。また結局画像をストレージで保存する場合と同じことをやっていることになってしまう。

そこで、今回は画像を参照するURLを返却するのではなく、バイナリをそのまま返却するという方法について検討する。
これによってDBにバイナリを保存する場合も、一度画像ファイルに変換することなく扱うことができる。

サンプルアプリケーション

今回、上記の実装方針で imgstore というアプリケーションを実装した。
画像をキャプションと一緒にアップロードでき、アップロードした画像を一覧で見ることができるシンプルな画像アップローダである。

アーキテクチャ

Web/APIサーバ、RDBをkubernetesクラスタに構築することにより、クラウドネイティブなアプリケーションとして作成した。
(L7ロードバランサなど、本質とは異なる箇所は省略した。)

RDB(MySQL)

MySQLには1つのレコードに画像のバイナリ本体とメタデータを保存する。
画像バイナリは MEDIUMBLOB (最大約16MB)で定義された列にそのまま挿入する。

CREATE TABLE `image`
(
    `image_id` MEDIUMINT NOT NULL AUTO_INCREMENT,
    `caption` VARCHAR(100) NOT NULL,
    `binary` MEDIUMBLOB NOT NULL ,
    primary key (image_id)
);

APIサーバ(バックエンド)

バックエンドは画像関連のエンドポイントが用意されている。Spring Boot in Kotlin で実装した。
実装の詳細はGitHubリポジトリを参照されたい。

  • 画像メタデータ全権取得
  • 画像登録
    • バイナリとメタデータを登録するため、multipart/form-dataでリクエストする。
  • 画像バイナリ取得
    • 画像IDを指定すると、対応するバイナリをapplication/octet-streamで返却する。(本来はjpg, pngといった種類ごとにimage/jpeg, image/pngを返してあげると親切だと思う。)
    • 画像バイナリはByteArray型(Javaのbyte[]に相当)で返却する。
@RestController
@RequestMapping("/images")
@CrossOrigin
class ImageController(private val imageService: ImageService) {

    @GetMapping
    fun readAll(): List<ImageMetaData> {
        return imageService.getAll()
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun add(
        @RequestParam("binary") @NotNull multipartFile: MultipartFile?,
        @RequestParam("caption") @NotBlank caption: String?
    ) {
        imageService.store(caption!!, multipartFile!!.bytes)
    }

    @GetMapping("/{imageId}", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
    fun readRawData(@PathVariable("imageId") @NotNull imageId: Int?): ByteArray {
        return imageService.getRawData(imageId!!).binary
    }
}

Webサーバ(フロントエンド)

フロントエンドはReact.jsで実装した。こちらも詳細はGitHubリポジトリを参照されたい。
特筆すべき箇所は、画像のバイナリを取得し、Blobとして扱って画像を表示する箇所である。
画像バイナリをBlobオブジェクトでラップし、URL.createObjectURL()によってメモリに保存したBlobにアクセス可能なURLを生成している。
これにより、バックエンドから取得した画像バイナリをブラウザで表示することが可能になる。

const getImageBlob = async (imageId) => {
  const response = await axios.get(
    `http://localhost:8080/images/${imageId}`,
    {
      responseType: "arraybuffer"
    }
  )
  const blob = new Blob([response.data], {type: "application/octet-stream"})
  const url = window.URL || window.webkitURL
  setImageUrl(url.createObjectURL(blob))
}

おわりに

SQLアンチパターンの第11章を読んでいて、クラウドネイティブなアプリケーションで実装するにはどのような方法があるか気になったので、今回技術検証を行った。
画像を参照するためのURLを取り回すのではなく、画像のバイナリをそのまま扱うことによって実現自体は可能であることがわかった。
ただし、少なくとも以下のような課題が思いつくため、実運用する場合はさらに工夫が必要と考えられる。

  • 画像をブラウザでキャッシュすることができない
  • http通信にバイナリがそのまま乗るため、トラフィックが増加する
  • 画像テーブルにメタデータとバイナリが保存されているため、データが増加するとパフォーマンスの低下が懸念される

今回のテーマに関係なく、技術的な選択肢に対してそれぞれのメリット/デメリットを考え、どの選択肢がサービスにとって最適か、しっかり検討することが非常に重要だと思う。