しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

GitHub Actions / Azure Pipelines 上で Azurite と Cosmos DB Emulator を使ったテストを実行する

GitHub や Azure DevOps を使った開発フローにテスト実行を組み込むのは一般的に行われていると思いますが、Azure Storage や Cosmos DB などに依存するテストを実行する際には、実際のリソースにアクセスさせるのではなくローカルで完結させたいことが多いです。

特にテストケースによってはデータが実行毎に揮発してくれた方が都合の良いことが多いので、実際のリソースより気軽に起動とデータの全削除が行える Emulator が便利です。Azure Storage と Cosmos DB には Docker ベースの Emulator が提供されているので、CI と組み合わせるのが簡単になっています。

Azure Storage の Emulator は最近は Azurite が主流になっているので、古い Storage Emulator ではなくこちらを使って行くようにしていきます。Node.js ベースなので使いやすいです。

ビルド済みの Docker Image が MCR で提供されているので、利用するのも捨てるのも簡単です。

ちなみに Visual Studio 2022 からは Azurite が npm でグローバルインストールされていれば、自動的に使われるようになっているので今後は自然と移行する形になるとは思います。

Cosmos DB Emulator は Windows と Linux 両方の Docker Image が公開されていますが、基本は Linux 向けの Docker Image を使えばよいです。自分は WSL 2 から使って試しました。

公式ドキュメントでは Direct モードでの接続に対応した手順が紹介されていますが、GitHub Actions や Azure Pipelines では Gateway モードを使うようにした方が簡単です。

気を付けないといけない点としては Emulator が発行する自己署名証明書の扱いがあります。クライアントの設定を変更して署名エラーを無視する方法もありますが、今回は証明書をインストールして対応します。

.NET ではマシンの証明書ストアを見てくれますが、利用する言語によっては独自のストアに追加する必要があります。特に Java を利用する場合には気を付けたい部分です。

クライアントを Emulator 向けに初期化

Azure Storage と Cosmos DB の各サービス向けクライアントは以下のドキュメントにある通りスレッドセーフが保証されていて、基本はシングルトンで扱うことが推奨されています。

多くの ASP.NET Core や Azure Functions を使ったアプリケーションでは DI を使って、コンストラクタで各インスタンスを受け取るようになっているはずなので、カスタマイズした各クライアントを簡単に渡せます。

Azure Storage の場合は以下のように接続文字列を見慣れたものに変えるだけで良いです。

// ほとんどの Azure SDK で対応
var connectionString = "UseDevelopmentStorage=true";

// 実際の Emulator 向け接続文字列
// Azure SDK for Python は UseDevelopmentStorage=true に対応していないので注意
// https://github.com/Azure/azure-sdk-for-python/issues/10040
// var connectionString = "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;";

// Blob / Queue / Table の各クライアントを Emulator 向けに初期化
var blobServiceClient = new BlobServiceClient(connectionString);
var queueServiceClient = new QueueServiceClient(connectionString);
var tableServiceClient = new TableServiceClient(connectionString);

多くの言語向けの SDK では UseDevelopmentStorage が使えますが、Python に関しては使えないのでフルの接続文字列を指定する必要があります。新しい Azure SDK は各言語でデザインが統一されているので、同じクラス名で書けるはずです。

次は Cosmos DB ですが Azure Storage のように省略された接続文字列は存在していないので、エンドポイントとアカウントキーが含まれた接続文字列を指定します。基本的には固定です。

// Cosmos DB Emulator の接続文字列(固定)
var connectionString = "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";

var cosmosClient = new CosmosClient(connectionString, new CosmosClientOptions
{
    // Gateway モードを使った方が簡単
    ConnectionMode = ConnectionMode.Gateway
});

今回は Emulator の自己署名証明書を信頼するように設定するので、変更点はほぼ接続文字列ぐらいで済んでいます。ドキュメントには署名エラーを無視する設定方法も記載されているので、参考にしてください。

これで Azurite と Cosmos DB Emulator をアプリケーションから使う準備が出来たので、後は GitHub Actions と Azure Pipelines で Emulator を使うように定義します。

GitHub Actions を使う例

GitHub Actions には任意の Docker Image をサービスとして実行する機能が用意されているので、これを使って Azurite と Cosmos DB Emulator を立ち上げます。

注意点としてはポートのマッピングをローカルとコンテナーの両方で明示しないと、ローカル側は自動的に空いているポートが割り当てられることです。ローカルからアクセスするポートは固定する必要があるので、しっかりと指定しておきます。

