コンテナ環境でのデータベースマイグレーション

# はじめに

この記事は 祝 .NET 6 GA！.NET 6 での開発 Tips や試してみたことなど、あなたの「いち推し」ポイントを教えてください【PR】日本マイクロソフト Advent Calendar 2021 の12/15 分の投稿です。

https://qiita.com/advent-calendar/2021/microsoft

EFCore のデータベースマイグレーション(`dotnet ef database update`)には、.NET SDK が必要です。EFCore 6 では、SDK がインストールされていない環境でマイグレーションを実行できるようにマイグレーションに bundle という仕組みが追加されました。
これにより、実行環境のサーバーやコンテナにアプリをデプロイするときに追加のパッケージや SDK をインストールする必要が無くなり(現状では .NET Runtime は必要)、データベースのマイグレーションの展開が簡単になります。

https://devblogs.microsoft.com/dotnet/introducing-devops-friendly-ef-core-migration-bundles/

今回はこれまでのコンテナ環境でのデータベースマイグレーションを振り返りつつ、EFCore 6 で追加された bundle によるデータベースマイグレーションを確認していきます。

# コンテナイメージでマイグレーションを実行するには

実行環境向けに EFCore でマイグレーションを実行する場合、下記の 3 つの選択肢があります。最初の 2 つの選択肢は EFCore 6 以前でも選択できますが、最後の 1 つは EFCore 6 以降が対象となります。

- マイグレーションを SQL スクリプトとして出力し、個別にスクリプトを実行する
- SDK をベースイメージとしたマイグレーション用のステージを作成して実行する
- EFCore 6 の bundle 実行オプションを利用する

Visual Studio でコンテナサポートを追加した際に作成される標準の Dockerfile です。今回はこのファイルをもとに手を加えていきます。

```Dockerfile
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["ConsoleApp18/ConsoleApp18.csproj", "ConsoleApp18/"]
RUN dotnet restore "ConsoleApp18/ConsoleApp18.csproj"
COPY . .
WORKDIR "/src/ConsoleApp18"
RUN dotnet build "ConsoleApp18.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ConsoleApp18.dll"]
```

docker-compose にはテスト用データベースの定義に加え、アプリ側の環境変数でデータベースへの接続文字列を追加しています。

```yaml
version: "3.9"

services:
  consoleapp18:
    image: ${DOCKER_REGISTRY-}consoleapp18
    build:
      context: .
      dockerfile: ConsoleApp18/Dockerfile
    environment:
      ConnectionStrings__blogdbcontext: Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb
    depends_on:
      - db
  db:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mydb
    ports:
      - 3306:3306
```

# マイグレーションを SQL スクリプトとして出力し、個別にスクリプトを実行する

`dotnet ef script` でマイグレーションに必要な SQL を作成する方法です。
作成したマイグレーション用の SQL は新たに作成した migration ステージで実行します。

## Dockerfile の修正

Dockerfile は次のようになります。

```Dockerfile
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["ConsoleApp18/ConsoleApp18.csproj", "ConsoleApp18/"]
RUN dotnet restore "ConsoleApp18/ConsoleApp18.csproj"
COPY . .
WORKDIR "/src/ConsoleApp18"
RUN dotnet build "ConsoleApp18.csproj" -c Release -o /app/build

FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish  \
   && dotnet tool install --global dotnet-ef --version 6.0.0 \
   && dotnet ef migrations script -i -o /migration/migrationscript.sql

FROM debian:bullseye-slim as migration
WORKDIR /migration
COPY --from=publish /migration .
RUN apt-get update && apt-get install -y default-mysql-client-core
ENTRYPOINT mysql -h ${MYSQL_HOST} -u${MYSQL_USER} -p${MYSQL_PASSWORD} -D ${MYSQL_DATABASE} < migrationscript.sql

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ConsoleApp18.dll"]
```

変更はこの部分ですね。publish ステージでは通常の成果物の生成とは別に `dotnet ef` サブコマンドをインストールしマイグレーション用の SQL を `/migration/migrationscript.sql` に出力しています。
その後に追加した migration ステージでは、publish ステージで作成した SQL を取り出し、MySQL クライアントをインストールして SQL を実行しています。

