APC 技術ブログ

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

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

HCP Terraform APIで管理対象リソース(RUM)をProject単位で集計する

はじめに

こんにちは、ACS事業部の小原です。

HCP Terraformをご利用中の皆さん、管理対象リソース(RUM: Resources Under Management)の把握はどのようにされていますか? RUMは請求金額と関係があるため、「どのように確認できるのか気になる」という方も多いのではないでしょうか。

現在のHCP TerraformのGUIでは、Organization単位でRUM数を確認できます。しかし、利用部門やチームごとにProjectが割り当てられているケースでは、Project単位で集計したい場面も多いのではないでしょうか。 そのような場合、GUIだけでは対応が難しく、少し工夫が必要になります。

本記事では、HCP Terraform APIを活用し、Project単位でRUMを集計する方法をPythonスクリプトを用いてご紹介します。
ここでお伝えしたいことは、スクリプトの細かい内容ではありません。「Workspaces API referenceとProjects APIを組み合わせて、実用的な集計を行うことができる」という点をお伝えしたいです。

RUMに関する詳細は、以下ドキュメントをご覧ください。


HCP TerraformにおけるProjectとWorkspace

HCP Terraformでのリソース管理において、ProjectとWorkspaceは重要な概念です。まず、これら二つの関係性について整理します。

  • Organization:最上位の階層で、すべてを包含します。
  • Project:関連するWorkspaceをグループ化する単位です。例えば、特定のアプリケーションやチームごとにWorkspaceをまとめることができます。
  • Workspace:Terraformの設定ファイル(.tf ファイル)を実行する環境です。個々のインフラストラクチャを管理する単位となります。RUMは基本的にWorkspaceに紐付いています。

Organizationの下に複数のProjectがあり、各Projectの下に複数のWorkspaceが配置できます。 現状、Project単位でのRUM集計はAPIで直接サポートされていません。そのため、各WorkspaceからRUM情報を取得し、それをProjectに紐付けて集計する必要があります。


集計方法の検討

Project単位でのRUMを集計するためには、以下の情報取得と加工が必要です。

  • ProjectのIDと名前
  • WorkspaceのID、名前、属しているProject
  • Workspaceに紐づくリソース情報

これらの情報をHCP Terraform APIを利用して取得し、最終的にProjectごとにRUMを合算するロジックを実装します。


集計スクリプトの実装

Pythonスクリプトを使って Project単位の RUM を集計する方法をご紹介します。 APIリクエストには requests ライブラリを利用します。

動作確認環境

  • Python:3.12.8
  • requests:2.32.3

事前準備

HCP Terraform APIを利用するには、APIトークンが必要です。HCP Terraformの設定画面から発行してください。 APIトークンはセキュリティの観点から直接スクリプトに書き込まないようにします。今回はOrganization名とともに環境変数で設定します。

export TFC_TOKEN="your-hcp-terraform-api-token"
export TFC_ORG_NAME="your-organization-name"

スクリプトの概要

  1. Organization内のすべてのWorkspaceをページングで取得
  2. 各Workspaceについて、Workspace名・所属ProjectのID・Project名を取得
  3. 各Workspace のリソース一覧を取得し、RUM数(data.やnull_resourceは除外)をカウント
  4. Projectごとにワークスペース名とRUM数を集計用データ構造に追加
  5. 集計結果をプロジェクト単位・ワークスペース単位で降順表示

なお、本記事の目的であるRUMのProject単位での集計方法に主眼を置くため、エラーハンドリングは省略しています。


コード解説

1. APIトークンとOrganization名の準備

TFC_TOKEN = os.environ.get("TFC_TOKEN")
TFC_ORG_NAME = os.environ.get("TFC_ORG_NAME")
HEADERS = { "Authorization": f"Bearer {TFC_TOKEN}" } # API認証用ヘッダー

環境変数からトークンとOrganization名を取得し、API認証用ヘッダーを作成します。

トークンでの認証や基本的なAPI操についての詳細は、以下ドキュメントをご覧ください。

2. Workspace一覧をページングで取得

def fetch_workspaces(session):
    """Workspace一覧をページングで取得"""
    workspaces = []  # 取得したワークスペースを格納するリスト
    page = 1  # ページ番号の初期値
    total_pages = None  # 総ページ数(最初は未取得)
    while True:
        # ページごとにWorkspace一覧APIを呼び出す
        url = f"https://app.terraform.io/api/v2/organizations/{TFC_ORG_NAME}/workspaces?page[number]={page}"
        resp = session.get(url)
        data = resp.json()
        if total_pages is None:
            total_pages = data["meta"]["pagination"]["total-pages"]  # 総ページ数を取得
        workspaces.extend(data["data"])  # ワークスペース情報を追加
        if page >= total_pages:  # 最後のページなら終了
            break
        page += 1  # 次のページへ
    return workspaces

