[Tutorial] Setting up Budget Alert in GCP

Burasakorn Sabyeying
Mils’ Blog
Published in
8 min readAug 13, 2023

This article is written in both Thai and English: https://mesodiar.com/tutorial-setting-up-budget-alert-in-gcp-english-ver-f134bf21da92

เคยไหมที่เปิด service บน cloud ไปแล้วเผลอลืมปิด โดนชาร์จไปแบบไม่รู้ตัว ?

เคยไหมที่ใช้ cloud ไปเยอะเท่าไร billing อัพเดทช้าไม่ทันใจ ไม่รู้ว่าตอนนี้ cost กินไปเท่าไรแล้ว ?

บทความนี้เราจะสอนวิธีเซ็ต budget alert flow เมื่อ cost ของการใช้งานสูงกว่า budget ที่กำหนดไว้ flow นี้ก็จะมี action บางอย่าง เช่น ส่ง notification มาหา 1 ครั้งต่อเดือน หรือทำ action บางอย่างแบบ automate ที่เราต้องการแบบมีเงื่อนไข

ปกติแล้ว ใน Cloud Billing ตัวมันเองจะสามารถส่ง noti หา Billing Admins อยู่แล้ว แต่ในเคสที่ต้องการ custom และมีเงื่อนไขการส่งที่มากขึ้น ตัว Cloud Billing เดี่ยวๆก็ไม่สามารถตอบโจทย์ได้

flow นี้จะใช้สำหรับ GCP โดยเฉพาะ เริ่มจาก Cloud Billing, Cloud Pub/Sub, Cloud Functions และมี Firestore

เคสนี้สามารถใช้ในเคสที่อยาก monitor cost ในโปรเจคตั้งแต่ 1 โปรเจคขึ้นไป หากมีโปรเจคเยอะๆ ก็จะช่วยทำให้ลดภาระการเช็ค cost แบบ manual ไปได้

ในตัวอย่าง scenarios นี้ของเราคือ

Scenario 1:

Given: มีการทำ query ข้อมูลที่ถี่ในเดือนนี้ เลยมีการใช้งาน BigQuery ที่สูง

When: cost เยอะกว่า 75% ของ budget ที่เรากำหนดไว้ต่อเดือน

Then: ระบบจะส่ง notification บอกเราว่าตอนนี้ cost เท่าไรและ reach 75% แล้ว

Scenario 2:

Given: มีการทำ query ข้อมูลที่ถี่ในเดือนนี้ เลยมีการใช้งาน BigQuery ที่สูง

When: cost เยอะกว่า 100% ของ Budget ที่เรากำหนดไว้ต่อเดือน

Then: ระบบจะส่ง notification บอกเราว่าตอนนี้มี cost เท่าไรและ reach 100% แล้ว + ปรับ pricing plan จาก on-demand เป็น BigQuery Editions

Prerequisites:

  1. Project ที่มีการใช้งาน BigQuery (หรือจะ service ไหนก็ได้) ถ้าอยากลองเล่นแบบไม่กลัว cost ลั่น แนะนำสมัคร GCP account ใหม่เพื่อเคลม 300$ credit ฟรี
  2. เราต้องมี role ที่ชื่อว่า Billing Account Costs manager หรือ Billing Account Administrator
    ซึ่งเป็นสิทธิที่สูงมากระดับ organization ถ้าอยู่บริษัทใหญ่ๆจะถือว่าใหญ่ทีเดียว หากอยากเทสง่ายๆแนะนำสมัคร account ใหม่ 555
  3. Budget ในใจ
  4. ช่องทางที่เราอยากรับ Notification เช่น Microsoft Teams

ถัดไป เราจะติดตั้งการใช้งานไล่ตามนี้ Cloud Billing > Cloud Pub/Sub > Cloud Firestore > Cloud Functions

Cloud Billing Budget & Alerts

  1. เมื่อเรามาที่ console.cloud.google.com ในช่อง search ให้เราใส่คำว่า ‘billing alert’ ก็จะมีคำว่า Budgets & Alerts ขึ้นมา

