自动费用控制响应示例

参考架构示例

使用预算提醒程序化通知自动执行费用控制响应的示例图。
图 1:此示例展示了如何使用预算提醒,通过用于程序化通知的 Pub/Sub 和用于自动响应的 Cloud Run 函数来自动执行费用控制响应。

如果您想要节省费用且需要根据预算控制环境,则可以使用程序化预算通知,根据预算通知自动执行费用控制响应。

预算通知使用 Pub/Sub 主题,通过面向云的企业消息传递中间件的可扩缩性、灵活性和可靠性来提供 Cloud Billing 预算的实时状态。

本文档包含一些示例和分步说明,介绍了如何结合使用预算通知与 Cloud Run 函数来自动执行费用管理。

设置预算通知

第一步是为您的预算启用 Pub/Sub 主题。这在管理程序化预算提醒通知中有详细描述。

启用预算通知后,请注意以下事项:

  • Pub/Sub 主题 - 为预算配置的通知端点。
  • 预算 ID:包含在所有通知中的预算的唯一 ID。您可以在管理通知下的预算中找到该预算的 ID。在您选中将一个 Pub/Sub 主题关联到此预算后,系统即会显示该 ID。

Google Cloud 控制台中的“管理通知”部分,您可以在其中将 Pub/Sub 主题与预算相关联。它包括预算 ID、项目名称和 Pub/Sub 主题。

监听通知

下一步是通过订阅 Pub/Sub 主题来监听通知。如果您没有订阅者账号,Pub/Sub 将丢弃已发布的消息,您以后将无法再进行检索。

虽然您可以通过多种方式订阅感兴趣的主题,但在以下示例中,我们将使用 Cloud Run 函数触发器

创建 Cloud Run 函数

如需创建新的 Cloud Run 函数,请执行以下操作:

  1. 在 Google Cloud 控制台中,前往 Cloud Run 函数页面。

    前往 Cloud Run 函数页面

  2. 点击 创建函数并为函数指定对您的预算有意义的名称。

  3. 触发器下,选择 Pub/Sub 主题

  4. 选择您在预算中配置的主题。

  5. 为要运行的函数提供源代码和依赖项。

  6. 确保将要执行的函数设置为正确的函数名称。

Google Cloud 控制台的 Cloud Run 函数部分中的“创建函数”页面。它包括函数名称、已分配的内存量、触发器类型以及您在预算中配置的 Pub/Sub 主题。

描述 Cloud Run 函数

如需告知 Cloud Run 函数您希望它如何处理通知,您可以使用內嵌编辑器编写代码,也可以上传文件。如需详细了解您的代码将收到的通知,请参阅通知格式

例如,在被预算通知触发后,函数可能会记录收到的 Pub/Sub 通知、属性和数据。如需了解详情,请参阅 Pub/Sub 触发器

查看 Cloud Run 函数事件

保存 Cloud Run 函数后,您可以点击查看日志以查看记录的预算通知。下图显示了有关函数调用的日志。

显示可以在屏幕上找到查看日志的位置,以及 Google Cloud 控制台中的 Cloud Run 函数事件列表。

测试 Cloud Run 函数

系统会向 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 Run 函数都会使用聊天机器人将消息发布到聊天机器人工作区的 Slack 频道。

设置 Slack 频道和权限

第一步是创建 Slack 工作区以及用于调用 Slack API 的聊天机器人用户令牌。API 令牌可以通过 https://api.slack.com/apps 进行管理。如需查看详细说明,请参阅 Slack 网站上的聊天机器人用户

配置 Slack 通知。

编写 Cloud Run 函数

  1. 按照创建 Cloud Run 函数中的步骤创建一个新函数。确保将触发器设置为预算所使用的同一 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": ">=16.0.0"
      },
      "scripts": {
        "compute-test": "c8 mocha -p -j 2 test/periodic.test.js --timeout=600000",
        "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit"
      },
      "author": "Ace Nassri <anassri@google.com>",
      "license": "Apache-2.0",
      "dependencies": {
        "@google-cloud/billing": "^4.0.0",
        "@google-cloud/compute": "^4.0.0",
        "google-auth-library": "^9.0.0",
        "googleapis": "^143.0.0",
        "slack": "^11.0.1"
      },
      "devDependencies": {
        "@google-cloud/functions-framework": "^3.0.0",
        "c8": "^10.0.0",
        "gaxios": "^6.0.0",
        "mocha": "^10.0.0",
        "promise-retry": "^2.0.0",
        "proxyquire": "^2.1.0",
        "sinon": "^18.0.0",
        "wait-port": "^1.0.4"
      }
    }
    

    Python

    将以下内容复制到 requirements.txt 中:

    slackclient==2.9.4
    google-api-python-client==2.131.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 Run 函数,以查看在 Slack 中显示的消息。

通过限制(停用)结算功能来停止使用

下面的示例展示了如何通过停用 Cloud Billing 设置费用上限并停止对项目的使用。停用项目结算功能将导致该项目中的所有 Google Cloud 服务(包括免费层级服务)终止。

为什么停用结算功能?

您可能会因为 Google Cloud 的相关开支存在硬性限制,而需要设置费用上限。这对于在沙盒环境中执行操作的学生、研究人员或开发者来说非常常见。在这些情况下,您希望停止支出,而且可能愿意在达到预算上限时,关停所有 Google Cloud 服务并停止使用。