APIは1リクエストで全Workspaceを返さないため、ページ番号を使って繰り返し取得します。

3. Project IDからProject名を取得

def fetch_project_name(session, project_id):
    """Project名の取得"""
    proj_resp = session.get(f"https://app.terraform.io/api/v2/projects/{project_id}")
    return proj_resp.json()["data"]["attributes"]["name"]

WorkspaceオブジェクトにはProject名が含まれないため、別途APIで取得します。

Projects APIに関する詳細は、以下ドキュメントをご覧ください。

4. Workspaceのリソース一覧を取得

def fetch_resources(session, ws_id):
    """リソース一覧をページングで取得"""
    resources = []  # 取得したリソースを格納するリスト
    res_page = 1  # ページ番号の初期値
    while True:
        # ページごとにリソース一覧APIを呼び出す
        res_url = f"https://app.terraform.io/api/v2/workspaces/{ws_id}/resources?page[number]={res_page}"
        res_resp = session.get(res_url).json()
        res_data = res_resp.get("data", [])
        resources += res_data  # リソース情報を追加
        # 次のページがなければ終了
        if not res_resp.get("meta", {}).get("pagination", {}).get("next-page"):
            break
        res_page += 1  # 次のページへ
    return resources

Workspace Resources APIを利用して、各Workspaceで管理されているリソース情報を取得します。 リソース一覧もページングで取得します。

Workspace Resources APIに関する詳細は、以下ドキュメントをご覧ください。

5. RUMのカウント

def count_rum(resources):
    """RUMカウント(除外: data. と null_resource)"""
    return sum(
        1 for r in resources
        if not r["attributes"]["address"].startswith(("data.", "null_resource"))
    )

resources リストから、RUM課金対象外のリソース(data, null_resource)は除外してカウントします。 以下ドキュメントに記載されているように、HCP Terraformは、null_resourceまたはterraform_dataリソースとして定義されたリソースを、マネージドリソースの総数に含めないためです。

6. プロジェクトごとにワークスペース名とRUM数を集計

project_data[project_name].append({
    "workspace": ws_name,
    "rum": rum
})

project_datadefaultdict(list) です。通常の辞書(dict)と違って、キーが未定義でもlistで初期化されるdefaultdict(list)を使用しました。 プロジェクト名をキーに、ワークスペース名とRUM数の辞書をリスト形式で追加しています。
これにより、後続の集計や出力処理がシンプルになります。

7. 集計結果の出力

def print_summary(project_data):
    """集計結果の出力"""
    total_projects = len(project_data)  # プロジェクト数
    total_workspaces = sum(len(ws_list) for ws_list in project_data.values())  # ワークスペース数
    print(f"Found {total_projects} active Projects and {total_workspaces} Workspaces.\n")

    # プロジェクトごとのRUM合計で降順ソート
    sorted_projects = sorted(
        project_data.items(),
        key=lambda item: sum(ws["rum"] for ws in item[1]),
        reverse=True
    )
    for project, ws_list in sorted_projects:
        project_total_rum = sum(ws["rum"] for ws in ws_list)  # プロジェクト単位のRUM合計
        print(f"{project} (Total RUM: {project_total_rum})")
        # ワークスペースごとのRUMで降順ソート
        for ws in sorted(ws_list, key=lambda x: x["rum"], reverse=True):
            print(f"    - {ws['workspace']}: {ws['rum']}")

プロジェクト単位・ワークスペース単位でRUM数を降順で出力します。
それらがどれだけリソースを管理しているかが一目で分かるようにするためです。


完成したコード全体

import os
from collections import defaultdict

import requests

TFC_TOKEN = os.environ.get("TFC_TOKEN")
TFC_ORG_NAME = os.environ.get("TFC_ORG_NAME")
HEADERS = { "Authorization": f"Bearer {TFC_TOKEN}" }

def fetch_workspaces(session):
    """Workspace一覧をページングで取得"""
    workspaces = []
    page = 1
    total_pages = None
    while True:
        url = f"https://app.terraform.io/api/v2/organizations/{TFC_ORG_NAME}/workspaces?page[number]={page}"
        resp = session.get(url)
        data = resp.json()
        if total_pages is None:
            total_pages = data["meta"]["pagination"]["total-pages"]
        workspaces.extend(data["data"])
        if page >= total_pages:
            break
        page += 1
    return workspaces

