วิธีการแจ้งเตือนค่าใช้จ่ายการใช้งาน AWS ใน Slack ทุกวัน

ค่าบริการ AWS เป็นเรื่องที่น่ากังวล เช่น "ค่าใช้จ่ายเดือนนี้คือเท่าไหร่?" หรือ "ค่าใช้จ่ายตอนนี้คือเท่าไหร่?" เป็นต้น ดังนั้นครั้งนี้เราจะมาสร้างกลไกเพื่อแจ้งยอดค่าใช้จ่ายรายเดือนทั้งแบบยอดรวมทั้งหมดและแบบราย Service ผ่านทาง Slack ทุกวันครับ

สวัสดีครับ POP จากบริษัท Classmethod (Thailand) ครับ

ผู้ที่ใช้งานบริการ AWS ส่วนใหญ่ มีการควบคุมค่าใช้จ่ายการใช้งาน AWS ใช่ไหมครับ?
บางครั้งเราอาจหลีกเลี่ยงปัญหาที่ทำให้สิ้นเปลืองค่าใช้จ่ายไม่ได้ เนื่องจากลืม Stop หรือลบ Resource ที่ไม่ได้ใช้งานนั่นเอง
วันนี้เราจะมาพูดถึงเรื่องเหล่านี้ และแนะนำการตั้งค่าแจ้งเตือนค่าใช้จ่ายการใช้งาน AWS ใน Slack ทุกวันตามเวลาที่กำหนด เนื่องจากการตั้งค่าเหล่านี้จะช่วยให้เราสามารถตรวจสอบค่าบริการ และมองเห็นค่าใช้จ่ายการใช้งาน AWS ในแต่ละวันได้อย่างชัดเจนมากขึ้น

บทนำ

ใน AWS Account ของสำนักงานในประเทศไทยที่ใช้ทดสอบนี้ อาจมีบางครั้งที่ค่าบริการเพิ่มขึ้นมากเกินไป เนื่องจากลืม Stop Instance โดยไม่ได้ตั้งใจ (เป็นสิ่งที่ไม่ควรเกิดขึ้น)
จึงอยากจะมาแนะนำตั้งค่าการแจ้งเตือนค่าใช้จ่ายการใช้งาน AWS ในแต่ละวันเพื่อตรวจสอบค่าบริการทั้งหมดในบัญ AWS Account ครับ

นอกจากนี้ เนื้อหาในบทความนี้ได้ปรับเปลี่ยนมาจากเนื้อหาตามลิงก์ด้านล่างนี้ ต้องขอขอบคุณ คุณฟูจิอิ(藤井元貴) ด้วยครับ

สิ่งที่จะทำ

เป็นการสร้างและกำหนดค่าแบบไร้เซิร์ฟเวอร์ (Serverless) ที่เปิดใช้งาน Lambda โดยตั้งค่าให้เป็นเวลาที่กำหนดทุกวัน แล้ว Lambda จะได้รับค่าบริการจาก AWS Cost Explorer และโพสต์ไปยัง Slack ทุกวันตามเวลาที่กำหนดไว้ใน Lambda ครับ

ข้อควรระวัง

โปรดทราบว่าค่าบริการที่มีการคำนวณออกมาของตัวอย่างนี้ อาจไม่ตรงกับจำนวนเงินที่เรียกเก็บจริง
เมื่อทำการตั้งค่านี้จะทำให้มีค่าใช้จ่ายประมาณ 0.6 USD/เดือน เนื่องจากเป็นค่าใช้จ่ายในการเรียกใช้ Cost Explorer API ครับ

ดูรายละเอียดค่าบริการ Cost Explorer API เพิ่มเติมได้ที่ลิงก์ด้านล่างนี้

ตั้งค่า AWS Cost Explorer

ก่อนอื่นให้เข้าไปที่บริการ AWS Cost Explorer ใน AWS Management Console แล้วเลือก Cost Explorer

หากแสดงข้อความว่าไม่ได้รับอนุญาต ให้เข้าสู่ระบบด้วยบัญชี Root (Root Account) และเปิดใช้งาน Cost Explorer จากหน้าจอตั้งค่าครับ

ดูรายละเอียดเพิ่มเติมได้ที่ลิงก์ด้านล่างนี้ครับ

ตั้งค่า Slack

สร้าง Channel

เราจะสร้าง Channel ใน Slack โดยใช้ชื่อ aws_usage และจะตั้งค่าเป็น Private เพราะเป็นข้อมูลเกี่ยวกับค่าบริการ

ก่อนอื่นเข้ามาที่หน้าจอ Slack ของเรา แล้วคลิก + Add channels แล้วเลือก Create a new channel

ป้อนชื่อ Channel ที่ต้องการ เช่น aws_usage แล้วคลิก Next