2. หากเรามีสิทธิ Billing Account Costs manager หรือ Billing Account Administrator เราจะสามารถเห็นหน้านี้ได้ และหน้านี้จะเป็น list ของ budget alert ที่เราสามารถเข้ามาเซ็ตได้

ให้กดที่ create budget เพื่อกดสร้าง

3. ทีนี้เราจะตั้งชื่อ alert นี้ว่า mils-project-bigquery-notification

โดยชื่อจะมาจากชื่อ GCP project และ BigQuery เพื่อสอดคล้องว่า alert นี้เป็นของ BigQuery ที่ใช้ในโปรเจคนี้

โดยจะเซ็ต time range เป็น Monthly ไว้ และเลือกเป็น service ตัว BigQuery ตัวเดียว

ซึ่งถ้าเราอยากให้เช็ค cost ทั้งโปรเจคก็เลือก service ทั้งหมดไปเลยได้นะ

คราวนี้อย่าลืมติ๊ก Discounts กับ Promotions and Others ออก เพราะว่าเราอยาก monitor cost ที่เกิดขึ้นจริงแต่แรก หากเรารวม Discounts มาด้วย เดี๋ยวมันจะไม่ส่ง noti มา

สังเกตเห็นได้ว่า หาก project นั้นมีการใช้งานอยู่แล้ว จะมีกราฟ Cost Trend แสดงมาด้านขวา

4. ใส่ Budget ที่ต้องการ

เราจะใส่ไป 300 Baht ถึงแม้ว่าการใช้งานจริงจะทะลุไปแล้วก็เถอะ5555

4. เลือก Threshold ที่ต้องการ

โดยเราจะเลือก Threshold ที่ 50%, 75%, 100% ซึ่งผลสุดท้ายแต่ละ Threshold จะมี action ที่แตกต่างกัน ซึ่งจะให้ Cloud Functions เป็นคน process logic ทีหลัง

จะเห็นได้ว่า Manage Notifications มี 3 choices ให้เลือก ซึ่ง 1 กับ 2 ต้องเลือกอย่างใดอย่างหนึ่ง หาก GCP project นี้เราใช้เป็น email ปัจจุบันเองก็จะไม่ต่างกันเท่าไร

แต่หากเราใช้ account ที่สร้างใหม่เพื่อเทส อาจจะต้องสร้าง Notification Channel เพิ่ม ก็สามารถเลือกข้อ 2 เป็นการ custom การส่งตาม email ที่ต้องการได้ สร้างเป็นอีเมลล์ต่อหา email ปัจจุบันที่เราใช้

ซึ่งมาถึงตรงนี้ หากคิดว่า flow ของเราต้องการแค่ส่ง email หรือใช้ webhook ตรงๆเลยก็ย่อมได้ แต่หากต้องการให้มีลูกเล่นในแต่ละเงื่อนไขของ Threshold เพิ่ม เราก็จะไปใช้ตัวเลือกที่ 3

ดังนั้นเราจะเลือกข้อ 3 ด้วย นั่นคือจะให้ส่งหา pub/sub ด้วยเพื่อจะเป็นสะพานไปส่งหา Cloud Functions อีกที ซึ่งลูกเล่นที่ว่านั้นจะอยู่ภายใน Cloud Functions นั่นเอง

Cloud Pub/Sub

ในตัวเลือกสุดท้าย เราจะสร้าง Pub/Sub topic จากหน้านี้แหล่ะ

โดยหลังจากเราจะเลือก create topics มันจะมีหน้าต่างเด้งมา เราจะตั้งชื่อว่า mils-project-bigquery-budget-alert-topic

เราจะ enable message retention ด้วย เซ็ตไว้ที่ 31 วัน เพราะเราก็เก็บ message ไว้ตามจำเป็นพอ

คราวนี้กด save ก็เป็นอันเสร็จ

ถ้า cost ที่เกิดขึ้นจริงเยอะกว่า budget ก็จะเป็นสีส้มแบบนี้5555 แต่ถ้าไม่เกินก็จะเป็นสีเขียว

