自动费用控制响应示例

参考架构示例

使用预算提醒程序化通知自动执行费用控制响应的示例图。
图 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 Functions 函数触发器

创建 Cloud Functions 函数

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

  1. 在 Cloud Console 中,转到 Cloud Functions 页面。

    转到 Cloud Functions 页面

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

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

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

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

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

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

描述 Cloud Functions 函数

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

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

查看 Cloud Functions 函数事件

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

此外,还显示了在屏幕上查看日志的位置以及 Cloud Console 中的 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 Functions 函数都会使用一个聊天机器人将消息发布到聊天机器人工作区的 Slack 频道。

设置 Slack 频道和权限

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

配置 Slack 通知。

编写 Cloud Functions 函数

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

  2. 添加以下依赖项:

    Node.js

    slack npm 软件包的一个依赖项添加到函数的 package.json 文件。
    {
      "name": "cloud-functions-billing",
      "version": "0.0.1",
      "dependencies": {
        "slack": "^11.0.1"
      }
    }
    

    Python

    slackclient==2.7.2 添加到函数的 requirements.txt 文件中:
    slackclient==2.7.2

  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 Functions 函数,以查看在 Slack 中显示的消息。

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

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

为什么要停用结算功能?

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

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

在 Cloud Console 中配置预算上限。

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

  • 启用 Cloud Billing API。您的 Cloud Functions 函数需要调用 Cloud Billing API 才能为项目停用 Cloud Billing。

  • 设置预算以监控项目费用并启用预算通知。

在 Cloud Console 中显示 Cloud Billing 提醒列表。

编写 Cloud Functions 函数

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

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

  2. 添加以下依赖项:

    Node.js

    googleapisgoogle-auth-library 的依赖项添加到函数的 package.json 文件中:
    {
     "name": "cloud-functions-billing",
     "version": "0.0.1",
     "dependencies": {
       "google-auth-library": "^2.0.0",
       "googleapis": "^52.0.0"
     }
    }

    Python

    google-api-python-client==1.9.3 添加到函数的 requirements.txt 文件中:
    google-api-python-client==1.9.3
    

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

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

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

Node.js

const {google} = require('googleapis');
const {GoogleAuth} = require('google-auth-library');

const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
const billing = google.cloudbilling('v1').projects;

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';
  }

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

/**
 * @return {Promise} Credentials set globally
 */
const _setAuthCredential = () => {
  const client = new GoogleAuth({
    scopes: [
      'https://www.googleapis.com/auth/cloud-billing',
      'https://www.googleapis.com/auth/cloud-platform',
    ],
  });

  // Set credential globally for all requests
  google.options({
    auth: client,
  });
};

/**
 * 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.getBillingInfo({name: projectName});
    return res.data.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.updateBillingInfo({
    name: projectName,
    resource: {billingAccountName: ''}, // Disable billing
  });
  return `Billing disabled: ${JSON.stringify(res.data)}`;
};

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

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

显示可在 Cloud Console 的“Cloud Functions 函数”部分中找到服务帐号信息的位置。

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

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

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

验证 Cloud Billing 已停用

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

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

您可以在 Cloud Console 中为项目手动重新启用 Cloud Billing

有选择地控制使用量

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

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

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

编写 Cloud Functions 函数

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

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

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

  4. 将要执行的函数设置为“limitUse”(Node) 或“limit_use”(Python)。

  5. GCP_PROJECT 环境变量可能会自动设置,具体取决于您的运行时。查看自动设置的环境变量列表,并确定是否需要将 GCP_PROJECT 变量设置为运行虚拟机的项目。

  6. 设置 ZONE 参数。在此示例中,实例将在此地区停止。

Node.js

const {google} = require('googleapis');
const {GoogleAuth} = require('google-auth-library');

const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
const PROJECT_NAME = `projects/${PROJECT_ID}`;
/**
 * @return {Promise} Credentials set globally
 */
const _setAuthCredential = () => {
  const client = new GoogleAuth({
    scopes: [
      'https://www.googleapis.com/auth/cloud-billing',
      'https://www.googleapis.com/auth/cloud-platform',
    ],
  });

  // Set credential globally for all requests
  google.options({
    auth: client,
  });
};
const compute = google.compute('v1');
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})`;
  }

  _setAuthCredential();

  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 res = await compute.instances.list({
    project: projectId,
    zone: zone,
  });

  const instances = res.data.items || [];
  const ranInstances = instances.filter(item => item.status === 'RUNNING');
  return ranInstances.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 compute.instances
        .stop({
          project: projectId,
          zone: zone,
          instance: instanceName,
        })
        .then(res => {
          console.log(`Instance stopped successfully: ${instanceName}`);
          return res.data;
        });
    })
  );
};

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

验证实例已经停止

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

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