コスト管理の自動レスポンスの例

リファレンス アーキテクチャの例

予算アラートに関するプログラムによる通知を使用して費用管理レスポンスを自動化する例を示した図
図 1: 予算アラートを使用して、プログラムによる通知に Pub/Sub を使用して費用管理レスポンスを自動化し、Cloud Functions でレスポンスを自動化する例を示す図。

費用を重視し、予算に関連して環境を制御する必要がある場合は、プログラムによる予算通知を使用して、予算通知に基づいて費用管理レスポンスを自動化できます。

予算通知では、Pub/Sub トピックを使用して、クラウド向けのエンタープライズ メッセージ指向ミドルウェアが持つスケーラビリティ、柔軟性、信頼性を活用し Cloud Billing の予算のステータスをリアルタイムで示します。

このページでは、Cloud Functions で予算通知を使用して費用管理を自動化する方法の例と詳しい手順を示します。

予算通知を設定する

最初のステップとして、予算の Pub/Sub トピックを有効にします。詳細については、プログラムによる予算アラート通知を管理するをご覧ください。

予算通知を有効にしたら、次の点に注意してください。

  • Pub/Sub トピック: これは、予算用に構成された通知エンドポイントです。
  • 予算 ID: すべての通知に含まれる予算の一意な ID です。予算の ID は [通知の管理] に表示されます。[Pub/Sub トピックをこの予算に接続する] を選択すると、ID が表示されます。

Google Cloud Console の [通知の管理] セクションでは、Pub/Sub サブトピックを予算に関連付けることができます。ここには、予算 ID、プロジェクト名、Pub/Sub トピックが含まれます。

通知をリッスンする

次に、Pub/Sub トピックに登録して通知をリッスンします。サブスクライバーがない場合、公開されたメッセージは Pub/Sub で削除され、後で取得することはできません。

トピックに登録するにはさまざまな方法がありますが、この例では Cloud Function のトリガーを使用します。

Cloud Function を作成

新しい Cloud Functions の関数を作成するには:

  1. コンソールで、[Cloud Functions] ページに移動します。

    [Cloud Functions] ページに移動

  2. [ 関数を作成] をクリックして、予算に対して意味を持つ名前を付けます。

  3. [トリガー] で、[Cloud Pub/Sub] を選択します。

  4. 予算に構成したトピックを選択します。

  5. 関数を実行するためのソースコードと依存関係を指定します。

  6. [実行する関数] を正しい関数名に設定します。

コンソールの Cloud Functions セクションにある [関数の作成] ページ。関数名、割り当てられたメモリ量、トリガーのタイプ、予算で構成した Pub/Sub トピックが含まれます。

Cloud Functions の関数を記述する

通知で実行することを Cloud Function に指示するには、インライン エディタを使用してコードを記述するか、ファイルをアップロードします。コードが受け取る通知の詳細については、通知形式をご覧ください。

たとえば、関数は予算通知によってトリガーされると、受信した Pub/Sub 通知、属性、データをログに記録します。詳細については、Pub/Sub トリガーをご覧ください。

Cloud Function イベントを表示する

Cloud Function を保存した後、[ログを表示] をクリックすると、ログに記録された予算通知を表示できます。これには、関数呼び出しからのログが表示されます。

画面上でログを表示し、コンソールの Cloud Functions の関数のイベントを確認できます。

Cloud Functions の関数をテストする

通知が Pub/Sub に送信され、登録者がメッセージを受信します。サンプル通知をテストして、関数が意図したとおりに機能していることを確認するには、このオブジェクトをメッセージ本文として Pub/Sub で次のようなメッセージを発行します。

{
    "budgetDisplayName": "name-of-budget",
    "alertThresholdExceeded": 1.0,
    "costAmount": 100.01,
    "costIntervalStart": "2019-01-01T00:00:00Z",
    "budgetAmount": 100.00,
    "budgetAmountType": "SPECIFIED_AMOUNT",
    "currencyCode": "USD"
}

請求先アカウント ID などのメッセージ属性を追加することもできます。詳細については、通知形式のドキュメントをご覧ください。

Slack に通知を送信する

特に予算が重要で時間的制約がある場合、必ずしもメールがクラウド費用の状況を把握する最適な方法とは限りません。通知では、予算メッセージを他のメディアに転送できます。

この例では、予算通知を Slack に転送する方法を説明します。この方法では、Cloud Billing で予算通知が発行されるたびに、Cloud Function がボットを使用してボットのワークスペースの Slack チャネルにメッセージを投稿します。

Slack チャネルと権限を設定する