คราวนี้จะเราจะแวะไปดูที่ Cloud Pub/Sub

โดยเราจะเห็นว่า มี pub/sub topic ที่เราสร้างไว้จากหน้า Billing Alert ไว้แล้ว

เมื่อเรากดเข้ามา mils-project-bigquery-budget-alert-topic จะเห็นว่า ยังไม่มี subscription อยู่ ดังนั้นเราจะต้องมากดสร้างตัว subcribe

กด create subscription

เราจะตั้งชื่อว่า mils-project-bigquery-budget-alert-sub เพื่อสอดคล้องกับชื่อ topic

config ตามนี้ แล้วกด create

ซึ่งหากเราสร้าง subscribe เสร็จแล้ว เราจะเข้าไปดู message ที่เกิดขึ้นได้

subscriptionนั้น > tab ชื่อ messages > กด pull

แต่เนื่องจาก เราเพิ่งสร้าง subscription สดๆร้อนๆ มันเลยยังไม่มีของวิ่งมา

หากเราปล่อยไปสักพัก มันจะมี message วิ่งเข้ามาเรื่อยๆ ตัวอย่างแบบในรูป

Message Example in Pub/sub

Message ที่ subscribe ดึงมาจาก topic จะมีอยู่ 2 รูปแบบ

  1. Message ที่ cost ยังไม่ reach threshold เลย
{
"budgetDisplayName": "mils-project-bigquery-notification",
"costAmount": 0.0,
"costIntervalStart": "2023-07-01T07:00:00Z",
"budgetAmount": 1.0,
"budgetAmountType": "SPECIFIED_AMOUNT",
"currencyCode": "USD"
}

2. Message ที่ cost reach threshold แล้ว

{
"budgetDisplayName": "mils-project-bigquery-notification",
"alertThresholdExceeded": 1.0,
"costAmount": 120.0,
"costIntervalStart": "2023-07-01T07:00:00Z",
"budgetAmount": 100.0,
"budgetAmountType": "SPECIFIED_AMOUNT",
"currencyCode": "THB"
}

โดยความน่าปวดหัวนิดนึงของ Pub/sub คือ มันจะส่ง message มาเรื่อยๆ ไม่ว่า cost จะราคาเท่าไร แล้วหลังจากนี้ ถ้าเรา plug ตัว Cloud Functions เข้ามา มันก็จะไป trigger ตัว Cloud Functions ทุกครั้งที่ Pub/sub topic มี message เช่นกัน

เราเลยต้องเซ็ตให้ Cloud Functions รองรับการที่ message วิ่งมาซ้ำๆด้วย เนื่องจาก Cloud Functions ก็จะเป็นคนส่ง noti หาเรา เราคงไม่อยากให้ Noti ส่งหาเราพร่ำเพรื่อ

Cloud Functions

คราวนี้เราจะเซ็ตให้ Cloud Functions เราจะตั้งชื่อว่า budget-alert-notifications

ซึ่งเราต้องกด Add Triggerเป็น Pub/Sub trigger ด้วย และเลือกชื่อจาก Pubsub topic ที่เราเซ็ตไว้

พอกด Next ก็จะเป็นหน้าที่เซ็ตว่า Cloud Functions นี้จะทำอะไรบ้าง

เราจะให้ Cloud Functions เป็นคนรับค่าจาก message แล้วเช็คว่าใน message มี alertThresholdExceeded หรือไม่

หากมี ก็จะแปลว่า cost ถึง threshold แล้ว ซึ่งเราก็จะแยก action ที่เกิดจาก threshole 75% และ 100%

โค้ดจึงออกมาหน้าตาคร่าวๆประมาณนี้ :D

# Triggered from a message on a Cloud Pub/Sub topic.
@functions_framework.cloud_event
def subscribe(cloud_event: CloudEvent) -> None:
# Print out the data from Pub/Sub, to prove that it worked
print(
"Hello, " + base64.b64decode(cloud_event.data["message"]["data"]).decode() + "!"
)
pubsub_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
pubsub_json = json.loads(pubsub_data)

