GitHub Actionsで一時的なAzure SQLを使った.NET 8統合テスト自動化ガイド|OIDCとEF Coreで安全・低コストに実現

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でも置き換え可能です。

全体アーキテクチャ(ワークフローの流れ)

  1. GitHub Actionsジョブ起動(各実行に一意なリソースグループ名を採番)
  2. Azureへログイン(初期はサービス プリンシパル、将来はOIDCへ移行)
  3. RG/SQL Server/SQL Databaseの3点セットを作成(Basic SKUで十分)
  4. ファイアウォールを最小限許可(原則「Azure サービス」許可)
  5. 接続文字列を環境変数に流し込み
  6. EF Coreのマイグレーションを適用し、dotnet testを実行
  7. 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スコープに絞ると最小権限)
設定項目推奨値の例
audienceapi://AzureADTokenExchange
subjectrepo:<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&lt;AppDbContext&gt;()
        .UseSqlServer(ConnectionString)
        .Options;

    using var db = new AppDbContext(options);
    await db.Database.MigrateAsync();
}

public Task DisposeAsync() =&gt; 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&lt;AppDbContext&gt;()
        .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 =&gt; 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" ] &amp;&amp; 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 [&lt;appName or objectId&gt;] FROM EXTERNAL PROVIDER;
ALTER ROLE db_owner ADD MEMBER [&lt;appName or objectId&gt;];

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 loginDNS伝播やサーバー名の脱字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 SKUBasic / S0+まずはBasicコスト最小。重い負荷時のみ一時スケール
マイグレーション手動適用 / 自動Migrate()Migrate()差分の取りこぼしを防ぐ
削除個別削除 / RG一括RG一括漏れがない(DNS/Firewall等も一網打尽)

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次