はじめに
みなさんこんにちは。
合同会社EXNOAプラットフォーム開発本部プラットフォームインフラ部の黒瀬です。
私たちの部門では、DMM GAMESにおけるゲームを稼働させるためのプラットフォームのインフラを運用構築しており、オンプレミスからクラウドまで幅広い環境を利用しています。
そのなかで今回は、特にパブリッククラウド構築にて利用しているTerraformの運用についての取り組みを紹介したいと思います。
Terraform Cloudとは
TerraformとTerraform Cloud
ご存知の方も多いと思いますが、TerraformはHashiCorp社が提供するhcl形式のコードベースにてインフラの構成管理を行うツールです。
AWSやGCPなどのパブリッククラウド環境の構築で使われることが主ですが、その他にオンプレミスのアプライアンス機器や、Kubernetesのリソース管理、MySQLやMongoDBなどのミドルウェアの設定などにも利用できます。
CLI版であるTerraform OSSを利用することで個人ユースには十分な機能を備えるのですが、私たちのチームのように複数人で活用していくには課題がありました。
- インフラの状態を示すtfstateファイルの共有に手間がかかる
- 個人のローカルPCで自由にインフラ構成のプロビジョニング実行することができてしまい、また、その履歴を残せない
- プロビジョニングが実行されるソースがレビューを経たかの担保が取れない
- ローカルで実行するためユーザに強い権限のクレデンシャルが必要になる
OSS以外のTerraformには、SaaSであるTerraform Cloud、自前サーバで構築し運用するTerraform Enterpriseがありましたが、それらの問題を解決するために各種ソリューションのPoCを行い、さらに運用保守の工数がかからずTerraformの活用に専念できるSaaSを選択しました。
今回の導入により前述の課題を解消しただけでなく、例えば以下のメリットも生まれました。
- ワークスペースのアクセスコントロールが容易になった
- GitHubやGitLabを介して容易にCI/CD環境を構築可能となった
- チームで利用するModuleの公開範囲を限定することができるようになった
Terraform Cloudの利用状況
現在、チームではパブリッククラウドの新規案件は概ねTerraform Cloudにて構築運用を行なっており、またチーム外にもTerraform Cloudの導入を進めています。
ただ、順調に利用者が増えることにより、利用状況の把握・可視化の部分で下記のような課題が見えてきました。
- ApplyやPlanの実行状況
- 同時実行数や実行時間
- ワークスペースの状態
それらの解決のために、以下の可視化に取り組みました。
Terraform Cloudの課題に対する取り組みの実際と課題
利用状況の可視化の取り組み
ひと口に可視化と言っても、様々な取り組みが考えられます。そのなかでも今回は、以下の4点についてお話ししようと思います。
- SlackへのNotification
- APIを利用したデータ取得
- LambdaとS3とAthenaによるデータ抽出作業の簡素化
- Notificationを利用してWebhook経由でDatadogのダッシュボードで可視化
1. SlackへのNotification

まず、全てのワークスペースに対してのアクション履歴を逐次通知するSlackのチャンネルを用意しました。
これによって実行ログと同等の情報がSlackに配信されるため、リアルタイムで利用状況の把握が容易になりました。
チャンネルへの投稿を見ることで、Terraform Cloudを開くことなくCI/CDの稼働状況が把握できます。