```Dockerfile
FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish  \
   && dotnet tool install --global dotnet-ef --version 6.0.0 \
   && dotnet ef migrations script -i -o /migration/migrationscript.sql

FROM debian:bullseye-slim as migration
WORKDIR /migration
COPY --from=publish /migration .
RUN apt-get update && apt-get install -y default-mysql-client-core
ENTRYPOINT mysql -h ${MYSQL_HOST} -u${MYSQL_USER} -p${MYSQL_PASSWORD} -D ${MYSQL_DATABASE} < migrationscript.sql
```

`dotnet ef script` を開始地点を指定せずに実行すると、これまでデータコンテキストに行われた全てのマイグレーションがスクリプトとして出力されますが、`-i` オプションを付けると下記のようにすでに適用済みのスクリプトは実行時にスキップされるような SQL が出力されます。

```sql:migrationscript.sqlの抜粋
CREATE TABLE IF NOT EXISTS `__EFMigrationsHistory` (
    `MigrationId` varchar(150) CHARACTER SET utf8mb4 NOT NULL,
    `ProductVersion` varchar(32) CHARACTER SET utf8mb4 NOT NULL,
    CONSTRAINT `PK___EFMigrationsHistory` PRIMARY KEY (`MigrationId`)
) CHARACTER SET=utf8mb4;

START TRANSACTION;

DROP PROCEDURE IF EXISTS MigrationsScript;
DELIMITER //
CREATE PROCEDURE MigrationsScript()
BEGIN
    IF NOT EXISTS(SELECT 1 FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20211206074809_first') THEN

    ALTER DATABASE CHARACTER SET utf8mb4;

    END IF;
END //
DELIMITER ;
CALL MigrationsScript();
DROP PROCEDURE MigrationsScript;

DROP PROCEDURE IF EXISTS MigrationsScript;
DELIMITER //
CREATE PROCEDURE MigrationsScript()
BEGIN
    IF NOT EXISTS(SELECT 1 FROM `__EFMigrationsHistory` WHERE `MigrationId` = '20211206074809_first') THEN

    CREATE TABLE `Blogs` (
        `BlogId` int NOT NULL AUTO_INCREMENT,
        `BlogName` longtext CHARACTER SET utf8mb4 NOT NULL,
        `Url` longtext CHARACTER SET utf8mb4 NOT NULL,
        CONSTRAINT `PK_Blogs` PRIMARY KEY (`BlogId`)
    ) CHARACTER SET=utf8mb4;

    END IF;
END //
DELIMITER ;
CALL MigrationsScript();
DROP PROCEDURE MigrationsScript;
```

## コンテナのビルドと個別実行

コンテナのビルドは下記のように実行用のコンテナとは別に、マイグレーション用のコンテナを作成し個別に実行できるようにしておきます。

```sh
❯ docker build -f .\ConsoleApp18\Dockerfile -t consoleapp18 .
❯ docker build --target migration -f .\ConsoleApp18\Dockerfile -t consoleapp18:migration-script .
```

docker-compose でデータベースだけ立ち上げておき、上で作成したマイグレーション用のタグのコンテナを起動してあげます。docker-compose を up した場合、`docker-compsoeが配置されているフォルダーの名前_default` でネットワークが作成されるのでそのネットワークに参加させるのを忘れないでください。

```sh
❯ docker-compose up db
❯ docker run --rm -e MYSQL_HOST=db -e MYSQL_USER=root -e MYSQL_PASSWORD=root -e MYSQL_DATABASE=mydb --net=consoleapp18_default consoleapp18:migration-script
mysql: [Warning] Using a password on the command line interface can be insecure.
```

## 開発用の docker-compose

上記のコマンドを毎回実行するのはつらいので、ローカル開発用には docker-compose にマイグレーション用のコンテナを含めておくのが良いでしょう。マイグレーション用のコンテナには `profiles` に `donotstart` を指定して置くと up 時に自動的に起動しなくなります。

