APC 技術ブログ

株式会社エーピーコミュニケーションズの技術ブログです。

株式会社 エーピーコミュニケーションズの技術ブログです。

Azure PipelinesでTerraformを使ったインフラCI/CDを行う

はじめに

こんにちは、ACS事業部の安藤です。
これまでAzure, AWS, GCPなど様々なクラウドに対してTerraformでデプロイを行ってきましたが、
今回Azure PipelinesでインフラのCI/CDパイプラインを構築したので紹介していきます。

今回の環境

今回実行する環境と対象は以下になります。

  • 実行環境:Azure DevOps Pipelines
  • デプロイ環境:Azure
  • リモートストレージ:Azure Storage Account (Blob Storage)

準備

早速パイプラインのコードを書いていきたいところですが、その前にあったほうがいいものを準備していきます。

Terraform Extension

Azure DevOpsではデフォルトの機能に加えて、機能を拡張するためのExtensionsがVisualStudio Marketplaceで公開されています。
Terraform関連もいくつか公開されています。

marketplace.visualstudio.com

Microsoft公式のTerraformExtensionもあるのですが、
今回はサポートされているTerraformコマンドが多いAzure Pipelines Terraform Tasksを採用したので、こちらをベースに進めていきます。

なお、Azure DevOps Extensionのインストールはプロジェクト単位ではなくOrganization全体でインストールするものなので、Organization Administratorレベルの権限が必要になります。
権限が不足している場合はインストールのリクエストをすることができるので、権限者に承認してインストールしてもらう必要があります。

Azureの認証情報

今回の環境ではAzureのように、何らかのサービスを操作する上で認証情報が必要になるかと思います。
Azure Pipelinesでは接続先の情報と認証情報を組み込んだService Connection(サーピス接続)を登録することで、コード内に秘匿情報を書かずに認証を可能にしています。

docs.microsoft.com

Terraformをローカル環境で実行するとき、Azureの場合はaz loginによるユーザ認証を利用することが多いと思います。
CI/CD Pipelineのような機械的な仕組みで実行する場合は特定のユーザによる認証を行うのではなく、
Azure Active Directoryにおけるアプリケーションを作成し、それに紐づくサービスプリンシパルの認証情報(通常はパスワード)で認証を行うのが通例となります。

docs.microsoft.com

AzureのService Connectionを作成する方法としては、
接続先のサブスクリプション等の情報を書いていくことでアプリケーションとサービスプリンシパルの作成を含めて自動的にやってくれるAutomaticのほうが推奨されていますが、
サービスプリンシパルのシークレットキーの有効期限(通常6ヶ月)が切れた場合のシークレットキーの更新ができないため、
シークレットキーの更新が可能なManualの方を個人的には推奨したいです。

Manualの場合はWebUIやCLIであらかじめアプリケーションとサービスプリンシパルを作成しておき、
そのIDやシークレットキーを埋め込むことでService Connectionを作成できます。

またTerraformでリソースを作成するためにはService Connectionに紐づくアプリケーションに必要な権限を割り当てる必要があります。
基本的にはサブスクリプションないしリソースグループに対する共同作成者権限を割り当てる事が多いですが、
Azure RBACを利用する場合はさらに他の権限が必要になったり、ロール割り当てを可能にするためにユーザー アクセス管理者ロールを割り当てたりと、
ケースバイケースで必要な権限は変わってきます。

Pipelinesの作成

Pipelinesを記述・実行する前の準備だけでも結構なボリュームでしたが、ここからパイプラインの作成に入ります。

Terraform Extensionで追加されるタスクタイプはTerraform InstallerTerraform CLIになります。

Installerを最初に実行して、以降はCLIで与えるcommandを変えながらタスクを作っていくことになります。
はじめはWebUI右のAssistantで設定していって、生成されたコードを参考に徐々にコードで書いていくようにすると良いと思います。

Azure PipelinesではStageやJobを分割することで整理されたパイプラインを構成することが可能…なのですが、
Terraformではterraform initで生成されるProvider PluginやModuleの情報などが必要になり、
StageやJobを跨いでしまうとそれらのファイルが引き継がれないため、Pipelinesの最小構成単位であるStep区切りでタスクを書いていくことになります。

Terraform Pipelinesのサンプル

以上のことを踏まえて、TerraformをPipelinesで実行するなら概ねこんな感じになるんじゃないかと思っています。

trigger:
- main

variables:
- name: TerraformVersion
  value: '1.2.5'
- name: terraformWorkingDirectory
  value: 'main'
- name: ServiceConnection
  value: 'ConnectionName'
- name: subscription
  value: '********-****-****-****-************'