2.APIを利用したデータ取得
次に、REST APIを使ってデータを取得し、可視化する情報量を増やすことに取り組みました。
APIを使うとワークスペースや利用者の情報や過去の利用履歴なども取得できますが、可読性の高いスプレッドシート形式に置き換えました。
実行履歴のJSON
$ curl -s \ > -H "Authorization: Bearer USER_TOKEN" \ > -H "Content-Type: application/vnd.api+json" \ > -X GET \ > https://app.terraform.io/api/v2/runs/run-HOGEhogeHOGEhoge/plan | jq { "data": { "id": "plan-Km5cFFQUcY2PRFSe", "type": "plans", "attributes": { "has-changes": false, "status": "finished", "status-timestamps": { "started-at": "2020-12-10T08:49:10+09:00", "finished-at": "2020-12-10T08:50:01+09:00", "managed-queued-at": "2020-12-10T08:49:09+09:00" }, "log-read-url": "https://archivist.terraform.io/v1/object/HOGEHOGEjE6ZWRoTS9temlrbHJWb0JKSE1NSWc4ZDJXTHRhR0o5N1lORzhoZDNqUEd4dFd2S25GcThiWE53K05FS1MzYU80S1RXR05UVkxRZkF2ZlovNUxFSEhxRnFlbjdPOC9RUnhxajN4RWxBYWY0UGtQaGtiRWZKRjJuM2pGYnFOaGI3YnMwRlkrZTFUallMSzlpOFBicjJOMWp6UmwxT3I3ak5LN0VYL3gyQTh6RGRtSFBCWnFzKzBkYVptVlVBYnRZdnNLTU1XQ0R4Q05TbGpWd0xMcnRtOHZ5M3FxaEd3RjFDTmZwczg1SnByRUJ0WGtDeHlKeUhvMk9KUTd2RXA3TUw0cHcwanQwL1Q5MHRIdVB1cENCRmxxZDBiS0hkTUtBeGprL2RGMGxTNXVheFNZck0vYXJOSG1GaHNUaDFYRFhjd1JKcURXZ3NrRlpseW1DWWNyd3VnZWk0akh6ZHFoQW9vNDdjSDhUMkRmdHNDY3RZTFV0b1FEeHQrcmk0cz0", "resource-additions": null, "resource-changes": null, "resource-destructions": null, "actions": { "is-exportable": false }, "execution-details": { "mode": "remote" }, "permissions": { "can-export": true } }, "relationships": { "state-versions": { "data": [] }, "exports": { "data": [] } }, "links": { "self": "/api/v2/plans/plan-hogeFFQUcY2PRFSe", "json-output": "/api/v2/plans/plan-hogeFFQUcY2PRFSe/json-output" } } }
SaaSにてコストが発生する以上、費用対効果を把握しておくことがとても重要だと思うので、利用状況は常に一目瞭然にしておきたいところです。
そのために、実行履歴を一括でダウンロードして、どれだけのPlanやApplyがTerraform Cloudを介して行われているのかをSpreadsheetのquery関数を利用して集計する、といった対応を行いました

