CIのたびに新規のAzure SQLデータベースを自動作成し、.NET 8の統合テストを実行して完全削除――これをGitHub Actionsだけで安全・低コストに回すための実践ガイドです。サービス プリンシパルからOIDC(ワークロードIDフェデレーション)への移行、EF Coreのマイグレーション適用、待機・クリーンアップまで一気通貫で解説します。
狙いと前提
本記事のゴールは、毎回まっさらなAzure SQLを作り、統合テストを走らせ、最後にリソースを完全削除する再現性の高いCIパイプラインを構築することです。主な要求は以下の通りです。
- 資格情報をコードへハードコードせず安全に接続する
- テスト終了後にリソースを残さない(リークゼロ)
- EF Core等のスキーマ変更(マイグレーション)を含むテストに対応
以降ではGitHub Actions(Linuxホスト)とAzure CLIを使用します。.NET 8 API/テストはxUnitを想定しますが、NUnit/MSTestでも置き換え可能です。
全体アーキテクチャ(ワークフローの流れ)
- GitHub Actionsジョブ起動(各実行に一意なリソースグループ名を採番)
- Azureへログイン(初期はサービス プリンシパル、将来はOIDCへ移行)
- RG/SQL Server/SQL Databaseの3点セットを作成(Basic SKUで十分)
- ファイアウォールを最小限許可(原則「Azure サービス」許可)
- 接続文字列を環境変数に流し込み
- EF Coreのマイグレーションを適用し、dotnet testを実行
- always()でRGごと削除(–no-waitで後処理に委譲)
| 項目 | ベストプラクティス |
|---|---|
| 認証方法 | まずはサービス プリンシパル(最小権限Contributor)+GitHub Secrets。中長期ではワークロードIDフェデレーション(OIDC)へ移行し、シークレットレス化。 |
| リソース構成 | 1ワークフロー=1リソースグループ。中にSQL Server+SQL Database(Basic)。 RG名やサーバー名に${{ github.run_id }}を付け衝突を防止。 |
| パスワード生成 | openssl rand -base64 32でランダム生成し::add-mask::でログ非表示。 |
| ファイアウォール | テスト中のみ最小許可。基本は0.0.0.0–0.0.0.0で「Azure サービスを許可」。外部公開に懸念があれば自前ランナーや固定IPの範囲指定。 |
| 接続文字列 | $GITHUB_ENVに書き出し、テストでは環境変数から取得(ConnectionStrings__DefaultConnection)。 |
| テスト実行 | dotnet test。EF CoreはDatabase.Migrate()で自動マイグレーション。 |
| 待機処理 | 作成直後の接続失敗を避けるため、状態確認のポーリング or 短時間のsleepを挿入。 |
| クリーンアップ | if: always()でaz group delete。RGごと削除で残骸ゼロ。 |
| コスト | Basic SKU+数分で削除なら極小。重い負荷テストはS0以上を一時的に使用。 |
最小構成のサンプルWorkflow(サービス プリンシパル認証)
まずはSecretsにAZURE_CREDENTIALS(JSON)を保存します。形式は以下のイメージです。
{
"clientId": "<APP_CLIENT_ID>",
"clientSecret": "<APP_CLIENT_SECRET>",
"subscriptionId": "<SUBSCRIPTION_ID>",
"tenantId": "<TENANT_ID>"
}
ワークフローの抜粋(概念図):
name: integration-tests
on:
push:
branches: [ main ]
pull_request:
jobs:
integration-tests:
runs-on: ubuntu-latest
env:
RG_NAME: "rg-${{ github.run_id }}"
SQL_SERVER: "sql-${{ github.run_id }}"
SQL_DB: "testdb"
SQL_ADMIN: "sqladmin"
LOCATION: "eastus"
steps:
- uses: actions/checkout@v4
- name: Azure ログイン (SP)
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: SQL サーバー/DB 作成
run: |
SQL_PW=$(openssl rand -base64 32)
echo "::add-mask::$SQL_PW"
echo "SQL_PW=$SQL_PW" >> $GITHUB_ENV
az group create -n $RG_NAME -l $LOCATION
az sql server create -g $RG_NAME -n $SQL_SERVER -l $LOCATION \
--admin-user $SQL_ADMIN --admin-password "$SQL_PW"
az sql server firewall-rule create -g $RG_NAME -s $SQL_SERVER \
-n AllowAzureServices --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
az sql db create -g $RG_NAME -s $SQL_SERVER -n $SQL_DB --service-objective Basic
# SQL DBがOnlineになるまで待機
for i in {1..30}; do
STATUS=$(az sql db show -g $RG_NAME -s $SQL_SERVER -n $SQL_DB --query status -o tsv)
echo "Status: $STATUS"
[ "$STATUS" = "Online" ] && break
sleep 5
done
# 接続文字列 (SQL認証) を環境へ
echo "ConnectionStrings__DefaultConnection=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=${SQL_DB};User ID=${SQL_ADMIN};Password=${SQL_PW};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" >> $GITHUB_ENV
- name: .NET セットアップとテスト
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- run: |
dotnet restore
dotnet test --configuration Release --logger "trx;LogFileName=test.trx"
- name: 後片付け (RGごと削除)
if: always()
run: az group delete -n $RG_NAME --yes --no-wait
ポイントはalways()での削除と、${{ github.run_id }}を含む命名により並列実行でも干渉しないことです。
Secretsレスへ:ワークロードIDフェデレーション(OIDC)版
OIDCによりGitHubが発行する短命トークンでAzureにログインできます。長期保管するクライアントシークレットが不要になり、漏洩リスクが大幅に下がります。
前提作業(Azure/Entra ID側)
- アプリ登録(クライアントID取得)
- 「フェデレーション認証情報」にGitHubリポジトリ(
repo:<owner>/<repo>:ref:refs/heads/mainなど)を追加 - 該当サブスクリプションにContributor相当のロールを付与(RGスコープに絞ると最小権限)
| 設定項目 | 推奨値の例 |
|---|---|
| audience | api://AzureADTokenExchange |
| subject | repo:<owner>/<repo>:ref:refs/heads/main(ブランチ固定) または ref:refs/tags/* や pull_request に合わせる |
| スコープ | リソースグループ or サブスクリプション単位で最小化 |
GitHub Actions側のYAML
permissions:
id-token: write
contents: read
jobs:
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure ログイン (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} # or リポジトリ変数
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# 以降の手順はSP版と同じ(RG/SQL作成~テスト~削除)
OIDC移行時はcredentials自体が不要になることに注意してください(clientId/tenantId/subscriptionIdはID情報なので、リークしても影響は限定的。可能ならリポジトリ変数に保持)。
EF Coreのマイグレーション運用
統合テストではスキーマが常に最新であることが重要です。最もシンプルなのはテスト起動時にDatabase.Migrate()を実行する方法です。
Program.cs(Minimal API)の例
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// ① 接続文字列は環境変数 ConnectionStrings__DefaultConnection を優先
var conn = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")
?? builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext(opt => opt.UseSqlServer(conn));
var app = builder.Build();
// ② 起動時にマイグレーションを適用(統合テストで有効)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
db.Database.Migrate();
}
app.MapGet("/health", () => "ok");
app.Run();
xUnit用テストフィクスチャの例
using Microsoft.EntityFrameworkCore;
using Xunit;
public class SqlFixture : IAsyncLifetime
{
public string ConnectionString { get; private set; } =
Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")!;
public async Task InitializeAsync()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(ConnectionString)
.Options;
using var db = new AppDbContext(options);
await db.Database.MigrateAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
}
public class UserRepositoryTests : IClassFixture<SqlFixture>
{
private readonly SqlFixture _fx;
public UserRepositoryTests(SqlFixture fx) => _fx = fx;
[Fact]
public async Task CreateUser_ShouldPersist()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_fx.ConnectionString)
.Options;
using var db = new AppDbContext(options);
db.Users.Add(new User { Name = "alice" });
await db.SaveChangesAsync();
var count = await db.Users.CountAsync(u => u.Name == "alice");
Assert.Equal(1, count);
}
} </code></pre>
<h3>シードデータの投入</h3>
<p>軽量な初期データが必要なら、<code>OnModelCreating</code>の<code>HasData</code>や、起動時にスクリプトを流すユーティリティ(例:<code>db.Database.ExecuteSqlRaw</code>)を使います。テストごとにデータを隔離したい場合は<strong>テーブルごとのクリーンアップ</strong>をAfterフックに入れるか、テストクラス専用DBを作ってもOKです(作成・削除は高速)。</p>
</section>
<section>
<h2>待機・接続の安定化</h2>
<p>SQL Server/DBの作成直後は「Online」遷移のタイムラグやDNS伝播の遅延により接続が失敗することがあります。CLIで状態をポーリングするか、指数バックオフで再試行しましょう。</p>
<pre><code class="language-bash"># 状態がOnlineになるまで最長150秒待機
for i in {1..30}; do
STATUS=$(az sql db show -g "$RG_NAME" -s "$SQL_SERVER" -n "$SQL_DB" --query status -o tsv)
[ "$STATUS" = "Online" ] && break
sleep 5
done
# dotnet側の再試行(例:SqlClientのTransient Fault Handling)
# 接続文字列に "ConnectRetryCount=3;ConnectRetryInterval=5;" を加えるのも有効
</code></pre>
</section>
<section>
<h2>ファイアウォール設計の現実解</h2>
<table>
<thead>
<tr>
<th>選択肢</th>
<th>メリット</th>
<th>デメリット/注意</th>
<th>用途</th>
</tr>
</thead>
<tbody>
<tr>
<td>Allow Azure services (0.0.0.0)</td>
<td>最小設定で接続可。作成/削除が簡単。</td>
<td>Azure上の広範なサービスからの到達を許可する点に留意。テスト用DBかつ短時間運用を前提にする。</td>
<td>GitHubホストランナーから簡易に接続したい場合</td>
</tr>
<tr>
<td>固定IPアロウリスト</td>
<td>到達元を限定できる</td>
<td>GitHubホストランナーは発信IPが固定でない。自前ランナーやNAT固定IPがある場合にのみ有効。</td>
<td>自前ランナー/企業ネットワーク</td>
</tr>
<tr>
<td>プライベートエンドポイント</td>
<td>閉域接続</td>
<td>作成/クリーンアップが重く、テストのたびに作る構成には不向き。</td>
<td>長期/本番相当の検証</td>
</tr>
</tbody>
</table>
<p>本記事の「短命DB」用途では、<strong>Allow Azure services</strong>で十分現実的です。より厳密にするなら自前ランナー+固定IPのホワイトリストを検討します。</p>
</section>
<section>
<h2>接続方式:SQL認証 or Entra IDトークン</h2>
<p>最初は<strong>SQL認証(ランダムパスワード)</strong>がシンプルです。さらに踏み込むなら<strong>Entra ID(Azure AD)によるアクセストークン接続</strong>で完全パスワードレス化できます。</p>
<h3>アクセストークン接続(上級)</h3>
<ol>
<li>SQL ServerにEntra ID管理者を設定(グループ推奨)</li>
<li>Databaseにアプリ(サービス プリンシパル)用のユーザーを作成しロール付与
<pre><code class="language-sql">-- master でなくターゲットDB内で実行
CREATE USER [<appName or objectId>] FROM EXTERNAL PROVIDER;
ALTER ROLE db_owner ADD MEMBER [<appName or objectId>];
GitHub Actionsからトークンを取得して接続
using Azure.Identity;
using Microsoft.Data.SqlClient;
var connStr = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection")
?? throw new InvalidOperationException("No connection string.");
// Passwordは不要。Data Source / Initial Catalog だけ使う。
var csb = new SqlConnectionStringBuilder(connStr) {
// ユーザー名/パスワードは指定しない
};
var credential = new DefaultAzureCredential(); // OIDCログインなら利用可
var token = credential
.GetToken(new Azure.Core.TokenRequestContext(new[] { "https://database.windows.net/.default" }))
.Token;
using var conn = new SqlConnection(csb.ConnectionString) { AccessToken = token };
await conn.OpenAsync();
テスト基盤が落ち着いたら、SQL認証から段階的に切替えると無理がありません。
クリーンアップの堅牢化
失敗時でもリークしない仕組み作りが肝要です。
- always()でのRG削除(–no-wait)
- RG名にrun_idを含めることで、後から手動で識別・掃除しやすい
- タグでTTLを埋め込む(例:
az group create ... --tags ttl-hours=2 ci=gha) - 万一の取り残しに備え、定期ジョブで古いRGを削除するメンテジョブを別途用意
# 例: 2時間以上前に作成されたci=ghaタグ付きRGを削除(概念例)
for rg in $(az group list --query "[?tags.ci=='gha'].name" -o tsv); do
# 条件チェックはJQや日付比較で厳密化
az group delete -n "$rg" --yes --no-wait
done
実運用で効果が出る小技集
- NuGetキャッシュ:
actions/cacheで復元時間を短縮 - マトリクス戦略:API/DB SKU/EF Coreのバージョンで組合せテスト
- テスト粒度:読み取り系はInMemory、DB依存は統合テストに限定して総実行時間を抑制
- ログの最小化:接続文字列やパスワードは必ずマスク、SQLの詳細ログは必要なケースに限定
- フェイルファスト:DB作成/Online待機に失敗したら早期終了し、クリーンアップを先行
トラブルシューティング
| 症状 | 主な原因 | 対処 |
|---|---|---|
| Login failed for user | パスワード誤り/DB未作成/Online前の接続 | 生成・マスク処理の再確認、status == Onlineを待機、接続先DB名を確認 |
| Cannot open server <name> requested by the login | DNS伝播やサーバー名の脱字 | FQDN(.database.windows.net)を使用、数秒待って再試行 |
| Firewallにより接続拒否 | IP許可が不足 | AllowAzureServicesルールを作成。固定IPならその範囲を追加 |
| マイグレーション失敗 | 保留中マイグレーションの破壊的変更 | 明示的なダウングレード・再生成、テスト前にDatabase.Migrate()で一貫管理 |
| クリーンアップが走らない | 前ステップでジョブが失敗 | if: always()を付ける。削除はRG単位に集約 |
応用:Bicep/ARM/スクリプト資産の分離
CLIの逐次コマンドでも十分ですが、Bicep/ARMテンプレートに落とすと再利用性が高まります。下はBicepのミニマル例(概念)。
param sqlServerName string
param administratorLogin string
param administratorLoginPassword string
param location string = resourceGroup().location
param databaseName string = 'testdb'
resource server 'Microsoft.Sql/servers@2022-05-01-preview' = {
name: sqlServerName
location: location
properties: {
administratorLogin: administratorLogin
administratorLoginPassword: administratorLoginPassword
minimalTlsVersion: '1.2'
publicNetworkAccess: 'Enabled'
}
}
resource firewall 'Microsoft.Sql/servers/firewallRules@2022-05-01-preview' = {
name: 'AllowAzureServices'
parent: server
properties: {
startIpAddress: '0.0.0.0'
endIpAddress: '0.0.0.0'
}
}
resource db 'Microsoft.Sql/servers/databases@2022-05-01-preview' = {
name: '${sqlServerName}/${databaseName}'
location: location
sku: {
name: 'Basic'
}
properties: {}
}
Actions側はaz deployment group createでRGに一括デプロイし、同様に最後はRGごと削除します。
セキュリティの勘所
- 最小権限:サブスクリプション全体ではなく、作成先RGスコープでロール付与
- 短命資格情報:SPはクライアントシークレットの有効期限を短く、OIDCに段階移行
- ログマスク:
::add-mask::の徹底。set -x等の冗長ログを避ける - 環境の分離:PR用とmain用でフェデレーションのsubjectを分離し、誤用を防止
- データ:実データは使用せず、疑似データで検証(PII/機密の流入を禁止)
パフォーマンスとコスト最適化
- 短時間の統合テストならBasicで十分。数分で削除すれば課金は極小
- 大量のマイグレーションやシード投入が重い場合は、ジョブ内で一時的にS0へスケールアップしてから実行し、終わったら戻す運用も可能
- テスト分割:読み取り中心のテストはDB不要にし、DB必須のケースだけを統合テストジョブに寄せる
- ディスクIO競合を避けるため、並列度をコントロール(マトリクスの
max-parallel)
実践テンプレート(完成版)
ここまでの要素を踏まえた「完成版」の雛形です(SP認証版)。OIDC版に切替える際はログイン手順のみ差し替えます。
name: dotnet8-integration-with-azsql
on:
pull_request:
push:
branches: [ main ]
permissions:
id-token: write
contents: read
env:
LOCATION: eastus
SQL_DB: testdb
SQL_ADMIN: sqladmin
jobs:
itest:
runs-on: ubuntu-latest
env:
RG_NAME: rg-${{ github.run_id }}
SQL_SERVER: sql-${{ github.run_id }}
steps:
- uses: actions/checkout@v4
# --- 認証(SP or OIDCに置換可能)---
- name: Azure Login (Service Principal)
uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
# --- リソース作成 ---
- name: Create RG/SQL and DB
run: |
SQL_PW=$(openssl rand -base64 32)
echo "::add-mask::$SQL_PW"
echo "SQL_PW=$SQL_PW" >> $GITHUB_ENV
az group create -n $RG_NAME -l $LOCATION
az sql server create -g $RG_NAME -n $SQL_SERVER -l $LOCATION \
--admin-user $SQL_ADMIN --admin-password "$SQL_PW"
az sql server firewall-rule create -g $RG_NAME -s $SQL_SERVER \
-n AllowAzureServices --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
az sql db create -g $RG_NAME -s $SQL_SERVER -n $SQL_DB --service-objective Basic
# Online待機
for i in {1..30}; do
STATUS=$(az sql db show -g $RG_NAME -s $SQL_SERVER -n $SQL_DB --query status -o tsv)
[ "$STATUS" = "Online" ] && break
sleep 5
done
# 接続文字列を環境へ
echo "ConnectionStrings__DefaultConnection=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=${SQL_DB};User ID=${SQL_ADMIN};Password=${SQL_PW};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" >> $GITHUB_ENV
# --- .NET セットアップ & テスト ---
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore & Test
run: |
dotnet restore
dotnet build --configuration Release --no-restore
dotnet test --configuration Release --no-build --logger "trx;LogFileName=test.trx"
# --- 片付け(常に実行) ---
- name: Cleanup RG
if: always()
run: az group delete -n $RG_NAME --yes --no-wait
まとめ
「RGごと使い捨て」「接続情報は環境経由」「always()で完全削除」という3点を守るだけで、Azure SQLを使った.NET 8の統合テストは安全かつ再現性高く回せます。初期はサービス プリンシパル+Secretsで手早く立ち上げ、安定したらOIDCへ移行してシークレットレス化。EF CoreのDatabase.Migrate()によりスキーマ差分も自動吸収できるため、CIの足を引っ張りません。あとは待機・マスク・タグ付けといった運用ディテールを積み上げれば、「作る→試す→消す」の気持ちよいループが手に入ります。
付録:よくある設計の分岐と推奨
| 分岐ポイント | 選択肢 | 推奨 | 理由 |
|---|---|---|---|
| 認証 | SP / OIDC | 短期:SP、長期:OIDC | 導入容易性と運用安全性のバランス |
| 防御 | Allow Azure services / 固定IP | 短命DB:Allow Azure services | 構築とクリーンアップが最小。固定IPは自前ランナー時のみ |
| DB SKU | Basic / S0+ | まずはBasic | コスト最小。重い負荷時のみ一時スケール |
| マイグレーション | 手動適用 / 自動Migrate() | Migrate() | 差分の取りこぼしを防ぐ |
| 削除 | 個別削除 / RG一括 | RG一括 | 漏れがない(DNS/Firewall等も一網打尽) |

コメント