最初に、Slack ワークスペースと、Slack API を呼び出すためのボットユーザー トークンを作成します。API トークンは、https://api.slack.com/apps で管理できます。詳細については、Slack サイトの Bot Users をご覧ください。

Slack 通知を構成します。

Cloud Functions の関数を記述する

  1. Cloud Function を作成するの手順で、新しい関数を作成します。使用する予算と同じ Pub/Sub トピックをトリガーに設定します。

  2. 依存関係を追加します。

    Node.js

    以下のコードを package.json にコピーします。

    {
      "name": "cloud-functions-billing",
      "private": "true",
      "version": "0.0.1",
      "description": "Examples of integrating Cloud Functions with billing",
      "main": "index.js",
      "engines": {
        "node": ">=12.0.0"
      },
      "scripts": {
        "compute-test": "mocha test/periodic.test.js --timeout=600000",
        "test": "mocha test/index.test.js --timeout=5000 --exit"
      },
      "author": "Ace Nassri <anassri@google.com>",
      "license": "Apache-2.0",
      "dependencies": {
        "@google-cloud/billing": "^3.0.0",
        "@google-cloud/compute": "^3.1.0",
        "google-auth-library": "^7.0.0",
        "googleapis": "^102.0.0",
        "slack": "^11.0.1"
      },
      "devDependencies": {
        "@google-cloud/functions-framework": "^3.0.0",
        "gaxios": "^4.3.0",
        "mocha": "^9.0.0",
        "promise-retry": "^2.0.0",
        "proxyquire": "^2.1.0",
        "sinon": "^14.0.0",
        "wait-port": "^0.3.0"
      }
    }
    

    Python

    以下のコードを requirements.txt にコピーします。

    slackclient==2.9.4
    google-api-python-client==2.47.0
    

  3. Slack API を使用して Slack チャット チャネルに予算通知を投稿するコードを記述、または次の例を使用します。

  4. 次の Slack API postMessage のパラメータが正しく設定されていることを確認します。

    • ボットユーザー OAuth アクセス トークン
    • チャネルの名前

コードの例:

Node.js

const slack = require('slack');

// TODO(developer) replace these with your own values
const BOT_ACCESS_TOKEN =
  process.env.BOT_ACCESS_TOKEN || 'xxxx-111111111111-abcdefghidklmnopq';
const CHANNEL = process.env.SLACK_CHANNEL || 'general';

exports.notifySlack = async pubsubEvent => {
  const pubsubAttrs = pubsubEvent.attributes;
  const pubsubData = Buffer.from(pubsubEvent.data, 'base64').toString();
  const budgetNotificationText = `${JSON.stringify(
    pubsubAttrs
  )}, ${pubsubData}`;

  await slack.chat.postMessage({
    token: BOT_ACCESS_TOKEN,
    channel: CHANNEL,
    text: budgetNotificationText,
  });

  return 'Slack notification sent successfully';
};

Python

import base64
import json
import os
import slack
from slack.errors import SlackApiError
# See https://api.slack.com/docs/token-types#bot for more info
BOT_ACCESS_TOKEN = 'xxxx-111111111111-abcdefghidklmnopq'
CHANNEL = 'C0XXXXXX'

slack_client = slack.WebClient(token=BOT_ACCESS_TOKEN)

def notify_slack(data, context):
    pubsub_message = data

    # For more information, see
    # https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification_format
    try:
        notification_attr = json.dumps(pubsub_message['attributes'])
    except KeyError:
        notification_attr = "No attributes passed in"

    try:
        notification_data = base64.b64decode(data['data']).decode('utf-8')
    except KeyError:
        notification_data = "No data passed in"

    # This is just a quick dump of the budget data (or an empty string)
    # You can modify and format the message to meet your needs
    budget_notification_text = f'{notification_attr}, {notification_data}'

    try:
        slack_client.api_call(
            'chat.postMessage',
            json={
                'channel': CHANNEL,
                'text'   : budget_notification_text
            }
        )
    except SlackApiError:
        print('Error posting to Slack')

これで、Cloud Function をテストして、Slack にメッセージが表示されることを確認できるようになりました。

課金に上限を設定(課金を無効化)して使用を停止する

この例では、費用の上限を設定し、Cloud Billing を無効にしてプロジェクトの使用を停止します。プロジェクトに対する課金を無効にすると、無料枠サービスを含め、プロジェクト内のすべての Google Cloud サービスが停止されます。

課金を無効にする理由

Google Cloud に支出できる金額に厳しい制限があるため、費用に上限を設定したい場合があります。これは、サンドボックス環境で作業する学生、研究者、開発者にとってよくあることです。このような場合、予算の上限に達したら支出を停止し、Google Cloud Platform のすべてのサービスと使用を停止したいと考えるかもしれません。

