😀

AWS料金をSlackに通知するやつ

2024/03/11に公開

前書き

よりAWSのコストを監視しやすくするために、
月初からの請求額を毎日Slackに通知するシステムをサーバーレスで構築してみました。
完成品は下記の図の通りです。

実装

最初はGo 1.xのランタイムを使用していましたが、そのランタイムがAWSで廃止されたため、Node.jsのランタイムを使用してコードを書き直しました。

プロジェクト初期化、serverless frameworkを使用してます、初期化は公式のv3テンプレート使用します、その方が生成されたファイル構成がシンプルです。

serverless create --template-url https://github.com/serverless/examples/tree/v3/aws-node-typescript --path aws-nodejs-typescript-2

AWSのコスト取得するためにaws-sdk/client-cost-explorerを使用します。

yarn add @aws-sdk/client-cost-explorer

serverless.ymlの設定、今回やることはシンプルです、lambda関数からcost-explorerにアクセスできれば良いので、下記のiamを追加します。

serverless.yml
resources:
  Resources:
+        BillingIamRole:
+          Type: AWS::IAM::Role
+          Properties:
+            AssumeRolePolicyDocument:
+              Version: "2012-10-17"
+              Statement:
+                - Effect: Allow
+                  Principal:
+                    Service: lambda.amazonaws.com
+                  Action: "sts:AssumeRole"
+            Policies:
+              - PolicyName: CostExplorerPolicy
+                PolicyDocument:
+                  Version: "2012-10-17"
+                  Statement:
+                    - Effect: Allow
+                      Action:
+                        - "ce:GetCostAndUsage"
+                      Resource: "*"

provider:
  name: aws
  runtime: nodejs18.x
  stage: ${opt:stage, 'dev'}
  region: ap-northeast-1
  profile: default
  iam:
+    role: BillingIamRole
...その他省略

コードの実装はこちら

handeler.ts
import * as AWS from '@aws-sdk/client-cost-explorer';

const SLACK_WEBHOOK_URL: string | undefined = process.env.SLACK_WEBHOOK_URL;
const AWS_REGION: string | undefined = process.env.AWS_REGION;

const costExplorer = new AWS.CostExplorer({ region: AWS_REGION });

interface TotalBilling {
  start: string;
  end: string;
  billing: number;
}

interface ServiceCost {
  serviceName: string;
  cost: number;
}

async function getTotalBilling(): Promise<TotalBilling> {
  const [startDate, endDate] = getMonthlyCostDateRange()

  const params = {
    TimePeriod: {
      Start: startDate,
      End: endDate,
    },
    Granularity: AWS.Granularity.MONTHLY,
    Metrics: ['AmortizedCost'],
  };

  const result = await costExplorer.getCostAndUsage(params);

  const billingAmount = parseFloat(result.ResultsByTime![0].Total!['AmortizedCost'].Amount!);
  return {
    start: startDate,
    end: endDate,
    billing: billingAmount,
  };
}

async function getServiceCosts(): Promise<ServiceCost[]> {
  const [startDate, endDate] = getMonthlyCostDateRange()
  const params = {
    TimePeriod: {
      Start: startDate,
      End: endDate,
    },
    Granularity: AWS.Granularity.MONTHLY,
    Metrics: ['AmortizedCost'],
    GroupBy: [
      {
        Type: AWS.GroupDefinitionType.DIMENSION,
        Key: AWS.Dimension.SERVICE,
      },
    ],
  };

  const result = await costExplorer.getCostAndUsage(params);
  const serviceCosts: ServiceCost[] = result.ResultsByTime![0].Groups!.map((group) => {
    return {
      serviceName: group.Keys![0],
      cost: parseFloat(group.Metrics!['AmortizedCost'].Amount!),
    };
  });

  return serviceCosts;
}

function formatServiceCosts(totalBilling: TotalBilling, serviceCosts: ServiceCost[]): string {
  let formattedText = `期間:${totalBilling.start}${totalBilling.end}\n合計金額:$${totalBilling.billing.toFixed(2)}`;
  formattedText += '\n\n各サービスごとの料金:\n';

  serviceCosts.forEach((serviceCost) => {
    if (serviceCost.cost.toFixed(2) === "0.00") {
      return;
    }
    formattedText += `${serviceCost.serviceName}: $${serviceCost.cost.toFixed(2)}\n`;
  });

  return formattedText;
}

export async function hello(event: any, context: Context): Promise<void> {
  try {
    const totalBilling = await getTotalBilling();
    const serviceCosts = await getServiceCosts();

    const payload = {
      attachments: [
        {
          color: '#36a64f',
          pretext: '今月のAWS利用費用の合計金額',
          text: formatServiceCosts(totalBilling, serviceCosts),
        },
      ],
    };

    const payloadBytes = Buffer.from(JSON.stringify(payload));

    const requestOptions = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: payloadBytes,
    };

    const response = await fetch(SLACK_WEBHOOK_URL!, requestOptions);

    if (response.status !== 200) {
      console.error(`Slack notification failed with status: ${response.status}`);
    }
  } catch (error) {
    console.error('Error:', error);
  }
}

function getMonthlyCostDateRange(): [string, string] {
  const currentDate = new Date();
  const startOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);

  if (currentDate.getDate() === 1) {
    const lastMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
    return [lastMonth.toISOString().slice(0, 10), currentDate.toISOString().slice(0, 10)];
  }

  return [startOfMonth.toISOString().slice(0, 10), currentDate.toISOString().slice(0, 10)];
}

SLACK_WEBHOOK_URLはIncoming Webhookから取得してください。

おまけ

SSOログイン使ってlambdaデプロイもできるようです。

serverless.yml があるディレクトリでインストールします。

npm install --save-dev serverless-better-credentials

pluginsに追加

serverless.yml
+ plugins:
+  - serverless-better-credentials

デプロイする際にssoできるプロフィール指定すれば良いです。

serverless deploy --aws-profile sso-profile

参考記事
https://zenn.dev/snowcait/articles/9d770774a655a5

Discussion