cost_amount = pubsub_json["costAmount"]
budget_amount = pubsub_json["budgetAmount"]
budget_display_name = pubsub_json["budgetDisplayName"]
alertThresholdExceeded = pubsub_json.get("alertThresholdExceeded", None)

if alertThresholdExceeded is None:
if cost_amount <= budget_amount:
print(f"No action necessary. (Current cost: {cost_amount})")
return
else:
content = """
Budget name '{}'<br>
Current cost is <b>${}</b>.<br>
Budget cost is <b>${}</b>.<br>
💸 💸 💸 💸
""".format(
budget_display_name, cost_amount, budget_amount
)
title = "💸 Cost reaches {:.0f}% from {}".format(
alertThresholdExceeded * 100, budget_display_name
)
print(f"############################################")
print(f"budget threshold is {alertThresholdExceeded}")
print(f"{title}")
print(f"{content}")
print(f"############################################")

if alertThresholdExceeded == 0.75:
send_noti(content, title)
elif alertThresholdExceeded == 1.0:
### do something differently from 75% ###
send_noti(content, title)

def send_noti(content, title):
## send noti to somewhere

แต่ปัญหาหลักคือ เจ้า Pub/sub ชอบส่ง msg มาซ้ำน่ะสิ

Budget notifications are sent to the Pub/Sub topic multiple times per day with the current status of your budget. This is a different cadence than the budget alert emails, which are only sent when a budget threshold is met

ซึ่งหากโจทย์เราคือ เราอยากให้ระบบส่ง noti มาแค่ 1 ครั้งต่อเดือน ตรงนี้ Cloud Functions เดี่ยวๆไม่สามารถเก็บได้ว่าโปรเจคไหนเคยส่ง เพราะหน้าที่หลักของ Cloud Functions คือเช็ค logic ของ threshold

ดังนั้นเราจึงเลือก database สักตัวที่ช่วยเราเก็บได้ว่า เคยส่ง noti ไปหรือยัง และส่งครั้งล่าสุดไปเมื่อไร ซึ่งเราเลือก Cloud Firestore มาช่วย เนื่องจากมัน simple ดี

ค้าง Cloud Functions ไว้แล้วขอสลับมาที่ Firestore กันแปบนึง

Cloud Firestore

ชะแว้บบบ เรามากันที่ Firestore

เราเลือกสร้างเป็น Native Mode และ เราจะสร้าง Collection ชื่อ budget-alert-notification-stats

หน้าตาของ Firestore เราจะหน้าตาคร่าวๆแบบนี้ คือ document ภายใน budget-alert-notification-stats จะมีลิสของโปรเจค เช่น project_a, project_b ว่าเคยส่งโนติไปแล้ว แล้วใน document ก็จะเก็บว่าส่งโนติไปครั้งล่าสุดเมื่อไร

ตรงนี้ทุกคนอาจจะยังไม่มี project_a กับ project_b นะ สร้างแค่ collection ก็พอ แล้วกลับไปที่ Cloud Functions กัน

Back to Cloud Functions

คราวนี้เราจะ edit code ภายใน Cloud Functions เพิ่มเติมว่าให้ไปรู้จัก Cloud Firestore แล้วเช็คว่าเคยส่ง noti ไปแล้วหรือยังในเดือนนั้น

หากไม่ยังไม่เคยส่ง ก็ส่ง

แต่หากเคยส่งแล้ว ก็ไม่ต้องทำอะไรต่อ

# Triggered from a message on a Cloud Pub/Sub topic.
@functions_framework.cloud_event
def subscribe(cloud_event: CloudEvent) -> None:
# Print out the data from Pub/Sub, to prove that it worked
print(
"Hello, " + base64.b64decode(cloud_event.data["message"]["data"]).decode() + "!"
)
pubsub_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
pubsub_json = json.loads(pubsub_data)
cost_amount = pubsub_json["costAmount"]
budget_amount = pubsub_json["budgetAmount"]
budget_display_name = pubsub_json["budgetDisplayName"]
alertThresholdExceeded = pubsub_json.get("alertThresholdExceeded", None)
project_id = get_alert_name(pubsub_json["budgetDisplayName"])