この例では、Cloud Billing を安全に無効にできる非本番環境プロジェクトとして acme-backend-dev を使用します。

コンソールで予算上限を構成します。

この例の作業を開始する前に、次のことが完了していることを確認してください。

  • Cloud Billing API を有効にする。Cloud Functions の関数で Cloud Billing API を呼び出し、プロジェクトの Cloud Billing を無効にする必要があります。

  • プロジェクトの費用をモニタリングする予算を設定し、予算通知を有効にする。

コンソールに Cloud Billing アラートのリストが表示されます。

Cloud Functions の関数を記述する

次に、Cloud Billing API を呼び出すように Cloud Function を構成する必要があります。これにより、Cloud Function がサンプル プロジェクト acme-backend-dev の Cloud Billing を無効にできます。

  1. Cloud Function を作成するの手順で、新しい関数を作成します。使用する予算と同じ Pub/Sub トピックをトリガーに設定します。

  2. 次の依存関係を追加します。

    Node.js

    以下のコードを package.json にコピーします。

    {
      "name": "cloud-functions-billing",
      "private": "true",
      "version": "0.0.1",
      "description": "Examples of integrating Cloud Functions with billing",
      "main": "index.js",
      "engines": {
        "node": ">=12.0.0"
      },
      "scripts": {
        "compute-test": "mocha test/periodic.test.js --timeout=600000",
        "test": "mocha test/index.test.js --timeout=5000 --exit"
      },
      "author": "Ace Nassri <anassri@google.com>",
      "license": "Apache-2.0",
      "dependencies": {
        "@google-cloud/billing": "^3.0.0",
        "@google-cloud/compute": "^3.1.0",
        "google-auth-library": "^7.0.0",
        "googleapis": "^102.0.0",
        "slack": "^11.0.1"
      },
      "devDependencies": {
        "@google-cloud/functions-framework": "^3.0.0",
        "gaxios": "^4.3.0",
        "mocha": "^9.0.0",
        "promise-retry": "^2.0.0",
        "proxyquire": "^2.1.0",
        "sinon": "^14.0.0",
        "wait-port": "^0.3.0"
      }
    }
    

    Python

    以下のコードを requirements.txt にコピーします。

    slackclient==2.9.4
    google-api-python-client==2.47.0
    

  3. 以下のコードを Cloud Functions の関数にコピーします。

  4. 実行する関数を stopBilling(Node)または stop_billing(Python)に設定します。

  5. ランタイムによっては、GCP_PROJECT 環境変数が自動的に設定される場合があります。自動的に設定される環境変数のリストを確認し、Cloud Billing に上限を設定する(無効にする)対象プロジェクトに GCP_PROJECT 変数を手動で設定する必要があるかどうかを判断します。

Node.js

const {CloudBillingClient} = require('@google-cloud/billing');
const {InstancesClient} = require('@google-cloud/compute');

const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
const billing = new CloudBillingClient();

exports.stopBilling = async pubsubEvent => {
  const pubsubData = JSON.parse(
    Buffer.from(pubsubEvent.data, 'base64').toString()
  );
  if (pubsubData.costAmount <= pubsubData.budgetAmount) {
    return `No action necessary. (Current cost: ${pubsubData.costAmount})`;
  }

  if (!PROJECT_ID) {
    return 'No project specified';
  }

  const billingEnabled = await _isBillingEnabled(PROJECT_NAME);
  if (billingEnabled) {
    return _disableBillingForProject(PROJECT_NAME);
  } else {
    return 'Billing already disabled';
  }
};

/**
 * Determine whether billing is enabled for a project
 * @param {string} projectName Name of project to check if billing is enabled
 * @return {bool} Whether project has billing enabled or not
 */
const _isBillingEnabled = async projectName => {
  try {
    const [res] = await billing.getProjectBillingInfo({name: projectName});
    return res.billingEnabled;
  } catch (e) {
    console.log(
      'Unable to determine if billing is enabled on specified project, assuming billing is enabled'
    );
    return true;
  }
};

/**
 * Disable billing for a project by removing its billing account
 * @param {string} projectName Name of project disable billing on
 * @return {string} Text containing response from disabling billing
 */
const _disableBillingForProject = async projectName => {
  const [res] = await billing.updateProjectBillingInfo({
    name: projectName,
    resource: {billingAccountName: ''}, // Disable billing
  });
  return `Billing disabled: ${JSON.stringify(res)}`;
};

Python