def fetch_project_name(session, project_id):
    """Project名の取得"""
    proj_resp = session.get(f"https://app.terraform.io/api/v2/projects/{project_id}")
    return proj_resp.json()["data"]["attributes"]["name"]

def fetch_resources(session, ws_id):
    """リソース一覧をページングで取得"""
    resources = []
    res_page = 1
    while True:
        res_url = f"https://app.terraform.io/api/v2/workspaces/{ws_id}/resources?page[number]={res_page}"
        res_resp = session.get(res_url).json()
        res_data = res_resp.get("data", [])
        resources += res_data
        if not res_resp.get("meta", {}).get("pagination", {}).get("next-page"):
            break
        res_page += 1
    return resources

def count_rum(resources):
    """RUMカウント(除外: data. と null_resource)"""
    return sum(
        1 for r in resources
        if not r["attributes"]["address"].startswith(("data.", "null_resource"))
    )

def print_summary(project_data):
    """集計結果の出力"""
    total_projects = len(project_data)
    total_workspaces = sum(len(ws_list) for ws_list in project_data.values())
    print(f"Found {total_projects} active Projects and {total_workspaces} Workspaces.\n")

    sorted_projects = sorted(
        project_data.items(),
        key=lambda item: sum(ws["rum"] for ws in item[1]),
        reverse=True
    )
    for project, ws_list in sorted_projects:
        project_total_rum = sum(ws["rum"] for ws in ws_list)
        print(f"{project} (Total RUM: {project_total_rum})")
        for ws in sorted(ws_list, key=lambda x: x["rum"], reverse=True):
            print(f"    - {ws['workspace']}: {ws['rum']}")

def main():
    with requests.Session() as session:
        session.headers.update(HEADERS)
        workspaces = fetch_workspaces(session)
        project_data = defaultdict(list)
        for ws in workspaces:
            ws_id = ws["id"]
            ws_name = ws["attributes"]["name"]
            project_id = ws.get("relationships", {}).get("project", {}).get("data", {}).get("id")
            project_name = fetch_project_name(session, project_id)
            resources = fetch_resources(session, ws_id)
            rum = count_rum(resources)
            project_data[project_name].append({
                "workspace": ws_name,
                "rum": rum
            })
        print_summary(project_data)

if __name__ == "__main__":
    main()

実行結果

実際に上記のスクリプトを実行した結果の一部です。(内容はマスクしています)

Found 8 active Projects and 43 Workspaces.

Project-www (Total RUM: 21)
    - ws-a: 16
    - ws-b: 2
    - ws-c: 1
    - ws-d: 1
    - ws-e: 1
    - ws-f: 0
    - ws-g: 0
    - ws-h: 0
Project-xxx (Total RUM: 9)
    - ws-i: 6
    - ws-j: 3
    - ws-k: 0
    - ws-l: 0
    - ws-m: 0
Project-yyy (Total RUM: 8)
    - ws-n: 4
    - ws-o: 4
    - ws-p: 0
    - ws-q: 0
Project-zzz (Total RUM: 7)
    - ws-r: 6
    - ws-s: 1
    - ws-t: 0

このように、各WorkspaceがどのProjectに属し、それぞれがいくつのRUMを管理しているかが分かります。 これらの把握から規模感を俯瞰するだけでなく、リソース計画に役立てることができそうです。


まとめ

本記事では、HCP TerraformにおけるProject単位でのRUM集計の重要性と、HCP Terraform APIを活用した具体的な集計方法をPythonスクリプトでご紹介しました。 この方法を用いることで、Organizationレベルでは把握しにくかったProjectごとのRUMを可視化し、より詳細な分析やリソース管理に役立てることができます。 また、対象リソースを絞ったり、定期的にスクリプトを実行して自動集計・レポートする仕組みを構築するのも有効だと思います。 本記事が皆さんの HCP Terraform 運用の一助となれば幸いです。


ACS事業部のご紹介

私達ACS事業部はクラウドネイティブ技術、Azure AI サービス、Platform Engineering、AI駆動開発支援などを通して、攻めのDX成功に向けた開発者体験・開発生産性の向上・内製化のご支援をしております。
www.ap-com.co.jp www.ap-com.co.jp また、一緒に働いていただける仲間も募集中です!
今年もまだまだ組織規模拡大中なので、ご興味持っていただけましたらぜひお声がけください。 www.ap-com.co.jp

本記事の投稿者: 小原 丈明
Azureをメインにインフラ系のご支援を担当しています。