if alertThresholdExceeded is None:
if cost_amount <= budget_amount:
print(f"No action necessary. (Current cost: {cost_amount})")
return
else:
content = (
"""Budget name '{}'.Current cost is ${}.Budget cost is ${}💸 💸 💸 💸""".format(
budget_display_name, cost_amount, budget_amount
)
)
title = "💸 Cost reaches {:.0f}% from {}".format(
alertThresholdExceeded * 100, budget_display_name
)
print(f"############################################")
print(f"budget threshold is {alertThresholdExceeded}")
print(f"{title}")
print(f"{content}")
print(f"############################################")

alert_percent = int(alertThresholdExceeded * 100)
should_notify = check_to_notify_per_month(project_id, alert_percent)

if alertThresholdExceeded == 0.75 and should_notify:
send_teams(
webhook_url,
content=content,
title=title,
color=green_code,
)
elif alertThresholdExceeded == 1.0 and should_notify:
### do something differently from 75% ###
send_teams(
webhook_url,
content=content,
title=title,
color=red_code,
)

def check_to_notify_per_month(project_id: str, threshold_percent) -> bool:
"""_summary_
check if already notify in Teams in that month or not
by checking from Cloud Firestore
Args:
project_id (str): project id get from alert name

Returns:
bool: True when already notify and update data in Firestore, False to skip notify
"""
db = firestore.Client()
last_notify_ref = db.collection("budget-alert-notification-stats").document(
project_id
)
current_timestamp = pendulum.now("Asia/Bangkok")

try:
last_notify_snapshot = last_notify_ref.get()
data = last_notify_snapshot.to_dict()
if not last_notify_snapshot.exists:
# Initialize with the current timestamp if it's the first time.
last_notify_ref.set({f"last_noti_{threshold_percent}": current_timestamp})
print("Never has notification before, should notify team")

return True

elif f"last_noti_{threshold_percent}" not in data:
# Update existing doc with new threshold
last_notify_ref.update(
{f"last_noti_{threshold_percent}": current_timestamp}
)
print(
f"Never has notification for {threshold_percent} before, should notify team"
)

return True
else:
# Both threshold is in doc, check if it should notify
last_notify_in_bkk = convert_to_timestamp_with_bkk(
last_notify_snapshot.get(f"last_noti_{threshold_percent}")
)

print("last_notify_in_bkk: ", last_notify_in_bkk)
print("current_timestamp: ", current_timestamp)
# Check if a month has passed since the last notify.
if has_one_month_passed(current_timestamp, last_notify_in_bkk):
# Proceed with sending the notify to Microsoft Teams.

# Update the last notify timestamp.
last_notify_ref.update(
{f"last_noti_{threshold_percent}": current_timestamp}
)
return True
else:
print("Skipping notify, All ready notify in this month.")
return False
except Exception as e:
print("Error processing notify:", e)

def convert_to_timestamp_with_bkk(timestamp_from_firestore: datetime) -> datetime:
last_notify_timestamp = timestamp_from_firestore.strftime("%Y-%m-%dT%H:%M:%S.%f%z")
last_notify_timestamp = pendulum.parse(last_notify_timestamp, tz="UTC")
last_notify_timestamp_in_bkk = last_notify_timestamp.in_tz("Asia/Bangkok")
return last_notify_timestamp_in_bkk


def has_one_month_passed(
current_timestamp: datetime, last_notify_timestamp: datetime
) -> bool:
current_month, current_year = get_month_and_year(current_timestamp)
last_notify_month, last_notify_year = get_month_and_year(last_notify_timestamp)
return (current_year > last_notify_year) or (
current_year == last_notify_year and current_month > last_notify_month
)


def get_month_and_year(timestamp: datetime):
month = timestamp.month
year = timestamp.year
return month, year

