しばやん雑記

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

Azure Pipelines だけ使っているプロジェクトを GitHub Actions に移行している話

最近は Azure Pipelines だけを使っている GitHub 上のプロジェクトを、徐々に GItHub Actions に移行しています。Azure Pipelines は Approvals や Gates などが便利ですが、OSS で一人開発しているような場合は GitHub Actions に寄せておいた方が都合が良かったです。

GitHub Actions はみんな知っているようにインフラ周りは Azure Pipelines と同じで、Azure Pipelines で言うところの Task が Action として提供されているので移行は比較的し易かったです。

まずは YAML についてドキュメントを読んでおきましょう。Azure Pipelines との差分をまとめてある移行ガイドもあります。多少構造は違いますが、すぐに理解できます。

YAML の構造とかは公式ドキュメントを読んでおけば理解できるので、具体的な利用パターン毎にどのような Workflow を書いたのかを実例を元に紹介していきます。

個人的には 80% ぐらいの利用パターンを以下の例でカバーできています。

App Service へのデプロイ

絶対に必要なものが App Service へのデプロイです。Web App や Azure Functions へのデプロイは必須とも言えるので、まずはここをちゃんと押さえておきました。

https://shibayan.jp/https://appservice.info/ は GitHub Actions を利用したデプロイに切り替え済みです。

デプロイには azure/webapps-deploy を使います。Azure Pipelines の時のようにデプロイ方法を選ぶ手段はなく、常に Zip Deploy が行われるようになっています。

なので Run From Package を使いたい場合には、予め App Settings に WEBSITE_RUN_FROM_PACKAGE = 1 を追加しておく必要があります。ARM Template や Terraform で行っておくと良いです。

デプロイに必要な資格情報ですが、Service Principal を使って Azure CLI にログインしておく方法と、Publish Profile を使う方法が選べるようになっています。

この時デプロイ先の App Service が一つの場合には Publish Profile を使うのをお勧めします。

name: Publish

on:
  push:
    branches: [ master ]

env:
  AZURE_WEBAPP_NAME: shibayan
  AZURE_WEBAPP_PACKAGE_PATH: dist
  NODE_VERSION: 12.x

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Use Node.js ${{ env.NODE_VERSION }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ env.NODE_VERSION }}

    - name: npm ci and build
      run: |
        npm ci
        npm run build -- --dest ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}

    - name: Deploy to Azure Web App
      uses: azure/webapps-deploy@v2
      with:
        app-name: ${{ env.AZURE_WEBAPP_NAME }}
        publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
        package: ${{ env.AZURE_WEBAPP_PACKAGE_PATH }}

後は Azure Portal からダウンロードした Publish Profile を、そのまま AZURE_WEBAPP_PUBLISH_PROFILE という名前で Secrets に追加するだけなので簡単です。

トリガーには master への push を指定してあるので、master に変更があればデプロイが実行されます。

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

シンプルな VuePress を使ったサイトですが、Publish Profile の設定だけでデプロイできるのは Service Principal を無駄に用意する必要がないので気軽に行えます。

複数の App Service に対してデプロイが必要な場合は、それぞれの Publish Profile を設定する方が大変になるので Service Principal を用意してデプロイするようにしましょう。

Azure CLI の利用

複数の App Service へのデプロイや Azure CLI を使った操作を行う場合には Service Principal でのログインが必要になります。今回は Azure Storage と CDN を使った Static Site のデプロイで利用しました。

https://daruyanagi.com/ はふざけた名前の割に中身は Azure Storage の Static Web Hosting と Azure CDN の組み合わせなので、Azure CLI での操作が必要になっています。

Azure Storage へのコピー程度なら接続文字列を Secrets に入れた方が楽です。実際に Azure Acmebot の Workflow では接続文字列を使うようにしています。

GitHub Actions の Worker には Azure CLI はインストール済みなので、azure/login を使って Service Principal でログインを行えば各コマンドが叩ける状態になります。

Action のページにも書いてあるコマンドを使って Service Principal を作成しておきます。