在我们的示例中,我们将“acme-backend-dev”用作非生产项目,该项目的 Cloud Billing 可以安全地停用。

在 Google Cloud 控制台中配置预算上限。

在开始使用此示例之前,请确保已完成以下操作:

在 Google Cloud 控制台中显示 Cloud Billing 提醒列表。

编写 Cloud Run 函数

接下来,您需要配置 Cloud Run 函数来调用 Cloud Billing API。这样,Cloud Run 函数就可以为示例项目“acme-backend-dev”停用 Cloud Billing。

  1. 按照创建 Cloud Run 函数中的步骤创建一个新函数。确保将触发器设置为预算所使用的同一 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": ">=16.0.0"
      },
      "scripts": {
        "compute-test": "c8 mocha -p -j 2 test/periodic.test.js --timeout=600000",
        "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit"
      },
      "author": "Ace Nassri <anassri@google.com>",
      "license": "Apache-2.0",
      "dependencies": {
        "@google-cloud/billing": "^4.0.0",
        "@google-cloud/compute": "^4.0.0",
        "google-auth-library": "^9.0.0",
        "googleapis": "^143.0.0",
        "slack": "^11.0.1"
      },
      "devDependencies": {
        "@google-cloud/functions-framework": "^3.0.0",
        "c8": "^10.0.0",
        "gaxios": "^6.0.0",
        "mocha": "^10.0.0",
        "promise-retry": "^2.0.0",
        "proxyquire": "^2.1.0",
        "sinon": "^18.0.0",
        "wait-port": "^1.0.4"
      }
    }
    

    Python

    将以下内容复制到 requirements.txt 中:

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

  3. 将以下代码复制到 Cloud Run 函数中。

  4. 将要执行的函数设置为 stopBilling (Node) 或 stop_billing (Python)。

  5. GOOGLE_CLOUD_PROJECT 环境变量可能会自动设置,具体取决于您的运行时。查看自动设置的环境变量列表,并确定是否需要手动将 GOOGLE_CLOUD_PROJECT 变量设为您要为其设置 Cloud Billing 上限(停用 Cloud Billing)的项目。

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 Run 函数作为自动创建的服务账号运行。因此,该服务账号可以停用结算功能,而您需要为其授予正确的权限,例如 Billing Admin

如需确定正确的服务账号,请查看 Cloud Run 函数详细信息。服务账号显示在页面底部。

显示可在 Google Cloud 控制台的 Cloud Run 函数部分中找到服务账号信息的位置。

您可以在 Google Cloud 控制台中的“结算”页面上管理 Billing Admin 权限

要向服务账号授予结算账号管理员权限,请选择服务账号名称。

显示在 Google Cloud 控制台的“权限”部分中选择服务账号名称和 Billing Account Administrator 角色的位置。

验证 Cloud Billing 已停用

当预算发出通知时,指定的项目将不再拥有 Cloud Billing 账号。如果您要测试函数,请发布包含上述测试消息的示例消息。Cloud Billing 账号下将不再显示此项目,且此项目中的资源(包括同一项目中的 Cloud Run 函数)已停用。

显示与 Cloud Billing 账号关联的项目列表中不再显示该示例项目。这可验证已为该项目停用 Cloud Billing。

您可以在 Google Cloud 控制台中为项目手动重新启用 Cloud Billing

有选择地控制使用量

上面的示例中所述的设置 Cloud Billing 上限(停用 Cloud Billing)操作只有两种最终结果。您的项目要么启用,要么停用。停用项目后,所有服务都将停止,且所有资源最终会被删除。

如果您需要更精细的响应,则可以有选择地控制资源。例如,如果您希望停止某些 Compute Engine 资源,但 Cloud Storage 保持不变,则可以有选择地控制使用量。这样可以降低每小时的费用,而不会完全停用您的环境。

您可以根据自己的需要编写足够细化的政策。不过,在我们的示例中,我们的项目使用许多 Compute Engine 虚拟机运行研究,并将结果存储在 Cloud Storage 中。下面的 Cloud Run 函数示例会关闭所有 Compute Engine 实例,但在超出预算后不会影响我们存储的结果。

编写 Cloud Run 函数

  1. 按照创建 Cloud Run 函数中的步骤创建一个新函数。确保将触发器设置为预算所使用的同一 Pub/Sub 主题。

  2. 确保您已添加了通过限制(停用)结算功能来停止使用中描述的依赖项。

  3. 将以下代码复制到 Cloud Run 函数中。

  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 Run 函数作为自动创建的服务账号运行。如需控制使用量,您需要将服务账号权限授予项目中需要进行更改的任何服务。
  2. 如需确定正确的服务账号,请查看 Cloud Run 函数的详细信息。服务账号显示在页面底部。
  3. 在 Google Cloud 控制台中,前往 IAM 页面,以设置相应权限。
    转到 IAM 页面
     
    显示 Google Cloud 控制台中的 IAM 屏幕,您可以在其中为运行 Cloud Run 函数的服务账号设置适当的权限。

验证实例已经停止

当预算发出通知时,Compute Engine 虚拟机会停止运行。

如需测试函数,请发布包含之前测试消息的示例消息。如需验证函数是否成功运行,请在 Google Cloud 控制台中检查您的 Compute Engine 虚拟机。