```yaml
version: '3.9'

services:
  consoleapp18:
    image: ${DOCKER_REGISTRY-}consoleapp18
    build:
      context: .
      dockerfile: ConsoleApp18/Dockerfile
    environment:
      ConnectionStrings__blogdbcontext: Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb
    depends_on:
      - db

  db:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mydb
    ports:
      - 3306:3306

  migration:
    build:
      context: .
      dockerfile: ConsoleApp18/Dockerfile
      target: migration
    environment:
      MYSQL_HOST: db
      MYSQL_USER: root
      MYSQL_PASSWORD: root
      MYSQL_DATABASE: mydb
    depends_on:
      - db
    profiles:
      - donotstart
```

下記のコマンドで実行できます。
データベースが起動するまで少し時間がかかるので、何回か接続に失敗してリトライした後に完了するはずです。
確実に動かしたいのであれば、先にデータベースのコンテナのみ立ち上げておいてもよいですね。

```sh
❯ docker-compose run --rm migration
Starting consoleapp18_db_1 ... done
mysql: [Warning] Using a password on the command line interface can be insecure.
```

# SDK をベースイメージとしたマイグレーション用のステージを作成して実行する

こちらはマイグレーション用の SQL を事前に作って置くのではなく、マイグレーション実行用のステージに SDK を使い `dotnet ef database update` コマンドをそのまま使えるようにした方法です。

## Dockerfile

Dockerfile は次のようになります。

```Dockerfile
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["ConsoleApp18/ConsoleApp18.csproj", "ConsoleApp18/"]
RUN dotnet restore "ConsoleApp18/ConsoleApp18.csproj"
COPY . .
WORKDIR "/src/ConsoleApp18"
RUN dotnet build "ConsoleApp18.csproj" -c Release -o /app/build

FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish

FROM build as migration
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet tool install --global dotnet-ef --version 6.0.0
ENTRYPOINT [ "dotnet", "ef", "database", "update" ]

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ConsoleApp18.dll"]
```

スクリプトの実行とは違い、publish ステージには特に手を加えず、追加したマイグレーションステージでビルドステージで作成した成果物に対し `dotnet ef` サブコマンドをインストールしマイグレーションの実行を行っています。

```Dockerfile
FROM build as migration
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet tool install --global dotnet-ef --version 6.0.0
ENTRYPOINT [ "dotnet", "ef", "database", "update" ]
```

## コンテナのビルドと個別実行

こちらもスクリプトの実行と同様に、通常のアプリとは別にマイグレーション用のコンテナを作成してタグをつけてビルドします。

```sh
❯ docker build -f .\ConsoleApp18\Dockerfile -t consoleapp18 .
❯ docker build --target migration -f .\ConsoleApp18\Dockerfile -t consoleapp18:migration .
```

SQL の実行ではなく `dotnet ef` コマンドを実行しているので出力されるログが変わっていますが、実行の方法はスクリプトの時と変わりません。

```sh
❯ docker-compose up db
❯ $connectionString="Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb"
❯ docker run --rm -e ConnectionStrings__blogdbcontext=$connectionString --net=consoleapp18_default  consoleapp18:migration
Build started...
Build succeeded.
warn: Microsoft.EntityFrameworkCore.Model.Validation[10400]
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data; this mode should only be enabled during development.
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 6.0.0 initialized 'BlogDbContext' using provider 'Pomelo.EntityFrameworkCore.MySql:6.0.0' with options: SensitiveDataLoggingEnabled ServerVersion 8.0-mysql
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (8ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='mydb' AND TABLE_NAME='__EFMigrationsHistory';
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (183ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE `__EFMigrationsHistory` (
          `MigrationId` varchar(150) CHARACTER SET utf8mb4 NOT NULL,
          `ProductVersion` varchar(32) CHARACTER SET utf8mb4 NOT NULL,
          CONSTRAINT `PK___EFMigrationsHistory` PRIMARY KEY (`MigrationId`)
      ) CHARACTER SET=utf8mb4;
... 略 ...
```

## 開発用の docker-compose

こちらも docker-compose にしておきましょう。