import base64
import json
import os
from googleapiclient import discovery
PROJECT_ID = os.getenv('GCP_PROJECT')
PROJECT_NAME = f'projects/{PROJECT_ID}'
def stop_billing(data, context):
    pubsub_data = base64.b64decode(data['data']).decode('utf-8')
    pubsub_json = json.loads(pubsub_data)
    cost_amount = pubsub_json['costAmount']
    budget_amount = pubsub_json['budgetAmount']
    if cost_amount <= budget_amount:
        print(f'No action necessary. (Current cost: {cost_amount})')
        return

    if PROJECT_ID is None:
        print('No project specified with environment variable')
        return

    billing = discovery.build(
        'cloudbilling',
        'v1',
        cache_discovery=False,
    )

    projects = billing.projects()

    billing_enabled = __is_billing_enabled(PROJECT_NAME, projects)

    if billing_enabled:
        __disable_billing_for_project(PROJECT_NAME, projects)
    else:
        print('Billing already disabled')

def __is_billing_enabled(project_name, projects):
    """
    Determine whether billing is enabled for a project
    @param {string} project_name Name of project to check if billing is enabled
    @return {bool} Whether project has billing enabled or not
    """
    try:
        res = projects.getBillingInfo(name=project_name).execute()
        return res['billingEnabled']
    except KeyError:
        # If billingEnabled isn't part of the return, billing is not enabled
        return False
    except Exception:
        print('Unable to determine if billing is enabled on specified project, assuming billing is enabled')
        return True

def __disable_billing_for_project(project_name, projects):
    """
    Disable billing for a project by removing its billing account
    @param {string} project_name Name of project disable billing on
    """
    body = {'billingAccountName': ''}  # Disable billing
    try:
        res = projects.updateBillingInfo(name=project_name, body=body).execute()
        print(f'Billing disabled: {json.dumps(res)}')
    except Exception:
        print('Failed to disable billing, possibly check permissions')

サービス アカウント権限を構成する

Cloud Function は自動的に作成されたサービス アカウントとして実行されます。サービス アカウントで課金を無効にできるように、課金管理者などの適切な権限を付与する必要があります。

正しいサービス アカウントを特定するには、Cloud Function の詳細を表示します。サービス アカウントはページの下部に表示されます。

サービス アカウントの情報がコンソールの Cloud Functions の関数のセクションに表示されています。

課金管理者の権限は、コンソールの [お支払い] ページで管理できます。

サービス アカウントに請求先アカウント管理者の権限を付与するには、サービス アカウント名を選択します。

コンソールの [権限] セクションで、サービス アカウント名と請求アカウント管理者のロールを選択できます。

Cloud Billing が無効であることを確認する

予算による通知が送信されると、指定したプロジェクトから Cloud 請求先アカウントが削除されます。この関数のテストを行うには、上記のテスト メッセージと一緒にサンプル メッセージをパブリッシュします。プロジェクトは Cloud 請求先アカウントに表示されなくなり、同じプロジェクト内にある Cloud Functions の関数を含めプロジェクト内のリソースは無効になります。

Cloud 請求先アカウントにリンクされているプロジェクトのリストに、サンプル プロジェクトが表示されていません。これにより、プロジェクトで Cloud Billing が無効になっていることを確認できます。

コンソールで、プロジェクトの Cloud Billing を手動で有効に戻すことができます

使用を選択的に制御する

前の例で説明した Cloud Billing の上限設定(無効化)は、二分法的で極端なものです。プロジェクトは有効または無効のいずれかです。無効にすると、すべてのサービスが停止し、すべてのリソースは最終的に削除されます。

よりきめ細かなレスポンスが必要な場合は、リソースを選択的に制御できます。 たとえば、一部の Compute Engine リソースを停止し、Cloud Storage はそのままにする場合は、使用を選択的に制御できます。これにより、環境を完全に無効にすることなく、時間あたりのコストを削減できます。

きめ細かなポリシーを自由に記述できます。ただし、この例のプロジェクトでは、いくつかの Compute Engine 仮想マシンを使って研究を進め、結果を Cloud Storage に保存しています。この Cloud Function の例では、すべての Compute Engine インスタンスがシャットダウンされますが、予算を超えても保存された結果には影響しません。

Cloud Functions の関数を記述する

  1. Cloud Function を作成するの手順で、新しい関数を作成します。使用する予算と同じ Pub/Sub トピックをトリガーに設定します。

  2. 課金に上限を設定(課金を無効化)して使用を停止するの依存関係が追加されていることを確認します。

  3. 以下のコードを Cloud Functions の関数にコピーします。

  4. 実行する関数を limitUse(Node)または limit_use(Python)に設定します。

  5. ランタイムによっては、GCP_PROJECT 環境変数が自動的に設定される場合があります。自動的に設定される環境変数のリストを確認し、仮想マシンを実行するプロジェクトに GCP_PROJECT 変数を手動で設定する必要があるかどうかを判断します。

  6. ZONE パラメータを設定します。これは、このサンプルでインスタンスが停止されるゾーンです。

