├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── serverless.yml ├── src ├── config.ts ├── functions │ └── billing-notifier.ts └── utils │ ├── billing.ts │ ├── currency.ts │ └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = true 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "prettier" 7 | ], 8 | "plugins": [ 9 | "node", 10 | "@typescript-eslint" 11 | ], 12 | "env": { 13 | "node": true, 14 | "es6": true 15 | }, 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "sourceType": "module" 19 | }, 20 | "rules": { 21 | "@typescript-eslint/no-explicit-any": 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .serverless/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 mpyw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Billing Slack Notification 2 | 3 | Node.js 14 以降向けの AWS 当月利用料金 Slack 通知スクリプト 4 | 5 | ![実行例](https://user-images.githubusercontent.com/1351893/45200732-2387f880-b2ad-11e8-8f22-be0ee86c9193.png) 6 | 7 | ## 環境構築手順 8 | 9 | 1. [Serverless Getting Started Guide](https://www.serverless.com/framework/docs/getting-started/) を参考に, Serverless Framework をグローバルインストール 10 | 2. `npm i` を実行 11 | 12 | ## 設定 13 | 14 | 絵文字は会社で使っているやつに合わせているので,各自 **[src/config.ts](./src/config.ts)** の修正は必須です。 15 | 16 | ## 使い方 17 | 18 | ### TypeScript のビルド 19 | 20 | ```bash 21 | npm run build 22 | ``` 23 | 24 | ### デプロイを実行 25 | 26 | ```bash 27 | # 単一アカウント 28 | SLACK_WEBHOOK_URL='https://...' npm run deploy 29 | 30 | # 複数アカウント 31 | SLACK_WEBHOOK_URL='https://...' AWS_PROFILE=xxxxx ACCOUNT_NAME=アカウントX npm run deploy 32 | SLACK_WEBHOOK_URL='https://...' AWS_PROFILE=yyyyy ACCOUNT_NAME=アカウントY npm run deploy 33 | ``` 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-lambda-billing-slack-notification", 3 | "description": "AWS 当月利用料金 Slack 通知スクリプト", 4 | "private": true, 5 | "scripts": { 6 | "check": "tsc --noEmit", 7 | "build": "rm -rf ./dist && tsc", 8 | "lint:eslint": "eslint ./src", 9 | "lint:prettier": "prettier ./src ./serverless.yml --list-different", 10 | "lint": "npm run lint:eslint && npm run lint:prettier", 11 | "fix:eslint": "npm run lint:eslint -- --fix", 12 | "fix:prettier": "prettier ./src ./serverless.yml --write", 13 | "fix": "npm run fix:eslint && npm run fix:prettier", 14 | "deploy": "serverless deploy" 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-cost-explorer": "^3.23.0", 18 | "@slack/webhook": "^6.0.0", 19 | "@types/aws-lambda": "^8.10.81", 20 | "@types/node": "^15.14.3", 21 | "axios": "^0.21.1", 22 | "dayjs": "^1.10.6" 23 | }, 24 | "devDependencies": { 25 | "@typescript-eslint/eslint-plugin": "^4.28.5", 26 | "@typescript-eslint/parser": "^4.28.5", 27 | "eslint": "^7.31.0", 28 | "eslint-config-prettier": "^8.3.0", 29 | "eslint-plugin-node": "^11.1.0", 30 | "prettier": "^2.3.2", 31 | "serverless": "^2.52.1", 32 | "typescript": "^4.3.5" 33 | }, 34 | "engines": { 35 | "node": ">=14" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: aws-lambda-billing-slack-notification 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs14.x 6 | stage: ${opt:stage, 'prd'} 7 | profile: ${env:AWS_PROFILE, 'default'} 8 | region: us-east-1 9 | lambdaHashingVersion: 20201221 10 | environment: 11 | SLACK_WEBHOOK_URL: ${env:SLACK_WEBHOOK_URL} 12 | ACCOUNT_NAME: ${env:ACCOUNT_NAME, ''} 13 | iam: 14 | role: 15 | statements: 16 | - Effect: Allow 17 | Action: 18 | - ce:GetCostAndUsage 19 | Resource: '*' 20 | 21 | functions: 22 | billing-notifier: 23 | handler: dist/functions/billing-notifier.handler 24 | name: billing-notifier 25 | description: 'Accumulate monthly charges and post them daily to Slack' 26 | events: 27 | - schedule: 'cron(0 3 ? * * *)' 28 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { IncomingWebhookDefaultArguments } from '@slack/webhook'; 2 | 3 | export const slackOptions: Readonly = { 4 | channel: '#general', 5 | username: 'AWS Lambda', 6 | icon_emoji: ':aws:', 7 | }; 8 | 9 | export const serviceAliases = { 10 | Total: '合計', 11 | 'AWS Backup': 'Backup', 12 | 'AWS CloudTrail': 'CloudTrail', 13 | 'AWS Direct Connect': 'DirectConnect', 14 | 'AWS Key Management Service': 'KMS', 15 | 'AWS Lambda': 'Lambda', 16 | 'Amazon CloudFront': 'CloudFront', 17 | 'AWS Config': 'Config', 18 | 'Amazon EC2 Container Registry (ECR)': 'ECR', 19 | 'Amazon EC2 Container Service': 'ECS', 20 | 'Amazon ElastiCache': 'ElastiCache', 21 | 'EC2 - Other': 'NATGateway', 22 | 'Amazon Elastic Compute Cloud - Compute': 'EC2', 23 | 'Amazon GuardDuty': 'GuardDuty', 24 | 'Amazon Elastic Load Balancing': 'ALB/ELB', 25 | 'Amazon Elasticsearch Service': 'ES', 26 | 'Amazon Elastic File System': 'EFS', 27 | 'Amazon Relational Database Service': 'RDS', 28 | 'Amazon Route 53': 'Route53', 29 | 'Amazon Simple Email Service': 'SES', 30 | 'Amazon Simple Notification Service': 'SNS', 31 | 'Amazon Simple Storage Service': 'S3', 32 | AmazonCloudWatch: 'CloudWatch', 33 | 'AWS Elemental MediaLive': 'MediaLive', 34 | 'AWS Elemental MediaStore': 'MediaStore', 35 | 'Amazon DynamoDB': 'DynamoDB', 36 | 'Amazon Elastic Transcoder': 'ElasticTranscoder', 37 | 'AWS Elemental MediaConvert': 'MediaConvert', 38 | 'Amazon Simple Queue Service': 'SQS', 39 | Tax: '税金', 40 | } as const; 41 | 42 | export const serviceEmoji: Readonly< 43 | Record 44 | > = { 45 | Total: ':money_with_wings:', 46 | 'AWS Backup': ':luggage:', 47 | 'AWS CloudTrail': ':aws-cloudwatch:', 48 | 'AWS Direct Connect': ':handshake:', 49 | 'AWS Key Management Service': ':key:', 50 | 'AWS Lambda': ':lambda:', 51 | 'Amazon CloudFront': ':lightning:', 52 | 'AWS Config': ':eyes:', 53 | 'Amazon EC2 Container Registry (ECR)': ':docker:', 54 | 'Amazon EC2 Container Service': ':aws_ecs:', 55 | 'Amazon ElastiCache': ':redis:', 56 | 'EC2 - Other': ':aws-es:', 57 | 'Amazon Elastic Compute Cloud - Compute': ':linux_penguin:', 58 | 'Amazon GuardDuty': ':cop:', 59 | 'Amazon Elastic Load Balancing': ':scales:', 60 | 'Amazon Elasticsearch Service': ':elasticsearch:', 61 | 'Amazon Elastic File System': ':file_folder:', 62 | 'Amazon Relational Database Service': ':mysql_dolphin:', 63 | 'Amazon Route 53': ':route53:', 64 | 'Amazon Simple Email Service': ':email:', 65 | 'Amazon Simple Notification Service': ':iphone:', 66 | 'Amazon Simple Storage Service': ':card_file_box:', 67 | AmazonCloudWatch: ':aws-cloudwatch:', 68 | 'AWS Elemental MediaLive': ':movie_camera:', 69 | 'AWS Elemental MediaStore': ':vhs:', 70 | 'Amazon DynamoDB': ':aws-dynamodb:', 71 | 'Amazon Elastic Transcoder': ':film_frames:', 72 | 'AWS Elemental MediaConvert': ':film_frames:', 73 | 'Amazon Simple Queue Service': ':aws_sqs:', 74 | Tax: ':moneybag:', 75 | } as const; 76 | -------------------------------------------------------------------------------- /src/functions/billing-notifier.ts: -------------------------------------------------------------------------------- 1 | import * as config from '../config'; 2 | import { billing } from '../utils'; 3 | import { ScheduledHandler } from 'aws-lambda'; 4 | import dayjs, { Dayjs } from 'dayjs'; 5 | import { IncomingWebhook, IncomingWebhookSendArguments } from '@slack/webhook'; 6 | import { slackOptions } from '../config'; 7 | 8 | const envs = { 9 | ACCOUNT_NAME: process.env.ACCOUNT_NAME as string, 10 | SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL, 11 | } as const; 12 | 13 | const now = dayjs(); 14 | 15 | export const handler: ScheduledHandler = async () => { 16 | if (!envs.SLACK_WEBHOOK_URL) { 17 | throw new Error('Missing environment variable: SLACK_WEBHOOK_URL'); 18 | } 19 | 20 | const { start, end } = prepareDates(); 21 | 22 | const summary = await billing.summary(start, end); 23 | 24 | const fields = Object.entries(summary) 25 | .filter(ignoreSmallAmountFilter) 26 | .sort(sortComparator) 27 | .map(([service, { usd, jpy }]) => ({ 28 | title: `${ 29 | config.serviceAliases[service as keyof typeof config.serviceAliases] || 30 | service 31 | } ${ 32 | config.serviceEmoji[service as keyof typeof config.serviceEmoji] || 33 | ':question:' 34 | }`, 35 | value: `${Math.floor(jpy / 100) * 100}円 ($${ 36 | Math.floor(usd * 100) / 100 37 | })`, 38 | short: true, 39 | })); 40 | 41 | const slack = new IncomingWebhook(envs.SLACK_WEBHOOK_URL); 42 | const { text } = await slack.send(preparePayload(envs.ACCOUNT_NAME, fields)); 43 | 44 | console.log(text); 45 | }; 46 | 47 | const preparePayload = ( 48 | accountName: string, 49 | fields: Exclude< 50 | Required['attachments'][number]['fields'], 51 | undefined 52 | > 53 | ): IncomingWebhookSendArguments => { 54 | const previousMonth = `${now.subtract(1, 'month').format('M')}月`; 55 | const whose = accountName ? ` ${accountName} の` : ''; 56 | 57 | const messages = isStarting() 58 | ? { 59 | fallback: `${previousMonth}の${whose} AWS 利用費は ${fields[0]?.value} です。`, 60 | pretext: `${previousMonth}の${whose} AWS 利用費が確定しました`, 61 | color: '#e0a837', 62 | } 63 | : { 64 | fallback: `今月の${whose} AWS 利用費は ${fields[0]?.value} です。`, 65 | pretext: `今月の${whose} AWS 利用費は…`, 66 | color: 'good', 67 | }; 68 | 69 | return { 70 | ...slackOptions, 71 | attachments: [ 72 | { 73 | ...messages, 74 | fields, 75 | }, 76 | ], 77 | }; 78 | }; 79 | 80 | const isStarting = (): boolean => { 81 | return now.startOf('date').isSame(now.startOf('month')); 82 | }; 83 | 84 | const prepareDates = (): Record<'start' | 'end', Dayjs> => { 85 | let start = now.startOf('month'); 86 | let end = start.add(1, 'month'); 87 | 88 | if (isStarting()) { 89 | start = start.subtract(1, 'month'); 90 | end = end.subtract(1, 'month'); 91 | } 92 | 93 | return { start, end }; 94 | }; 95 | 96 | const ignoreSmallAmountFilter = ([, { jpy }]: [ 97 | unknown, 98 | billing.BillingOfServiceResult 99 | ]): boolean => { 100 | return Math.floor(jpy / 100) * 100 > 0; 101 | }; 102 | 103 | const sortComparator = ( 104 | [x, { jpy: a }]: [string, billing.BillingOfServiceResult], 105 | [y, { jpy: b }]: [string, billing.BillingOfServiceResult] 106 | ): number => { 107 | if (x === 'Total') return 0; 108 | if (y === 'Total') return 1; 109 | if (x === 'Tax') return 0; 110 | if (y === 'Tax') return 1; 111 | return Number(a < b) - Number(a > b); 112 | }; 113 | -------------------------------------------------------------------------------- /src/utils/billing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CostExplorer, 3 | GetCostAndUsageCommandInput, 4 | GetCostAndUsageResponse, 5 | } from '@aws-sdk/client-cost-explorer'; 6 | import { usd2jpy } from './currency'; 7 | import { Dayjs } from 'dayjs'; 8 | 9 | export type BillingOfServiceResult = { 10 | usd: number; 11 | jpy: number; 12 | }; 13 | export type BillingOfServicesResult = Record; 14 | 15 | const ce = new CostExplorer({ region: 'us-east-1' }); 16 | 17 | export const summary = async ( 18 | start: Dayjs, 19 | end: Dayjs 20 | ): Promise => { 21 | const results = await Promise.all([ 22 | request(start, end, true), 23 | request(start, end, false), 24 | ]); 25 | 26 | return Object.assign({}, ...results); 27 | }; 28 | 29 | export const request = async ( 30 | start: Dayjs, 31 | end: Dayjs, 32 | total: boolean 33 | ): Promise => { 34 | const params: GetCostAndUsageCommandInput = { 35 | TimePeriod: { 36 | Start: start.format('YYYY-MM-DD'), 37 | End: end.format('YYYY-MM-DD'), 38 | }, 39 | GroupBy: [{ Key: 'SERVICE', Type: 'DIMENSION' }], 40 | Granularity: 'MONTHLY', 41 | Metrics: ['BlendedCost'], 42 | }; 43 | 44 | if (total) { 45 | delete params.GroupBy; 46 | } 47 | 48 | const response = await ce.getCostAndUsage(params); 49 | return total ? handleTotal(response) : handleGroups(response); 50 | }; 51 | 52 | const handleTotal = async ({ 53 | ResultsByTime: [{ Total }] = [{}], 54 | }: GetCostAndUsageResponse): Promise => { 55 | return { 56 | Total: { 57 | usd: Number(Total?.BlendedCost.Amount || 0), 58 | jpy: await usd2jpy(Total?.BlendedCost.Amount || 0), 59 | }, 60 | }; 61 | }; 62 | 63 | const handleGroups = async ({ 64 | ResultsByTime: [{ Groups }] = [{}], 65 | }: GetCostAndUsageResponse): Promise => { 66 | const entries = await Promise.all( 67 | Groups?.map( 68 | async ({ 69 | Keys: [Key] = [], 70 | Metrics: { BlendedCost: { Amount } } = {}, 71 | }) => ({ 72 | [Key]: { 73 | usd: Number(Amount || 0), 74 | jpy: await usd2jpy(Number(Amount || 0)), 75 | }, 76 | }) 77 | ) || [] 78 | ); 79 | 80 | return Object.assign({}, ...entries); 81 | }; 82 | -------------------------------------------------------------------------------- /src/utils/currency.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | type Quote = { 4 | open: string; 5 | currencyPairCode: string; 6 | }; 7 | type Result = { 8 | quotes: Quote[]; 9 | }; 10 | 11 | let quotes: null | Quote[] = null; 12 | 13 | export const convert = async ( 14 | source: string | number, 15 | pair: string 16 | ): Promise => { 17 | if (!quotes) { 18 | ({ 19 | data: { quotes }, 20 | } = await axios.get( 21 | 'https://www.gaitameonline.com/rateaj/getrate' 22 | )); 23 | } 24 | const result = quotes.find( 25 | ({ currencyPairCode }) => currencyPairCode === pair 26 | ); 27 | if (!result) { 28 | throw new Error('Failed to fetch currency rate'); 29 | } 30 | return Number(source) * Number(result.open); 31 | }; 32 | 33 | export const usd2jpy = async (usd: string | number): Promise => 34 | convert(usd, 'USDJPY'); 35 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * as billing from './billing'; 2 | export * as currency from './currency'; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "lib": [], 6 | "outDir": "dist/", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true 16 | }, 17 | "exclude": [ 18 | "node_modules" 19 | ], 20 | "include": [ 21 | "src/**/*.ts" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------