出力された JSON をそのまま Secrets に AZURE_CREDENTIALS という名前で保存すれば準備完了です。

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

後は Workflow で azure/login を実行すれば、その後から Azure CLI が利用可能になります。今回作成した Workflow は以下のようになります。

name: Publish

on:
  push:
    branches: [ master ]

env:
  AZURE_STORAGE_NAME: daruyanagi1
  NODE_VERSION: '12.x'

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Use Node.js ${{ env.NODE_VERSION }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ env.NODE_VERSION }}

    - name: npm ci and build
      run: |
        npm ci
        npm run build -- --dest dist

    - name: Azure Login
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}

    - name: Adding IP Address to Firewall
      run: |
        ip=$(curl -s https://ipinfo.io/json | jq -r .ip)
        az storage account network-rule add --account-name ${{ env.AZURE_STORAGE_NAME }} --ip-address $ip

    - name: Sync to Storage
      run: |
        sleep 15
        az storage blob sync --container '$web' --source ./dist/ --account-name ${{ env.AZURE_STORAGE_NAME }}

    - name: Remove IP Address from Firewall
      run: |
        ip=$(curl -s https://ipinfo.io/json | jq -r .ip)
        az storage account network-rule remove --account-name ${{ env.AZURE_STORAGE_NAME }} --ip-address $ip

    - name: Purge CDN Cache
      run: |
        az cdn endpoint purge -g DaruTest-RG -n static-daruyanagi --profile-name daruyanagi --content-paths '/*' --no-wait

以前に書いた IP 制限周りの処理と AzCopy を使った Storage へのデプロイ、そして CDN Purge を Workflow 内で行うようにしています。Azure Pipelines で書いた時よりシンプルになった気がします。

この Workflow もトリガーを master への push にしているので、master が変更されたタイミングで実行されます。もちろん Pull Request ではテスト実行だけして、マージされた時にデプロイを実行することも出来ます。

Terraform の実行

最近はリソースの構築に Terraform を使うようにしているので、よく使っているパターンを GitHub Actions でも実現できるか試しておきました。具体的には PR では terraform plan を実行し、マージされたら terraform apply を実行するという流れです。

上の流れは HCL を含むリポジトリで Workflow の新規作成で選べる Terraform の Starter で用意されていました。Terraform のインストールも hashicorp/setup-terraform で行えます。

生成されたテンプレートで大体の流れは問題なかったですが、Azure の場合は Service Principal でのログインになるので azure/login を実行していても別途設定が必要になります。

Terraform のドキュメントにある通り、環境変数に Service Principal の情報を追加する必要があります。

https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/service_principal_client_secret

Service Principal の作成時に出力された JSON を Secrets に一つずつ追加しておいて、Workflow で環境変数に追加するようにします。JSON のパース機能は Workflow に無さそうなので、このような対応を選びました。

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

Workflow では Secrets に追加した値を、そのまま環境変数に移し替えています。以下の Workflow は多少 Starter から変更していますが、Azure を使う上での最小 Workflow はこのようになると思います。

name: 'Terraform'

on:
  push:
    branches: [ master ]
  pull_request:

env:
  TERRAFORM_VERSION: 0.12.24
  ARM_CLIENT_ID: ${{ secrets.ARM_CLIENT_ID }}
  ARM_CLIENT_SECRET: ${{ secrets.ARM_CLIENT_SECRET }}
  ARM_SUBSCRIPTION_ID: ${{ secrets.ARM_SUBSCRIPTION_ID }}
  ARM_TENANT_ID: ${{ secrets.ARM_TENANT_ID }}

jobs:
  terraform:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Use Terraform ${{ env.TERRAFORM_VERSION }}
      uses: hashicorp/setup-terraform@v1
      with:
        terraform_version: ${{ env.TERRAFORM_VERSION }}

    - name: Terraform Init
      run: terraform init

    - name: Terraform Format
      run: terraform fmt -check

    - name: Terraform Plan
      run: terraform plan

    - name: Terraform Apply
      if: github.ref == 'refs/heads/master' && github.event_name == 'push'
      run: terraform apply -auto-approve

そして忘れてはいけないのが tfstate を Azure Storage に永続化する設定です。

Azure Pipelines の Terraform Task ではいい感じに設定してくれていましたが、GitHub Actions の場合は基本的に手動でやる必要があるので、以下のように backend の設定を追加します。

terraform {
  backend "azurerm" {
    resource_group_name  = "StorageAccount-ResourceGroup"
    storage_account_name = "abcd1234"
    container_name       = "tfstate"
    key                  = "prod.terraform.tfstate"
  }
}

Storage Account は Service Principal からアクセスできる場所に作成しておく必要があります。そして Container は自動作成してくれないので、前もって作っておく必要もあります。

backend の設定をファイルに書きたくない場合や、ターゲットによって変更したい場合は terraform init の実行時に -backend-config を渡すことでオーバーライドできます。

上で紹介した Workflow は非常にシンプルなものでしたが、以下のリポジトリに dev / prd で環境とブランチを分離しつつ、terraform plan を各 PR 作成時に実行したいという例を用意しておいたので、気になる方は参考にしてください。

Workflow を実行すると、以下のように Develop と Production というように 2 つの Workflow が個別に実行されるようになっています。

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

GitHub Actions は Azure Pipelines とは違って、気軽に Workflow の追加や分離が行えるので便利ですね。複雑な条件式を書かなくても、トリガーを使って Workflow を分けた方がシンプルになるケースが多いです。

NuGet パッケージの作成と発行

最後はライブラリ作者なら必須となる、NuGet パッケージの作成と NuGet.org への発行を GitHub Actions で自動化しました。GitHub から Release を作成するとタグ名からバージョンを作成して nupkg をビルドし、NuGet.org へパッケージをプッシュします。

Pull Request の作成時や master へのマージ時に実行する処理と、Release が作成された時に実行する処理はトリガーから別物なので Workflow を Build と Publish の 2 つに分割しています。

Build 用の Workflow ではトリガーとして master へのプッシュと Pull Request を指定しています。Pull Request は branch を指定しなくても良いかもしれませんが、master で運用しているので影響はないです。

name: Build

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

env:
  DOTNET_VERSION: 3.1.201

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

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

    - name: Install .NET Core format tool
      run: dotnet tool update -g dotnet-format

    - name: Build project
      run: dotnet build -c Release

    - name: Lint C# code
      run: dotnet format --dry-run --check --verbosity diagnostic

Pull Request に対してはビルドのチェックと .editorconfig に従ったフォーマットになっているかをチェックしています。テストコードがある場合は、もちろんこのタイミングでテストを実行します。

そして次は Publish 用の Workflow ではタグ名からバージョンを作成し、.NET Core CLI を使って nupkg の作成と NuGet.org への発行を行います。

name: Publish

on:
  push:
    tags: [ v* ]

env:
  DOTNET_VERSION: 3.1.201

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

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

    - name: Setup Version
      id: setup_version
      run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/}

    - name: Pack NuGet Package
      run: dotnet pack Sharprompt/Sharprompt.csproj -c Release -o ./dist -p:Version=${{ steps.setup_version.outputs.VERSION }}

    - name: Publish
      run: dotnet nuget push dist/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json

NuGet の API キーは Push 用に作成したものを Secrets に追加しておきます。

タグ名からバージョンを作成する方法は、以下の回答を参考にしています。シンプルに書けて嬉しいです。

実際に GitHub Actions への移行後にパッケージの新バージョンをリリースして、正しく動作することを確認しています。GitHub Actions はトリガーが非常に柔軟でかなり嬉しい感じです。

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

1 つのコマンド実行で書けるものに対しても、Azure Pipelines では Task を使っていましたが大げさだなと感じました。引き続き Azure DevOps として複数の機能を使っておらず、Azure Pipelines だけ使っている場合は GitHub Actions に順次移行しようと思います。

ただし GitHub Actions は Azure Pipelines で言うところの Deployment jobs や Approvals and checks が無いので、現実世界でのデプロイにはまだ使いにくいなという印象を持っています。