ครั้งนี้จะตั้งค่าเป็น Private เพราะเป็นข้อมูลเกี่ยวกับค่าบริการ

หากยังไม่ต้องการเพิ่ม Email เพื่อนร่วมงาน ให้คลิก หรือ Skip for now ได้เลย เพราะสามารถเพิ่มในภายหลังได้

เพิ่ม Incoming Webhook

เมื่อสร้าง Channel เสร็จแล้วจะแสดงหน้าจอแบบนี้ ผมจะใช้ Channel นี้รับการแจ้งเตือนจาก Lambda function
จากนั้นให้คลิกที่ชื่อ Channel ของเรา

เลือกแท็บ Integrations และคลิก Add an App

ค้นหาและเลือกแอป Incoming Webhook แล้วจะย้ายไปที่หน้าจอบนเว็บเบราว์เซอร์โดยอัตโนมัติ

แล้วคลิก Add to Slack

เลือก Channel aws_usage ที่สร้างเมื่อสักครู่นี้ที่ Post to Channel และคลิก Add Incoming WebHooks integration

แล้วจดบันทึก Webhook URL ที่สร้างขึ้นเตรียมไว้ และบันทึกการตั้งค่านี้โดยเลื่อนลงมาด้านล่างสุดแล้วคลิก Save Settings

สร้าง Lambda Layer

ให้สร้าง Lambda Layer เพื่อใช้ Requests library ใน Python กับ Lambda function นี้ครับ

ดูตัวอย่างได้ที่ลิงก์นี้ครับ

ครั้งนี้จะสร้าง Lambda Layer ตามด้านล่างนี้

ตัวอย่างตั้งค่าการสร้าง Lambda Layer สำหรับครั้งนี้

Layer configuration
Name: python-requests

เลือก ◎ Upload a file from Amazon S3
Amazon S3 link URL: Your S3 URI

Compatible architectures: ✅ x86_64

Compatible runtimes: Python 3.11 Python 3.10 Python 3.7 Python 3.8 Python 3.9 (เลือกเฉพาะ Python)

คลิก Create

สร้าง Lambda function

เข้ามาที่หน้าจอ AWS Lambda ใน AWS Management Console แล้วทำการสร้าง Lambda function ดังนี้

เลือก Functions

คลิก Create function

แล้วจะทำการตั้งค่าหน้า Create function ตามนี้
◎ Author from scratch

Basic information
Function name: aws_usage_notify (ป้อนชื่อที่ต้องการ)
Runtime: Python 3.11
Architecture: ◎ x86_64

คลิก Create function

เมื่อสร้างเสร็จแล้วจะแสดงหน้าจอแบบนี้

ตั้งค่า Configuration

General configuration

เลื่อนลงมาด้านล่าง เลือกแท็บ Configuration แล้วเลือกเมนู General configuration แล้วคลิก Edit

ตั้งค่า Timeout เป็น [0 min 30 sec] แล้วคลิก Save

Permissions

การตั้งค่าในส่วนของ IAM Role นี้ เป็นการเพิ่มสิทธิ์ Cost Explorer ให้กับ Role name ที่ Lambda function ใช้งานอยู่ เพื่อให้ Lambda function สามารถเข้าถึง Cost Explorer และรับข้อมูลไปแจ้งเตือนใน Slack ได้ครับ

เลือกแท็บ Configuration แล้วเลือกเมนู Permissions แล้วคลิก Role name ของเรา แล้วจะย้ายไปหน้าจอของ Role name นี้โดยอัตโนมัติ

เมื่อย้ายมาหน้าจอ Role name ของเราแล้ว คลิก Add permissions และเลือก Create inline policy

Step 1 - Specify permissions
เลือก JSON แล้วลบ Policy เก่าออกทั้งหมด แล้วคัดลอก Policy ด้านล่างนี้วางแทนที่ก็จะแสดงตามรูปภาพ และเลื่อนลงมาด้านล่าง แล้วคลิก Next

Policy นี้เป็น Policy สำหรับอนุญาตการเชื่อมต่อจาก Lambda Function ไปยัง Cost Explorer API

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ce:*"
      ],
      "Resource": [
        "*"
      ]
    }
  ]
}

Step 2 - Review and create
ป้อน Policy name: CostExplorerPolicy (ชื่ออะไรก็ได้) แล้วคลิก Create policy

แล้วดูในแท็บ Permissions จะเห็นว่ามี Policy name: CostExplorerPolicy ที่สร้างขึ้นมาแล้ว

เพิ่ม Code

กลับมาที่หน้าจอ Lambda function แล้วเลือกแท็บ Code แล้วดูที่แท็บ lambda_function ใน Code source
ให้ลบ Code เก่าออกทั้งหมด แล้วคัดลอก Code ด้านล่างนี้วางแทนที่ก็จะแสดงตามรูปภาพด้านล่าง Code นี้
แล้วคลิก Deploy เพื่อบันทึก (อย่าลืมเปลี่ยน [your-webhook-url] ให้เป็น Webhook URL ของคุณ)

