Lambda를 통해 Teams에 AWS 비용 통지

개시하다


람바다로 AWS 비용을 알리기 때문에 메모입니다.
슬랙 알림에는 정보가 가득하지만, 인터넷의 바다에서는 팀스를 알리는 사람을 보지 못했다.
사용할 수 있는 환경이 아니라는 제약도 있고sam은 사용하지 않았다.

Teams로 incoming-webhook을 준비합니다.


공식 서류에 따라 진행하다.웹훅 URL 제어하는 거 잊지 마세요.
https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook

람다만의 IAM Role이 함께 만들어졌습니다.


매니저부터 lambda 페이지까지 함수를 만듭니다.
함수의 이름은 aws_billing 등 임의의 이름으로 만듭니다.
기본 실행 캐릭터가 생성되었기 때문에,iam 페이지에 부속된 정책을 변경하여, 생성된 캐릭터가 비용을 받을 수 있도록 합니다.
우선 다음 두 가지 AWS 관리 정책을 첨부합니다.
  • AWSAccountUsageReportAccess
  • AWSBillingReadOnlyAccess
  • 다음에 정책을 만들고 첨부합니다.
    정책을 작성하려면 라이센스Cost Explorer Service의 ReadOnly를 사용합니다.CostExplorerRead 등 임의의 명칭으로 저장하고 첨부한다.

    Lambda 코드에 대한 설명 (python)


    나는 학급 방법의 슬랙 알림용 글의 코드를 기초 위에 썼다.
    https://dev.classmethod.jp/articles/notify-slack-aws-billing/
    Incomming-Webhook URL은 환경 변수로 만들어 숨기려 했지만 시간이 없어 포기했습니다.
    시간 있을 때 해.TEAMS_WEBHOOK_URL에 이전 장에서 만든 Teams의 Incoming-Webhook URL을 입력합니다.
    import os
    import boto3
    import json
    import requests
    from datetime import datetime, timedelta, date
    
    # TODO: 環境変数化
    # SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL']
    TEAMS_WEBHOOK_URL = "ここにWebhookURLを入力"
    
    
    def lambda_handler(event, context) -> None:
        client = boto3.client('ce', region_name='ap-northeast-1')
    
        # 合計とサービス毎の請求額を取得する
        total_billing = get_total_billing(client)
        service_billings = get_service_billings(client)
    
        # Slack用のメッセージを作成して投げる
        (title, detail) = get_message(total_billing, service_billings)
        print(f'title: {title}')
        print(f'detail: {detail}')
        # post_slack(title, detail)
        post_teams(title, detail, service_billings)
    
    
    def get_total_billing(client) -> dict:
        (start_date, end_date) = get_total_cost_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',
            Metrics=[
                'AmortizedCost'
            ]
        )
        return {
            'start': response['ResultsByTime'][0]['TimePeriod']['Start'],
            'end': response['ResultsByTime'][0]['TimePeriod']['End'],
            'billing': response['ResultsByTime'][0]['Total']['AmortizedCost']['Amount'],
        }
    
    
    def get_service_billings(client) -> list:
        (start_date, end_date) = get_total_cost_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',
            Metrics=[
                'AmortizedCost'
            ],
            GroupBy=[
                {
                    'Type': 'DIMENSION',
                    'Key': 'SERVICE'
                }
            ]
        )
    
        billings = []
    
        for item in response['ResultsByTime'][0]['Groups']:
            billings.append({
                'service_name': item['Keys'][0],
                'billing': item['Metrics']['AmortizedCost']['Amount']
            })
        return billings
    
    
    def get_message(total_billing: dict, service_billings: list) -> (str, str):
        start = datetime.strptime(total_billing['start'], '%Y-%m-%d').strftime('%m/%d')
    
        # Endの日付は結果に含まないため、表示上は前日にしておく
        end_today = datetime.strptime(total_billing['end'], '%Y-%m-%d')
        end_yesterday = (end_today - timedelta(days=1)).strftime('%m/%d')
    
        total = round(float(total_billing['billing']), 2)
    
        title = f'{start}{end_yesterday}の請求額は、{total:.2f} USDです。'
    
        details = []
        for item in service_billings:
            service_name = item['service_name']
            billing = round(float(item['billing']), 2)
    
            if billing == 0.0:
                # 請求無し(0.0 USD)の場合は、内訳を表示しない
                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 post_teams(title: str, detail: str, service_billings: list) -> None:
        # https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL
        # payload = {
        #     'title': title,
        #     'text': detail
        # }
    
        facts = []
        for item in service_billings:
            service_name = item['service_name']
            billing = round(float(item['billing']), 2)
    
            if billing == 0.0:
                # 請求無し(0.0 USD)の場合は、内訳を表示しない
                continue
            dict_tmp = {'name': f'{billing:.2f} USD', 'value':service_name, 'billing':billing}
            facts.append(dict_tmp)
    
        facts_sorted_by_billing = sorted(facts, key=lambda x:x['billing'], reverse=True)
    
        # ソート用に保持していたbilling要素を削除
        for item in facts_sorted_by_billing:
            del item['billing']
    
        payload = {
            '@type': 'MessageCard',
            "@context": "http://schema.org/extensions",
            "themeColor": "0076D7",
            "summary": title,
            "sections": [{
                "activityTitle": title,
                "activitySubtitle": "サービス別利用金額(金額降順)",
                "activityImage": "https://img.icons8.com/color/50/000000/amazon-web-services.png",
                "facts": facts_sorted_by_billing,
                "markdown": 'true',
                "potentialAction": [{
                    "@type": "OpenUri",
                    "name": "Cost Management Console",
                    "targets": [{
                        "os": "default",
                        "uri": "https://console.aws.amazon.com/cost-management/home?region=ap-northeast-1#/dashboard"
                    }]
                }]
            }]
        }
    
        try:
            response = requests.post(TEAMS_WEBHOOK_URL, data=json.dumps(payload))
        except requests.exceptions.RequestException as e:
            print(e)
        else:
            print(response.status_code)
    
    def get_total_cost_date_range() -> (str, str):
        start_date = get_begin_of_month()
        end_date = get_today()
    
        # get_cost_and_usage()のstartとendに同じ日付は指定不可のため、
        # 「今日が1日」なら、「先月1日から今月1日(今日)」までの範囲にする
        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 코드의 변경점


    크라메소의 글 코드와 차이가 있는 부분은 post_teams()부분을 추가했다.
    서비스 분류에 따른 비용은 내림차순으로 표시하는 방식으로 투고한다.
    슬랙의 정보도 다양한 맞춤 제작이 가능해 흥미롭고, 팀스도 어느 정도 맞춤 제작이 가능하다.
    연결기 메시지 규격의 공식 문서는 다음과 같다.
    https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL
    def post_teams(title: str, detail: str, service_billings: list) -> None:
        # https://docs.microsoft.com/ja-jp/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL
        # payload = {
        #     'title': title,
        #     'text': detail
        # }
    
        facts = []
        for item in service_billings:
            service_name = item['service_name']
            billing = round(float(item['billing']), 2)
    
            if billing == 0.0:
                # 請求無し(0.0 USD)の場合は、内訳を表示しない
                continue
            dict_tmp = {'name': f'{billing:.2f} USD', 'value':service_name, 'billing':billing}
            facts.append(dict_tmp)
    
        facts_sorted_by_billing = sorted(facts, key=lambda x:x['billing'], reverse=True)
    
        # ソート用に保持していたbilling要素を削除
        for item in facts_sorted_by_billing:
            del item['billing']
    
        payload = {
            '@type': 'MessageCard',
            "@context": "http://schema.org/extensions",
            "themeColor": "0076D7",
            "summary": title,
            "sections": [{
                "activityTitle": title,
                "activitySubtitle": "サービス別利用金額(金額降順)",
                "activityImage": "https://img.icons8.com/color/50/000000/amazon-web-services.png",
                "facts": facts_sorted_by_billing,
                "markdown": 'true',
                "potentialAction": [{
                    "@type": "OpenUri",
                    "name": "Cost Management Console",
                    "targets": [{
                        "os": "default",
                        "uri": "https://console.aws.amazon.com/cost-management/home?region=ap-northeast-1#/dashboard"
                    }]
                }]
            }]
        }
    
        try:
            response = requests.post(TEAMS_WEBHOOK_URL, data=json.dumps(payload))
        except requests.exceptions.RequestException as e:
            print(e)
        else:
            print(response.status_code)
    

    Teams 발언 아이콘


    이번 코드는 투고한 정보함에 aws 아이콘이 포함된 것인데 배경이 투명한 아이콘만 발견됐기 때문에 Teamas의 어두운 패턴으로 보면 뭔가 미묘한 느낌이 든다...
    만약 배경에 흰색을 칠한aws 아이콘 URL을 아는 사람이 있다면 댓글로 저에게 알려주세요.

    주기적으로 수행되는 트리거 설정


    트리거 추가EventBridge(Cloud Watch Events)를 선택한 다음cron으로 씁니다.
    이번에는 직원들이 근무일 오전 10시에 통지할 수 있도록 cron(0 1 ? * MON-FRI *)결정했다.(UTC이므로 마이너스 9시간)

    만들어진 물건



    끝맺다


    일이 힘들 때 bot을 하면 휴식 시간이 좋아지기 때문에 Teams를 이용하는 분들은 꼭 해보세요.

    좋은 웹페이지 즐겨찾기