Node.js

const {CloudBillingClient} = require('@google-cloud/billing');
const {InstancesClient} = require('@google-cloud/compute');

const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
const instancesClient = new InstancesClient();
const ZONE = 'us-central1-a';

exports.limitUse = async pubsubEvent => {
  const pubsubData = JSON.parse(
    Buffer.from(pubsubEvent.data, 'base64').toString()
  );
  if (pubsubData.costAmount <= pubsubData.budgetAmount) {
    return `No action necessary. (Current cost: ${pubsubData.costAmount})`;
  }

  const instanceNames = await _listRunningInstances(PROJECT_ID, ZONE);
  if (!instanceNames.length) {
    return 'No running instances were found.';
  }

  await _stopInstances(PROJECT_ID, ZONE, instanceNames);
  return `${instanceNames.length} instance(s) stopped successfully.`;
};

/**
 * @return {Promise} Array of names of running instances
 */
const _listRunningInstances = async (projectId, zone) => {
  const [instances] = await instancesClient.list({
    project: projectId,
    zone: zone,
  });
  return instances
    .filter(item => item.status === 'RUNNING')
    .map(item => item.name);
};

/**
 * @param {Array} instanceNames Names of instance to stop
 * @return {Promise} Response from stopping instances
 */
const _stopInstances = async (projectId, zone, instanceNames) => {
  await Promise.all(
    instanceNames.map(instanceName => {
      return instancesClient
        .stop({
          project: projectId,
          zone: zone,
          instance: instanceName,
        })
        .then(() => {
          console.log(`Instance stopped successfully: ${instanceName}`);
        });
    })
  );
};

Python

import base64
import json
import os
from googleapiclient import discovery
PROJECT_ID = os.getenv('GCP_PROJECT')
PROJECT_NAME = f'projects/{PROJECT_ID}'
ZONE = 'us-west1-b'

def limit_use(data, context):
    pubsub_data = base64.b64decode(data['data']).decode('utf-8')
    pubsub_json = json.loads(pubsub_data)
    cost_amount = pubsub_json['costAmount']
    budget_amount = pubsub_json['budgetAmount']
    if cost_amount <= budget_amount:
        print(f'No action necessary. (Current cost: {cost_amount})')
        return

    compute = discovery.build(
        'compute',
        'v1',
        cache_discovery=False,
    )
    instances = compute.instances()

    instance_names = __list_running_instances(PROJECT_ID, ZONE, instances)
    __stop_instances(PROJECT_ID, ZONE, instance_names, instances)

def __list_running_instances(project_id, zone, instances):
    """
    @param {string} project_id ID of project that contains instances to stop
    @param {string} zone Zone that contains instances to stop
    @return {Promise} Array of names of running instances
    """
    res = instances.list(project=project_id, zone=zone).execute()

    if 'items' not in res:
        return []

    items = res['items']
    running_names = [i['name'] for i in items if i['status'] == 'RUNNING']
    return running_names

def __stop_instances(project_id, zone, instance_names, instances):
    """
    @param {string} project_id ID of project that contains instances to stop
    @param {string} zone Zone that contains instances to stop
    @param {Array} instance_names Names of instance to stop
    @return {Promise} Response from stopping instances
    """
    if not len(instance_names):
        print('No running instances were found.')
        return

    for name in instance_names:
        instances.stop(
          project=project_id,
          zone=zone,
          instance=name).execute()
        print(f'Instance stopped successfully: {name}')

サービス アカウント権限を構成する

  1. Cloud Function は自動的に作成されたサービス アカウントとして実行されます。使用を制御するには、変更が必要なプロジェクトのサービスにサービス アカウントの権限を付与する必要があります。
  2. 正しいサービス アカウントを特定するには、Cloud Function の詳細を表示します。サービス アカウントはページの下部に表示されます。
  3. コンソールで [IAM] ページに移動して、適切な権限を設定します。
    [IAM] ページに移動

    コンソールの IAM 画面で、Cloud Functions の関数を実行するサービス アカウントに適切な権限を設定できます。

インスタンスが停止していることを検証する

予算が通知を送信すると、Compute Engine 仮想マシンは停止します。

この関数のテストを行うには、上記のテスト メッセージでサンプル メッセージをパブリッシュします。関数が正常に実行されたことを確認するには、コンソールで Compute Engine 仮想マシンを確認します。