LoginSignup
2
2

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

Posted at

前書き

より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

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2