หน้าตาของ Cloud Functions จะหน้าตาประมาณนี้ เราใช้ python 3.11 มีไฟล์ main.py และ requirement.txt

เราไม่ได้ใส่โค้ดทั้งหมดเพราะเดี๋ยวจะยาวไป หากสนใจอ่านโค้ดเต็มๆแนะนำไปดูที่ Github

Test ว่า Cloud Functions ใช้ได้ไหม

เนื่องจากเราต้องใช้เวลาสักพักเพื่อรอให้ msg จะวิ่งมา ระหว่างนั้นเราสามารถทำการเทสโดยไม่ต้องรอ ให้เข้าไปที่ Pub/sub topic > messages > publish message

แล้วพ่น message ปลอมผ่าน topics ไปก่อน

{"test": "yes","budgetDisplayName": "mils-project-bigquery-notification","alertThresholdExceeded": 0.75,"costAmount": 730.0,"costIntervalStart": "2023-07-02T07:00:00Z","budgetAmount": 1.0,"budgetAmountType": "SPECIFIED_AMOUNT","currencyCode": "THB"}

Notifications เราเด้งแล้ว ก็แปลว่า Cloud Functions ใช้ได้

แต่เทสแล้ว อย่าลืมไปลบ document ใน Cloud Firestore ด้วยนะ เดี๋ยวมันจะไม่ส่งมาใหม่5555

ผลลัพท์ของการ setup

1. Cloud Pub/sub Topics

หากเราปล่อยระบบทิ้งไว้สักพัก ภายใน Cloud Pub/Sub topics มันจะมี msg วิ่งมา หน้าตาจะคล้ายๆแบบนี้

จะเห็นได้ว่าตอนนี้ cost เกิน budget มาแล้ว 100%

2. Cloud Firestore

ใน Firestore ก็จะมี document ชื่อ project และข้างในจะมี field ชื่อ last_noti_100 หรือ/และ last_noti_75

ในเคสนี้มีแต่ last_noti_100 เพราะ cost ในเดือนนี้ดันพุ่งเกิน budget ไปที่เรียบร้อยแล้ว

3. Noti channel

ผลสุดท้ายเราจะได้รับ noti ที่แตกต่างกันออกไป ตามที่ตั้งใจไว้

เมื่อ cost ถึง 50% -> ได้รับ email เพียงอย่างเดียว

เมื่อ cost ถึง 75% -> ได้รับ email และ noti เข้า Teams

เมื่อ cost ถึง 100% -> ได้รับ email และ noti เข้า Teams (และอื่นๆแล้วแต่จะเพิ่ม)

ทิ้งท้าย

อันที่จริงแล้ว Flow หลักที่จำเป็นคือ Cloud Billing > Cloud Pub/Sub > Cloud Functions

ส่วนของ Firestore จะเป็นตัวเสริมเนื่องจากเราต้องการให้ระบบส่ง noti แค่ 1 ครั้งต่อเดือนเท่านั้น ซึ่งนั่นแล้วแต่ว่าผู้วางระบบต้องการออกแบบให้ flow เป็นเช่นไร

อย่าง threshold ที่ 100% เราก็อาจจะปรับให้ BigQuery สลับไปใช้ Editions pricing plan แบบ auto ก็ได้ (อีกชื่อคือ assign reservations) ก็จะเป็นการ custom ที่เราหวังไว้ แต่ไม่ได้ใส่ในบทความหรือโค้ดตัวอย่าง เนื่องจากเราอยากเน้นให้เห็นการทำงานหลักของ 3 ตัวนี้ ซึ่งผู้อ่านสามารถ custom ได้ตามสะดวก

และในกรณีมี project เข้ามาใหม่ ก็เพียงแค่สร้าง budget & alerts ใน Cloud Billing เพิ่ม แล้วผูกกับ Pubsub topics เดิมที่เซ็ตไว้ ก็ใช้ได้แล้ว

--

--

Burasakorn Sabyeying
Mils’ Blog

Data engineer at CJ Express. Women Techmakers Ambassador. GDG Cloud Bangkok team. Moved to Mesodiar.com