```yaml
version: "3.9"

services:
  consoleapp18:
    image: ${DOCKER_REGISTRY-}consoleapp18
    build:
      context: .
      dockerfile: ConsoleApp18/Dockerfile
    environment:
      ConnectionStrings__blogdbcontext: Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb
    depends_on:
      - db
  db:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: mydb
    ports:
      - 3306:3306

  migration:
    build:
      context: .
      dockerfile: ConsoleApp18/Dockerfile
      target: migration
    environment:
      ConnectionStrings__blogdbcontext: Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb
    depends_on:
      - db
    profiles:
      - donotstart
```

実行方法も変わりません。

```sh
❯ docker-compose run --rm migration
Creating network "consoleapp18_default" with the default driver
Creating consoleapp18_db_1 ... done
Creating consoleapp18_migration_run ... done
Build started...
Build succeeded.
warn: Microsoft.EntityFrameworkCore.Model.Validation[10400]
      Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data; this mode should only be enabled during development.
info: Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 6.0.0 initialized 'BlogDbContext' using provider 'Pomelo.EntityFrameworkCore.MySql:6.0.0' with options: SensitiveDataLoggingEnabled ServerVersion 8.0-mysql
```

# EFCore 6 の bundle 実行オプションを利用する

EFCore 6 で追加された bundle を利用してマイグレーション用の実行ファイルを作成する方法です。MySQL クライアントや .NET SDK が無い環境でもマイグレーションを実行することができます。

bundle は下記のコマンドで作成できます。

```
dotnet ef migrations bundle
```

`--self-contains` オプションを使うと、.NET Runtime すら必要ない自己完結型の実行ファイルを作成できそうですが現在のところサポートされていないようです。

https://github.com/dotnet/efcore/issues/19693

## Dockerfile

Dockerfile はこんな感じになりました。

```Dockerfile
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["ConsoleApp18/ConsoleApp18.csproj", "ConsoleApp18/"]
RUN dotnet restore "ConsoleApp18/ConsoleApp18.csproj"
COPY . .
WORKDIR "/src/ConsoleApp18"
RUN dotnet build "ConsoleApp18.csproj" -c Release -o /app/build

FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish  \
   && dotnet tool install --global dotnet-ef --version 6.0.0 \
   && dotnet ef migrations bundle -o /app/publish/bundle \
   && chmod 755 /app/publish/bundle

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ConsoleApp18.dll"]
```

ほかの例に比べレイヤーも少なくだいぶシンプルな Dockerfile になっていますね。
もちろんマイグレーション専用のステージを作って build ステージから bundle と appsettings.json を取り出してもよいですね。今後 `--self-contains` もサポートされれば debian や alpine などの .NET Runtime を含まない小さなコンテナイメージに差し替えることもできます。
`dotnet ef migrations bundle -o /app/publish/bundle` で作成した bundle は最終的に `/app/bundle` に出力されます。実行権限を与えるのを忘れないようにしましょう。

```Dockerfile
FROM build AS publish
ENV PATH="$PATH:/root/.dotnet/tools"
RUN dotnet publish "ConsoleApp18.csproj" -c Release -o /app/publish  \
   && dotnet tool install --global dotnet-ef --version 6.0.0 \
   && dotnet ef migrations bundle -o /app/publish/bundle \
   && chmod 755 /app/publish/bundle
```

## コンテナのビルドと個別実行

この場合 .NET SDK やソースファイルは必要ないのでアプリのコンテナの etrypoint を書き換えて /app/bundle を実行するように変更します。

```sh
❯ docker-compose up db
❯ $connectionString="Server=db;Port=3306;Uid=root;Pwd=root;Database=mydb"
❯ $entrypoint="/app/bundle"
❯ docker run --rm -e ConnectionStrings__blogdbcontext=$connectionString --net=consoleapp18_default --entrypoint=$entrypoint consoleapp18
```

## 開発用の docker-compose

docker-compose を利用する場合も標準の docker-compose を利用してアプリのコンテナのエントリーポイントを上書きしてあげれば良いです。

```sh
❯ docker-compose run --rm --entrypoint /app/bundle consoleapp18
```

# おわりに
bundle の仕組みを使うと、自前でマイグレーション用のステージを作るよりもすっきりした構成になりましたね。
不要なステージを用意したり、.NET SDK をインストールしたりしなくてよいので CI/CD で動かしたり、コンテナを使って運用する際には良い選択肢が増えたのではないでしょうか。