🐬

O/Rマッパー×コンテナ技術使用時のマイグレーション実行タイミングについて

2021/03/16に公開

概要

O/Rマッパーを使用し、インフラにコンテナを使っている場合のマイグレーションタイミングについて試行錯誤したので記事にする。

技術スタックは以下だが、特に技術スタック特有の話はしない。重要なのはO/Rマッパーと、コンテナを使っているという点だけだ。

領域 技術
言語 Node.js(TypeScript)
O/Rマッパー TypeORM
データベース Aurora MySQL
ホスティング Fargate
CI/CD CodeBuild

結論

選択肢としては2つあり、❷の方法を採用した。

❶デプロイしたFargateコンテナのアプリ起動直前にマイグレーションを行う
❷Fargateコンテナをデプロイする前に、同じイメージから作ったコンテナを起動してマイグレーションを行う

比較まとめ

❶コンテナからマイグレーション ❷CI/CD環境からマイグレーション
ローカル開発 ○ 開発者がマイグレーションを忘れない × 忘れる可能性あり
シンプルさ ○ マイグレーションの記述がシンプル × 少し冗長
冪等性の担保 × 保たれない可能性あり ○ 保たれる
コスト × コンテナのスペック問題で最適化できない ○ 最適化可能
ログの見やすさ × 分散する ○ 一箇所にまとまる

❶デプロイしたFargateコンテナのアプリ起動直前にマイグレーションを行う

O/Rマッパーを利用している場合、テーブル構造はアプリのソースコード(Entity)と連動するのでコンテナからマイグレーションをかけるのが一番美しい方法だと思う。

TypeORMの場合、typeorm migration:run というコマンドでマイグレーションを行うので、例えば下記のようなDockerfileがある場合、

FROM node:14.15.3-slim

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    mysql-client

WORKDIR /usr/src/api

COPY package.json ./
COPY yarn.lock ./

RUN yarn install

COPY . .

EXPOSE 4000

CMD ["/bin/sh", "entrypoint.sh"]

entrypoint.shはこんな感じになる。

entrypoint.sh
#!/bin/sh
# Wait MySQL up
while ! mysqladmin ping -h"$MYSQL_HOST" --silent; do
  sleep 1;
done

# Migration(typeorm migration:run をyarnでラップ)
yarn migration:run

# Run api application
yarn start

メリット

この場合、ローカルで開発するときもDockerfileから環境を構築すれば、開発者がマイグレーションを忘れたまま開発してしまうことは絶対にない。

また、ステージングや本番環境に適用するときもローカルと同じようにイメージをビルドしてデプロイすればいいだけなのでとてもシンプルに保てる。

デメリット

もちろん採用しなかった方法なのでデメリットもある。

まず、O/Rマッパーのマイグレーション処理はデータベースの現在の状態を計算するので割とCPUやメモリを消費する。つまり、マイグレーションのためだけにコンテナにある程度のスペックが必要になる。

コストを抑えるために開発環境のスペックは下げておくものだが、マイグレーション処理があるとそれが不可能になる。

次に、複数コンテナをデプロイする(特に本番環境は大体複数)場合、マイグレーション処理がほぼ同じタイミングで複数走るので、O/Rマッパーの冪等性を強く信じなければならない。

個人的には、「O/Rマッパーの冪等性」は信じない方がいいと思う。自身の経験もあるが、周りの歴戦の猛者達も冪等性が守られずにデータ構造が壊れた苦い経験をしている。

複数コンテナで処理が走るということはログも分散してしまう。問題が発生したときにどのコンテナでマイグレーションが走ったかを特定するのが面倒だ。サービスが大きくなるとより致命的になる。

❷Fargateコンテナをデプロイする前に、同じイメージから作ったコンテナを起動してマイグレーションを行う

❶のデメリットを回避するためのマイグレーション方法。

❶だとコンテナのアプリ起動直前にマイグレーションのスクリプト(yarn migration:run)を実行していたが、このやり方ではマイグレーション処理をCI/CD側で実行する。

例えば、CodeBuildで「Dockerイメージのビルド → マイグレーション → イメージのPush → コンテナのデプロイ」の処理を行う(❶ではマイグレーション処理の記述は不要だった)。

build.sh
# ~省略~

# Build Docker image
docker build -t $IMAGE_REPO_URI:latest api/
docker tag $IMAGE_REPO_URI:$IMAGE_TAG

# Migration
while ! docker run --rm $IMAGE_REPO_URI:latest mysqladmin ping -h"$MYSQL_HOST" --silent; do
  sleep 1;
done
docker run --rm \
  -e MYSQL_HOST=$MYSQL_HOST \
  -e MYSQL_PORT=$MYSQL_PORT \
  -e MYSQL_USER=$MYSQL_USER \
  -e MYSQL_PASSWORD=$MYSQL_PASSWORD \
  -e MYSQL_DATABASE=$MYSQL_DATABASE \
  $IMAGE_REPO_URI:latest yarn migration:run

# Push Docker image
docker push $IMAGE_REPO_URI:$IMAGE_TAG

# Deploy container by cdk
# ~省略~

メリット

メリットはもちろん、❶のデメリットを補う点だ。

マイグレーション処理をアプリコンテナではなくCI/CD環境に任せられるので、コンテナにマイグレーションのためのスペックは必要なく、コストを最適化できる。

また、複数のマイグレーション処理が走ることもないので、冪等性は当然守られるし、ログが分散することもない。アプリのログに紛れることもないのでかなり追いやすくなる。

デメリット

ローカルで開発する際にDockerfileから環境を構築したとしても、❶のように自動でマイグレーションが走らないため、各開発者が忘れずに実施する必要がある。

ただし、マイグレーションは頻発するものではないため、たまに周知すればいい程度でそこまで問題にはならない。

CI/CD側にマイグレーション処理を記述すると、先程のシェルスクリプトにあったように少し冗長になる。が、CI/CDのスクリプトもそうそう書き換えるものではないため問題にならない。

まとめ

特に冪等性の問題とコンテナのスペック問題は大きかったので、上記の理由を総合して❷を選択した。最後に再度まとめておく。

❶コンテナからマイグレーション ❷CI/CD環境からマイグレーション
ローカル開発 ○ 開発者がマイグレーションを忘れない × 忘れる可能性あり
シンプルさ ○ マイグレーションの記述がシンプル × 少し冗長
冪等性の担保 × 保たれない可能性あり ○ 保たれる
コスト × コンテナのスペック問題で最適化できない ○ 最適化可能
ログの見やすさ × 分散する ○ 一箇所にまとまる

Discussion