import os
import boto3
import json
import requests
from datetime import datetime, timedelta, date
from pprint import pprint

SLACK_WEBHOOK_URL = '[your-webhook-url]'

def lambda_handler(event, context) -> None:
    client = boto3.client('ce', region_name='us-east-1')

    daily_billings = get_total_billing(client)
    # pprint(daily_billings)
    total, service_billings = get_service_billings(client)
    # pprint(service_billings)
    (title, detail) = get_message(daily_billings, service_billings, total)
    # pprint(title)
    # pprint(detail)
    post_slack(title, detail)

def get_total_billing(client) -> dict:
    (start_date, end_date) = get_3days_date_range()

    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='DAILY', # 'DAILY'|'MONTHLY'|'HOURLY'
        Metrics=[
            'AmortizedCost'
        ],
    )

    daily_billings = []
    for item in response['ResultsByTime']:
        daily_billings.append({
            'date': item['TimePeriod']['Start'],
            'billing': float(item['Total']['AmortizedCost']['Amount'])
        })

    return daily_billings

def get_service_billings(client) -> list:
    (start_date, end_date) = get_this_month_date_range()

    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ce.html#CostExplorer.Client.get_cost_and_usage
    response = client.get_cost_and_usage(
        TimePeriod={
            'Start': start_date,
            'End': end_date
        },
        Granularity='MONTHLY', # 'DAILY'|'MONTHLY'|'HOURLY'
        Metrics=[
            'AmortizedCost'
        ],
        GroupBy=[
            {
                'Type': 'DIMENSION',
                'Key': 'SERVICE'
            }
        ]
    )

    billings = []
    total = 0.0
    for item in response['ResultsByTime'][0]['Groups']:
        billings.append({
            'service_name': item['Keys'][0],
            'billing': float(item['Metrics']['AmortizedCost']['Amount'])
        })
        total += float(item['Metrics']['AmortizedCost']['Amount'])

    return total, billings

def get_message(daily_billings: list, service_billings: list, total: float) -> (str, str):
    (start_date, end_date) = get_this_month_date_range()
    end_today = datetime.strptime(end_date, '%Y-%m-%d')
    end_yesterday = (end_today - timedelta(days=1)).strftime('%m/%d')

    title = 'AWS Usage Quick Report\n'
    for item in daily_billings:
        date = item['date']
        billing = item['billing']
        title += f'{date}: {billing:.2f} USD\n'
    title += f'\nTotal: {total:.2f} USD ({start_date} - {end_yesterday})\n'

    details = []
    for item in service_billings:
        service_name = item['service_name']
        billing = round(float(item['billing']), 2)
        if billing == 0.0:
            continue
        details.append(f' * {service_name}: {billing:.2f} USD')

    return title, '\n'.join(details)

def post_slack(title: str, detail: str) -> None:
    # https://api.slack.com/incoming-webhooks
    # https://api.slack.com/docs/message-formatting
    # https://api.slack.com/docs/messages/builder
    payload = {
        'attachments': [
            {
                'color': '#36a64f',
                'pretext': title,
                'text': detail
            }
        ]
    }

    # http://requests-docs-ja.readthedocs.io/en/latest/user/quickstart/
    try:
        response = requests.post(SLACK_WEBHOOK_URL, data=json.dumps(payload))
    except requests.exceptions.RequestException as e:
        print(e)
    else:
        print(response.status_code)

def get_3days_date_range() -> (str, str):
    start_date = get_prev_day(3)
    end_date = get_prev_day(0)
    return start_date, end_date

def get_this_month_date_range() -> (str, str):
    start_date = get_begin_of_month()
    end_date = get_today()
    if start_date == end_date:
        end_of_month = datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=-1)
        begin_of_month = end_of_month.replace(day=1)
        return begin_of_month.date().isoformat(), end_date
    return start_date, end_date

def get_begin_of_month() -> str:
    return date.today().replace(day=1).isoformat()

def get_prev_day(prev: int) -> str:
    return (date.today() - timedelta(days=prev)).isoformat()

def get_today() -> str:
    return date.today().isoformat()

เพิ่ม Lambda Layer

มาที่หัวข้อ Function overview ด้านบน แล้วคลิก Layers (0)

มาที่หัวข้อ Layers แล้วคลิก Add a layer

แล้วจะทำการตั้งค่าหน้า Add layer ดังนี้
Choose a layer
Layer source: ◎ Custom layers
Custom layers: python-requests
Version: 1
คลิก Add

เมื่อเพิ่ม Lambda Layer เสร็จแล้ว จะแสดง Layers (1) แบบนี้