steps:
- task: TerraformInstaller@0
  displayName: 'Install terraform $(TerraformVersion)'
  inputs:
    terraformVersion: $(TerraformVersion)

- task: TerraformCLI@0
  displayName: 'Terraform Version'
  inputs:
    command: 'version'

- task: TerraformCLI@0
  displayName: 'Terraform Format'
  inputs:
    command: 'fmt'
    commandOptions: '-recursive'

- task: TerraformCLI@0
  displayName: 'Terraform Init'
  inputs:
    command: 'init'
    workingDirectory: $(terraformWorkingDirectory)
    backendType: 'azurerm'
    backendServiceArm: $(ServiceConnection)
    backendAzureRmSubscriptionId: $(subscription)

- task: TerraformCLI@0
  displayName: 'Terraform Validate'
  inputs:
    command: 'validate'
    workingDirectory: $(terraformWorkingDirectory)

- task: TerraformCLI@0
  displayName: 'Terraform Plan'
  inputs:
    command: 'plan'
    workingDirectory: $(terraformWorkingDirectory)
    environmentServiceName: $(ServiceConnection)
    providerAzureRmSubscriptionId: $(subscription)
    publishPlanResults: '$(Build.SourceBranchName)_$(Build.BuildNumber).tfplan'


- task: TerraformCLI@0
  displayName: 'Terraform Apply'
  condition: and(succeeded(), eq(variables['action'], 'apply'))
  inputs:
    command: 'apply'
    workingDirectory: $(terraformWorkingDirectory)
    environmentServiceName: $(ServiceConnection)
    providerAzureRmSubscriptionId: $(subscription)


- task: TerraformCLI@0
  displayName: 'Terraform Destroy'
  condition: and(succeeded(), eq(variables['action'], 'destroy'))
  inputs:
    command: 'destroy'
    workingDirectory: $(terraformWorkingDirectory)
    environmentServiceName: $(ServiceConnection)
    providerAzureRmSubscriptionId: $(subscription)

Pipelineを作るにあたって気をつけたことを簡単に解説します。

Variable

Terraformのinit/plan/apply/destroyコマンドは共通の引数を指定することが多いので、変数としてまとめるのをオススメします。

Installer

commandOptions(引数)として terraformVersion を与えるとTerraformのバージョンを任意のものに固定できます。 (デフォルトでは最新バージョン) Terraformコード内のterraform.required_versionと合わせてTerraformのバージョンをコントロールできます。
ちなみに最新バージョン以外で実行すると以下のような警告が出ます。

Format

Terraformコードのサンプルは今回置いていませんが、別ディレクトリ内でモジュールも作成・管理している想定です。
実行ディレクトリを指定せずトップレベルでcommandOptions-recursiveを付けることで、 ルートモジュールだけでなくモジュールも全てフォーマットチェックするようにしています。

Plan

publishPlanResultsでPlanファイルを指定してあげると、Summaryとは別にTerraform Planが見れるようになります。
Jobの実行履歴から見れる内容と差異はないのですが、Planをしっかり確認したい場合はこのように切り出したほうが見やすそうですね。

Apply/Destroy

ApplyとDestroyは一度にどちらか一方しか実行しないので、最初はどちらか実行しない方をコメントアウトするような運用をしていましたが、
流石にダサいと思ってcondition文で制御するようにしました。
こちらはApplyの条件式ですが、succeeded()でここまでのFormatやPlanの実行に失敗していないこととして、
さらに変数actionapplyであることを条件にしています。

condition: and(succeeded(), eq(variables['action'], 'apply'))

変数actionはパイプラインの中で設定するのではなく、Azure PipelinesのWebUIで設定する変数を使用しています。

Destroyしたい時は[Run Pipeline]のオプションでaction=destroyとなるように上書きすることでApply/Destroyを制御させるようにしました。

個人的にはPlanのほうも同様にDestroy時はterraform plan -destroyを出力するような制御をしたかったのですが、
タスク入力を条件付きで設定する書き方が意図したように動かず実現できませんでした。

おわりに

Azure PlinelinesでTerraformを実行することにフォーカスして紹介させていただきました。

より実践的なところだと更にtfseccheckovといったセキュリティスキャンを組み込んだり、
ネットワークやセキュリティ等の要件から実行環境をデフォルトのMicrosoftがホストするエージェントから、
自分たちが管理するセルフホステッドエージェントに切り替えることなども考えられます。
以下の記事でセルフホステッドエージェントの1つであるVMSSエージェントを利用する方法が紹介されていますのでご参考ください。
techblog.ap-com.co.jp

こういったTipsを今後もご紹介していきたいと思います。