はじめに
この記事は 祝 .NET 6 GA!.NET 6 での開発 Tips や試してみたことなど、あなたの「いち推し」ポイントを教えてください【PR】日本マイクロソフト Advent Calendar 2021 の12/15 分の投稿です。
EFCore のデータベースマイグレーション(dotnet ef database update
)には、.NET SDK が必要です。EFCore 6 では、SDK がインストールされていない環境でマイグレーションを実行できるようにマイグレーションに bundle という仕組みが追加されました。
これにより、実行環境のサーバーやコンテナにアプリをデプロイするときに追加のパッケージや SDK をインストールする必要が無くなり(現状では .NET Runtime は必要)、データベースのマイグレーションの展開が簡単になります。
今回はこれまでのコンテナ環境でのデータベースマイグレーションを振り返りつつ、EFCore 6 で追加された bundle によるデータベースマイグレーションを確認していきます。
コンテナイメージでマイグレーションを実行するには
実行環境向けに EFCore でマイグレーションを実行する場合、下記の 3 つの選択肢があります。最初の 2 つの選択肢は EFCore 6 以前でも選択できますが、最後の 1 つは EFCore 6 以降が対象となります。
- マイグレーションを SQL スクリプトとして出力し、個別にスクリプトを実行する
- SDK をベースイメージとしたマイグレーション用のステージを作成して実行する
- EFCore 6 の bundle 実行オプションを利用する
Visual Studio でコンテナサポートを追加した際に作成される標準の 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 にはテスト用データベースの定義に加え、アプリ側の環境変数でデータベースへの接続文字列を追加しています。
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 は次のようになります。
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 を実行しています。
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 が出力されます。
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;
コンテナのビルドと個別実行
コンテナのビルドは下記のように実行用のコンテナとは別に、マイグレーション用のコンテナを作成し個別に実行できるようにしておきます。
❯ 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
でネットワークが作成されるのでそのネットワークに参加させるのを忘れないでください。
❯ 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 時に自動的に起動しなくなります。
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
下記のコマンドで実行できます。
データベースが起動するまで少し時間がかかるので、何回か接続に失敗してリトライした後に完了するはずです。
確実に動かしたいのであれば、先にデータベースのコンテナのみ立ち上げておいてもよいですね。
❯ 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 は次のようになります。
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
サブコマンドをインストールしマイグレーションの実行を行っています。
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" ]
コンテナのビルドと個別実行
こちらもスクリプトの実行と同様に、通常のアプリとは別にマイグレーション用のコンテナを作成してタグをつけてビルドします。
❯ docker build -f .\ConsoleApp18\Dockerfile -t consoleapp18 .
❯ docker build --target migration -f .\ConsoleApp18\Dockerfile -t consoleapp18:migration .
SQL の実行ではなく dotnet ef
コマンドを実行しているので出力されるログが変わっていますが、実行の方法はスクリプトの時と変わりません。
❯ 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 にしておきましょう。
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
実行方法も変わりません。
❯ 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 すら必要ない自己完結型の実行ファイルを作成できそうですが現在のところサポートされていないようです。
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
に出力されます。実行権限を与えるのを忘れないようにしましょう。
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 を実行するように変更します。
❯ 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 を利用してアプリのコンテナのエントリーポイントを上書きしてあげれば良いです。
❯ docker-compose run --rm --entrypoint /app/bundle consoleapp18
おわりに
bundle の仕組みを使うと、自前でマイグレーション用のステージを作るよりもすっきりした構成になりましたね。
不要なステージを用意したり、.NET SDK をインストールしたりしなくてよいので CI/CD で動かしたり、コンテナを使って運用する際には良い選択肢が増えたのではないでしょうか。