このシートの利用によって、例えば、運用ルールから外れたワークスペースがいくつあるのかなども、情報を横並びにするからこそ気付けたことも多かったです。
3.LambdaとS3とAthenaによるデータ抽出作業の簡素化
REST APIのデータ取得も運用開始当初は、Pythonで作成したスクリプトをローカルPCで実行していました。
それをさらに手を煩わせないためにAWS上でLambda化し、Cloudwatch Eventでキックして自動収集するように変更し、S3にJSONを蓄積しAthenaを介して検索できるようにしています。
Pythonでは、pipで導入可能かつ比較的実装機能が豊富なterrasnekというライブラリを利用しています。
(Terrasnek: https://github.com/dahlke/terrasnek )
実際に実行履歴やWorkspaceを取得しているPythonサンプルコードは次のようなものです。
import os import boto3 import terrasnek.api import json from datetime import datetime s3 = boto3.resource('s3') bucket = 'terraform-cloud-bucket' tfc = terrasnek.api.TFC(os.environ['TF_TOKEN'], url=os.environ['TF_URL'], verify=True) tfc.set_org(os.environ['TF_ORGANIZATION']) page_size = 100 def lambda_handler(event, context): workspaces = get_workspaces() for workspace in workspaces: runs = get_runs(workspace['id']) for run in runs: key = os.environ['TF_ORGANIZATION'] + '/runs/' + run['id'] + '.json' obj = s3.Object(bucket,key) obj.put( Body=json.dumps(run) ) return def get_workspaces(page=1): workspaces = tfc.workspaces.list(page=page, page_size=page_size) if not workspaces['meta']['pagination']['next-page'] is None: ret = get_workspaces(workspaces['meta']['pagination']['next-page']) workspaces['data'].extend(ret) return(workspaces['data']) def get_runs(workspace_id, page=1): runs = tfc.runs.list(workspace_id, page=page, page_size=page_size) if not runs['meta']['pagination']['next-page'] is None: ret = get_runs(workspace_id, runs['meta']['pagination']['next-page']) runs['data'].extend(ret) return(runs['data'])
import os import boto3 import terrasnek.api import json from datetime import datetime s3 = boto3.resource('s3') bucket = 'terraform-cloud-bucket' tfc = terrasnek.api.TFC(os.environ['TF_TOKEN'], url=os.environ['TF_URL'], verify=True) tfc.set_org(os.environ['TF_ORGANIZATION']) page_size = 100 def lambda_handler(event, context): workspaces = get_workspaces() for workspace in workspaces: key = os.environ['TF_ORGANIZATION'] + '/workspaces/' + workspace['id'] + '.json' obj = s3.Object(bucket,key) obj.put( Body=json.dumps(workspace) ) return def get_workspaces(page=1): workspaces = tfc.workspaces.list(page=page, page_size=page_size) if not workspaces['meta']['pagination']['next-page'] is None: ret = self.get_workspaces(workspaces['meta']['pagination']['next-page']) workspaces['data'].extend(ret) return(workspaces['data'])
これらスクリプトでワークスペース、実行履歴全レコードのJSONを取得し、S3の所定のパスに1レコード1ファイルで保存します。
HTTPリクエストやレスポンスの処理、JSONをリスト型で読み込む処理などがterrasnek側で解決しているので、Pythonの処理はかなりすっきりしたものにできました。
次に、Athenaを利用して必要なレコードを抽出するのですが、その際に使用したDDLのサンプルを紹介します。
CREATE EXTERNAL TABLE runs ( id string, relationships struct< workspace:struct< data:struct< id:string > > >, attributes struct < `auto-apply`:string, `error-text`:string, `is-destroy`:string, message:string, source:string, status:string, `status-timestamps`:struct< `plan-queued-at`:string, `planning-at`:string, `planned-at`:string, `cost-estimating-at`:string, `cost-estimated-at`:string, `policy-checked-at`:string, `confirmed-at`:string, `planned-and-finished-at`:string, `apply-queued-at`:string, `applying-at`:string, `applied-at`:string, `discarded-at`:string, `errored-at`:string, `canceled-at`:string, `force-canceled-at`:string >, `terraform-version`:string, `created-at`:string, `canceled-at`:string, `has-changes`:string, actions:struct< `is-cancelable`:boolean, `is-confirmable`:boolean, `is-discardable`:boolean, `is-force-cancelable`:boolean > > ) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' WITH SERDEPROPERTIES ('ignore.malformed.json' = 'true') LOCATION 's3://terraform-cloud-bucket/HOGEHOGE/runs/' ;
CREATE EXTERNAL TABLE workspaces ( id string, attributes struct < name:string, `created-at`:string, `terraform-version`:string, `working-directory`:string, `speculative-enabled`:boolean, `allow-destroy-plan`:boolean, `auto-destroy-at`:string, `latest-change-at`:string, operations:boolean, `execution-mode`:string, `description`:string, `file-triggers-enabled`:boolean, `trigger-prefixes`:array < string >, `vcs-repo`:struct< branch:string, `ingress-submodules`:boolean, identifier:string, `oauth-token-id`:string, `webhook-url`:string > > ) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' WITH SERDEPROPERTIES ('ignore.malformed.json' = 'true') LOCATION 's3://terraform-cloud-bucket/HOGEHOGE/workspaces/' ;
ここでは抜き出したいフィールドをピックアップしたうえで、ワークスペース用、実行履歴用のテーブルを作成しています。
ただし、このままではJSONのネスト構造を持ったままテーブルが作成されているので、可読性のあるレコードを作成するためには非常に手間のかかるSELECT文を発行する必要があります。
また、せっかくAthenaにしたわけですから、RDBのように関連するものは一回のクエリで解決したいとも思いました。そこで、いったんER図に各データの関連を書き起こし、ビューを作成することにしました。
その際のサンプルがこの図になります。
そして、不足フィールドを補ったり、スプレッドシートに最適化させた書式にしたりといった加工を行ったビューを作成しました。
CREATE VIEW runs_history AS SELECT "terraform_cloud"."workspaces"."attributes"."name", "terraform_cloud"."runs"."id", "terraform_cloud"."runs"."attributes"."status", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."created-at"), '%Y-%m-%d %H:%i:%S') as "created-at", date_format(from_iso8601_timestamp(CASE WHEN "terraform_cloud"."runs"."attributes"."status" = 'policy_checking' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimated-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'policy_override' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimated-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'policy_soft_failed' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimated-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'plan_queued' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."plan-queued-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'planning' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."planning-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'planned' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."planned-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'cost_estimating' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimating-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'cost_estimated' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimated-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'policy_checked' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."policy-checked-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'confirmed' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."confirmed-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'planned_and_finished' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."planned-and-finished-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'apply_queued' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."apply-queued-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'applying' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."applying-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'applied' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."applied-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'discarded' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."discarded-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'errored' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."errored-at" WHEN "terraform_cloud"."runs"."attributes"."status" = 'canceled' THEN CASE WHEN "terraform_cloud"."runs"."attributes"."status-timestamps"."canceled-at" IS null THEN "terraform_cloud"."runs"."attributes"."canceled-at" ELSE "terraform_cloud"."runs"."attributes"."status-timestamps"."canceled-at" END WHEN "terraform_cloud"."runs"."attributes"."status" = 'force_canceled' THEN "terraform_cloud"."runs"."attributes"."status-timestamps"."force-canceled-at" END), '%Y-%m-%d %H:%i:%S') as "end-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."plan-queued-at"), '%Y-%m-%d %H:%i:%S') as "plan-queued-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."planning-at"), '%Y-%m-%d %H:%i:%S') as "planning-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."planned-at"), '%Y-%m-%d %H:%i:%S') as "planned-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimating-at"), '%Y-%m-%d %H:%i:%S') as "cost-estimating-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."cost-estimated-at"), '%Y-%m-%d %H:%i:%S') as "cost-estimated-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."policy-checked-at"), '%Y-%m-%d %H:%i:%S') as "policy-checked-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."confirmed-at"), '%Y-%m-%d %H:%i:%S') as "confirmed-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."planned-and-finished-at"), '%Y-%m-%d %H:%i:%S') as "planned-and-finished-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."apply-queued-at"), '%Y-%m-%d %H:%i:%S') as "apply-queued-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."applying-at"), '%Y-%m-%d %H:%i:%S') as "applying-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."applied-at"), '%Y-%m-%d %H:%i:%S') as "applied-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."discarded-at"), '%Y-%m-%d %H:%i:%S') as "discarded-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."errored-at"), '%Y-%m-%d %H:%i:%S') as "errored-at", date_format(from_iso8601_timestamp(CASE WHEN "terraform_cloud"."runs"."attributes"."status-timestamps"."canceled-at" IS null THEN "terraform_cloud"."runs"."attributes"."canceled-at" ELSE "terraform_cloud"."runs"."attributes"."status-timestamps"."canceled-at" END), '%Y-%m-%d %H:%i:%S') as "canceled-at", date_format(from_iso8601_timestamp("terraform_cloud"."runs"."attributes"."status-timestamps"."force-canceled-at"), '%Y-%m-%d %H:%i:%S') as "force-canceled-at" FROM "terraform_cloud"."runs" LEFT JOIN "terraform_cloud"."workspaces" ON "terraform_cloud"."runs"."relationships"."workspace"."data"."id" = "terraform_cloud"."workspaces"."id" ORDER BY "terraform_cloud"."runs"."attributes"."created-at" ASC ;
これらのテーブルやビューを作成したことで、単純なSQLで解りやすいデータが抜き出せるようになりました。
先に紹介したスプレッドーシートも、このVIEWの結果をCSVダウンロードしてそのまま取り込んでいます。
4.Notificationを利用してWebhook経由でDatadogのダッシュボードで可視化
先に紹介しましたSlackへのNotificationですが、その他のURLにも通知することが可能です。
Webhookを受けるサーバを構築すれば、それを踏み台にしてDatadogに実行履歴を送ることができます。
Terraformから送られてくるNotificationのrequest body
{
"payload_version": 1,
"notification_configuration_id": "nc-HOGEHOGEHOGEHOGE",
"run_url": "https://app.terraform.io/app/HOGEHOGE/kurose-tfce-test-dev/runs/run-hogehogehogehoge",
"run_id": "run-hogehogehogehoge",
"run_message": "Queued manually in Terraform Cloud",
"run_created_at": "2021-04-01T09:33:04.000Z",
"run_created_by": "kurose-etsuo",
"workspace_id": "ws-hogeHOGEhoge,
"workspace_name": "kurose-tfce-test-dev",
"organization_name": "HOGEHOGE",
"notifications": [
{
"message": "Run Planned and Finished",
"trigger": "run:completed",
"run_status": "planned_and_finished",
"run_updated_at": "2021-04-01T09:33:50.000Z",
"run_updated_by": null
}
]
}
Terraform Cloudから送られるデータは上記のようなJSONなのですが、それをそのまま下記のようなスクリプトでログとしてDatadogに渡し、.notifications.run_statusの値でフィルタしメトリクスとして集計することで、ダッシュボードでモニタリングすることも可能になりました。
import json import requests notifications = 受け取った通知JSON def main(): headers = {"Content-Type": "application/json", "DD-API-KEY": "DATADOG_API_KEY"} log_context = { "ddsource": "terraform-cloud", "service": "terraform-cloud-ip-ranges", "message": notifications } r = requests.post("https://http-intake.logs.datadoghq.com/v1/input", data=json.dumps(log_context), headers=headers) if __name__ == '__main__': main()
余談になりますが、Terraform Cloudで使用されているIPアドレスも他のシステムとの連携で必要になるので、実行履歴と同様にDatadogにて変更を検知できるようダッシュボードにてモニタリングしています。
可視化における今後の課題
Terraform Cloud Business TierのLogging
これまでの内容は、主に利用開始当時のTerraform Cloudで実装したお話で、Terraform Cloud Free や Terraform Cloud Team & Governance でも利用可能であることを前提として書きました。
しかし、昨秋リリースされたTerraform Cloud Business Tierには標準で利用レポート機能が実装されましたので、特にユーザの手を煩わせることなく可視化されています。
さらに、Audit Trail機能(※)でREST APIを経由してTerraform Cloud上での操作ログを取得し、それをDatadogに渡すことでより詳細なダッシュボードを作成できるのではないかと考えています。いずれ、この部分の評価を行い、業務に役立つかを検討をしたいと思います。
(※)Terraform Cloud Business Tierで実装された操作に関する証跡を取る機能
まとめ
Terraform Cloudはインフラ構築とその自動化において非常に便利で強力なツールです。 今回の記事では、それに一手間を加えることで管理面でも便利にできることと、さらなる発展の可能性を紹介しました。
日々進化するSaaSを使いこなすのも、これからのインフラエンジニアに求められるスキルだと考えています。
最後に、EXNOAプラットフォームインフラ部では、様々なことに興味を持ち、業務を良くしていこうと一緒に考えていただけるインフラエンジニアを募集しています。
ご興味のある方は下記からお気軽にご応募ください。
https://dmmgames.co.jp/recruit/entry/?contents_type=107&search_ext_col_02=