ทดสอบส่งแจ้งเตือน

เราจะทำการทดสอบส่งแจ้งเตือนจาก Lambda function ไปที่ Channel ปลายทางใน Slack ที่สร้างในตอนแรก

เลือกแท็บ Test และคลิก Test แล้วจะแสดงข้อความ Executing function: succeeded แบบนี้
หากต้องการดูรายละเอียดเพิ่มเติม คลิกที่ Log หรือ Details ได้ครับ

แล้วเปิด Channel ปลายทางใน Slack ของเรา จะเห็นว่ามีการส่งแจ้งเตือนค่าใช้จ่ายการใช้งาน AWS มาแล้วครับ

ตั้งค่า Trigger

เราจะทำการตั้งค่า Trigger เพื่อให้ Lambda function ส่งแจ้งเตือนไปที่ Channel ปลายทางใน Slack ของเราตามเวลาที่กำหนดทุกวันโดยอัตโนมัติครับ

มาที่หัวข้อ Function overview ด้านบน แล้วคลิก Add trigger

แล้วจะทำการตั้งค่าหน้า Add trigger ดังนี้
Trigger configuration
ค้นหาและเลือก EventBridge (CloudWatch Events)
Rule: ◎ Create a new rule
Rule name: Daily_8AM (ป้อนชื่อที่ต้องการ)
Rule description: (ป้อนตามต้องการ)
Rule type: ◎ Schedule expression
Schedule expression: cron(0 1 ? * * *) (การตั้งค่านี้เป็นเวลาเท่ากับ 08:00 ของเขตเวลาในประเทศไทย (GMT+7))
คลิก Add

เมื่อตั้งค่า Trigger เสร็จแล้ว จะแสดง EventBridge (CloudWatch Events) แบบนี้ครับ

ผลลัพธ์การตั้งค่า Trigger สำหรับตัวอย่างนี้
เมื่อทำการตั้งค่า Trigger เสร็จแล้ว Lambda function จะส่งแจ้งเตือนไปที่ Channel ปลายทางที่ชื่อ aws_usage ใน Slack ทุกวันตามเวลาที่กำหนดไว้คือ 08:00 ของเขตเวลาในประเทศไทย (GMT+7) โดยอัตโนมัติครับ

เกี่ยวกับการแจ้งเตือน

การแจ้งเตือนที่ได้รับมาในแต่ละวันอาจจะมีผลลัพธ์ที่ไม่ตรงกัน เนื่องจากการเก็บข้อมูลจาก Cost Explorer มีการดีเลย์และใน 1 วันมีการอัพเดทหลายรอบ เช่น ผลลัพธ์ที่แสดงขึ้นจะเป็นของเมื่อวานซึ่งทยอยทำการอัพเดทตามช่วงเวลานั้นๆ จึงทำให้ผลลัพธ์อาจจะมีการเปลี่ยนแปลงขึ้นได้

Channel [aws_usage] ใน Slack

# ผลลัพธ์การแจ้งเตือนของวันที่ 2023-08-10
incoming-webhook APP 2:37 PM
AWS Usage Quick Report
2023-08-07: 9.67 USD
2023-08-08: 7.16 USD
2023-08-09: 6.21 USD

# ผลลัพธ์การแจ้งเตือนของวันที่ 2023-08-11
incoming-webhook APP 8:00 AM
AWS Usage Quick Report
2023-08-08: 7.23 USD
2023-08-09: 8.65 USD
2023-08-10: 4.38 USD

สุดท้ายนี้

เราสามารถส่งแจ้งเตือนค่าใช้จ่ายการใช้งาน AWS ไปยัง Slack ในแต่ละวันได้โดยทำการตั้งค่านี้
หากเราตรวจสอบค่าบริการทุกวัน จะช่วยให้เราเข้าใจเกี่ยวกับค่าใช้จ่ายการใช้งาน AWS ได้เป็นอย่างดี และช่วยให้เราควบคุมการใช้งานต่างๆ ได้มากขึ้น เช่น ไม่ลืมปิดใช้งาน Resource ที่ไม่ได้มีการใช้งานตลอดเวลา หรือ ไม่ลืมลบ Resource ที่ไม่จำเป็นออกไป เป็นต้น ถ้าเราสามารถควบคุมสิ่งเหล่านี้ได้จะทำให้เราประหยัดต้นทุนได้เป็นอย่างดีครับ

ผมหวังว่าบทความนี้จะเป็นประโยชน์ให้กับผู้อ่านได้นะครับ

บทความต้นฉบับ

เรียบเรียงโดย: คุณมินามิ(Keisuke Minami) ประธานบริษัท Classmethod (Thailand)
เขียนโดย: ป๊อป(Tinnakorn Maneewong) จากบริษัท Classmethod (Thailand)

Link อ้างอิง