作成した Workflow は全体として以下のようになりますが、重要なのは services の定義と証明書をインストールしている部分ぐらいです。証明書のダウンロードエンドポイントは Readiness としても使えるらしいので、リトライすることで起動するまで待つようにしています。

name: Tests

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

env:
  DOTNET_VERSION: 5.0.x

jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      azurite:
        image: mcr.microsoft.com/azure-storage/azurite
        ports:
        - 10000:10000
        - 10001:10001
        - 10002:10002
      cosmos:
        image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
        ports:
        - 8081:8081
        - 10251:10251
        - 10252:10252
        - 10253:10253
        - 10254:10254
        env:
          AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 3
          AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: false
        options: -m 3g --cpus=2.0
    steps:
    - uses: actions/checkout@v2

    - name: Import emulator certificate
      run: |
        while ! curl -k -f https://localhost:8081/_explorer/emulator.pem > ~/emulatorcert.crt; do sleep 5; done
        sudo cp ~/emulatorcert.crt /usr/local/share/ca-certificates/
        sudo update-ca-certificates

    - name: Use .NET Core ${{ env.DOTNET_VERSION }}
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: ${{ env.DOTNET_VERSION }}

    - name: Build project
      run: dotnet build

    - name: Run unit tests
      run: dotnet test --no-build

Cosmos DB Emulator の設定は AZURE_COSMOS_EMULATOR_PARTITION_COUNT を最低値の 3 に変更しています。パーティション数を多くすると起動パフォーマンスが悪化するので最低限にしています。8081 以外のポートは Gateway だと不要な気もしましたが、とりあえずそのまま入れています。

適当にコミットして GitHub Actions 上で実行すると、以下の通りテストケース含め全て成功します。

f:id:shiba-yan:20210822012046p:plain

証明書のインストールを行っている部分を削除すると、ちゃんと Cosmos DB のテストケースが落ちるようになります。証明書のインストール部分以外は特に難しいことはないですね。

Azure Pipelines を使う例

Azure Pipelines にも同じように Docker Image をサービスとして起動する機能があるので、ほぼ同じように扱えます。書き方が少し違いますが機能的には全く同じです。

こちらも同じようにポート番号はローカル側も定義しないと自動で割り振られるので注意が必要です。

trigger:
- master

pool:
  vmImage: ubuntu-latest

variables:
  DOTNET_VERSION: 5.0.x

resources:
  containers:
  - container: azurite
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
    - 10000:10000
    - 10001:10001
    - 10002:10002
  - container: cosmos
    image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
    ports:
    - 8081:8081
    - 10251:10251
    - 10252:10252
    - 10253:10253
    - 10254:10254
    env:
      AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 3
      AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: false
    options: -m 3g --cpus=2.0

services:
  azurite: azurite
  cosmos: cosmos

steps:
- script: |
    while ! curl -k -f https://localhost:8081/_explorer/emulator.pem > ~/emulatorcert.crt; do sleep 5; done
    sudo cp ~/emulatorcert.crt /usr/local/share/ca-certificates/
    sudo update-ca-certificates
  displayName: Import emulator certificate

- task: UseDotNet@2
  inputs:
    packageType: 'sdk'
    version: $(DOTNET_VERSION)
  displayName: Use .NET Core $(DOTNET_VERSION)

- task: DotNetCoreCLI@2
  inputs:
    command: 'build'
  displayName: Build project

- task: DotNetCoreCLI@2
  inputs:
    command: 'test'
    arguments: '--no-build'
  displayName: Run unit tests

実行すると GitHub Actions の時と同じように、各コンテナーが起動されてから実際の処理が行われます。VM には差はないはずですが、若干 GitHub Actions で実行したほうが早く感じたのは謎です。

f:id:shiba-yan:20210822012019p:plain

.NET との組み合わせでは自動的にテスト結果を収集して、見やすく表示してくれるので結構便利です。

f:id:shiba-yan:20210822012032p:plain

サービスを色々動かそうとすると Hosted Runner では若干リソース不足な感が否めないので、もうちょっと大きな VM を選べるようになると利用範囲が広がりそうです。

今回の Service Containers を Self-Hosted Runner を使う場合には Docker 周りを自分で構築する必要があり、それなりに手間がかかるのであまりお勧めはしないです。この辺りは Virtual Environment のビルド済み Image が公開されると圧倒的に楽になるのですが難しそうです。