Help us understand the problem. What is going on with this article?

AWSコストエクスプローラーAPIと気軽につきあう(2020) with Next.js

はじめに

この記事ははてなエンジニアAdvent Calendar 2020の14日目のエントリーです。

この記事では、 next.js を用いた静的サイトとしてAWS コストエクスプローラのデータを利用したダッシュボードを作ってみようという話題を取り扱います。

コストエクスプローラを題材にしてあれやこれやするの、自分にとって一番身近なデータソースだから以上の理由はないですが、適度に複雑な構造のデータで、毎日更新されたり月次で確定未確定が移り変わったり、償却などの概念が入り込んだり、Rate Limit が厳しかったりで、面白い題材だと思います。前回は Cloud9 で頑張ろうとしていた気配が見えます。しかし、残念なことに Cloud9 は約束の地ではなかった。今回はローカルで Visual Studio Code 使ってます。前回コストエクスプローラーAPIを利用することそのものについては触れたので、今回は特にその辺は書いてません。

プロジェクトを作っていきます

ということで、一から作っていきます。

$ mkdir cost-explorer-2020
$ cd cost-explorer-2020/

npm init から。

$ npm init -y

yarn に乗り換えるタイミングを逸して特にこだわりもなく npm です。

next.js のセットアップをする

react と next をインストールする。typescript も手動でセットアップ。

$ npm install next react react-dom
$ npm install --save-dev typescript @types/react @types/node

eslint 関連も入れておく。

$ npm install --save-dev eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
$ npm install --save-dev prettier eslint-config-prettier

2020/12/14 現在、next.js のバージョンは 10.0.3 でした。reactは 17.0.1。(新しいJSXトランスフォーム が有効になってる)

package.json の script セクションに next 関連のコマンドを突っ込みます

package.json
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },

ディレクトリ構成を作ります。アプリケーションは src/ 以下に置きます。

$ mkdir -p src/pages

src/pages/index.tsx を作成し、最初のページを用意します。

src/pages/index.tsx
export default function Home(){
    return <h1>It works!</h1>
}

スクリーンショット 2020-12-14 4.38.56.png

起動すると tsconfig.json とかが生成されるので便利です。tsconfig.json を開いて "strict": true, にしておきましょう。

よりミニマムにやるならこのくらいでいいかなって思って素振り会場記事を別に分けた。

トップページに組織のアカウント一覧を表示する

Node.js で利用開始 - AWS SDK for JavaScriptを眺めつつ aws-sdk for node.js をインストールする。いい翻訳。

$ npm install aws-sdk

まずブロジェクトルートに .env ファイルを作成し、以下のような設定をしておきましょう。これはローカルで実行するための設定で、ECSとかで動かす分にはいらないやつです。

".env"
AWS_SDK_LOAD_CONFIG=1
AWS_PROFILE=default

次に src/aws/ ディレクトリを掘ってサーバーサイドで AWS の API を叩く処理を書いていきます。region: "us-east-1" の指定が必要なのは地味なハマりポイント。

src/aws/get_organization.ts
import * as AWS from 'aws-sdk';
const organizations = new AWS.Organizations({ region: "us-east-1" });

export type AwsOrganizationAccount = {
    id?: string,
    name?: string
}

type Option = { nextToken: string }

const requestAwsOrganizationAccounts = async (opt?: Option): Promise<AwsOrganizationAccount[]> => {
    if (opt && !opt.nextToken) return []
    const { Accounts: accounts, NextToken: newNextToken } =
        await organizations.listAccounts({ NextToken: opt && opt.nextToken }).promise()

    if (accounts) {
        const result: Array<AwsOrganizationAccount> = accounts.map(account => ({
            id: account.Id,
            name: account.Name
        }))
        if (newNextToken) {
            return result.concat(await requestAwsOrganizationAccounts({ nextToken: newNextToken }))
        }
        return result
    }

    return []
}

export async function getOrganizationAccounts() {
    return requestAwsOrganizationAccounts()
}

書いてて .promise() の存在を知ったんですが最高ですか。今まで await new Promise((res,rej)=> ... でラップしてたけど戻り値型がたまに公開されてなくて面倒くさがってた。

src/pages/index.tsx からこの組織アカウント一覧を利用してリストを表示するようにします。getStaticProps がビルド時に実行され、組織アカウント一覧が静的に解決されます。「ビルド時」ではあるもののデプロイ前とは限らなくて、getStaticProps の戻り値に revalidate 属性を含めてやると指定秒数経過後にサーバーサイドで再ビルドが実行されるという振る舞いになる。このあたりの面白さは語り尽くされている気がするので割愛します。

src/pages/index.tsx
import { GetStaticProps } from "next";
import {
  AwsOrganizationAccount,
  getOrganizationAccounts,
} from "../aws/get_organization_accounts";

type Props = {
  organizations: AwsOrganizationAccount[];
};

export const getStaticProps: GetStaticProps<Props> = async () => {
  return { props: { organizations: await getOrganizationAccounts() } };
};

export default function Home(props: Props) {
  return (
    <>
      <h1>AWS Accounts in Orgs</h1>
      <ul>
        {props.organizations.map((organization) => {
          return (
            <li key={organization.id}>
              {organization.name} <span>( {organization.id} )</span>
            </li>
          );
        })}
      </ul>
    </>
  );
}

requestAwsOrganizationAccounts の結果で中身がないデータを弾くのサボってるので、実はorganization.idundefined | null だったら困るのだけど、そこはロックにいくことにする。

ローカルサーバでみてみると、ちゃんとデータが取れてそうだよしよし。

スクリーンショット 2020-12-14 5.48.59.png

/account/1000000 のようなURLでアカウントごとのページを作る

[accountId].tsx というファイル名を作っておくと、任意のパス部分を受け取って パラメータ accountId 設定するような動的ルートが楽しめます。

getStaticPaths() の戻り値で事前ビルド時に生成するパスのリストを制御することができます、ここでは getOrganizationAccounts() で取得した現在存在するアカウント id を使ってパスの集合を決定します。

src/pages/account/[accountId].tsx
import { GetStaticPaths, GetStaticProps } from "next";
import Link from "next/link";
import { getOrganizationAccounts } from "../../aws/get_organization_accounts";

type Props = {
  accountId?: string;
};

type Params = {
  accountId: string;
};

export const getStaticPaths: GetStaticPaths = async () => {
  const accounts = await getOrganizationAccounts();
  return {
    paths: accounts.flatMap((account) =>
      account.id ? `/account/${account.id}` : []
    ),
    fallback: true,
  };
};

export const getStaticProps: GetStaticProps<Props, Params> = async (
  context
) => {
  return { props: { accountId: context.params?.accountId } };
};

export default function Account(props: Props) {
  return (
    <>
      <Link href="/">Home</Link>
      <h1>Account: {props.accountId}</h1>
    </>
  );
}

スクリーンショット 2020-12-14 6.22.22.png

getStaticPaths の戻り値を fallback: true, にして試すとわかる(かもしれない)けど、静的にサーバーサイドでレンダリングされていないページを読み込もうとすると、まず props.accountId が存在しない状態でレンダリングされ、そのあと props が更新される形で再度レンダリングが実行されます。細かいことだけど、URLのパスが常にコンポーネントの初回レンダリング前に供給されるとは限らないので気をつけましょうというお話。

アカウントごとのページでコストエクスプローラのデータを表示する

このままコストエクスプローラの話題が出なかったらどうしようかと思った。

get_organization_accounts.ts 同様に、 src/aws/ 以下に、get_cost_and_usage.ts を置きましょう。例によってクライアントを生成する際、new AWS.CostExplorer({ region: "us-east-1" })するのを気をつけましょう。

src/aws/get_cost_and_usage.ts
import * as AWS from 'aws-sdk';

const costexplorer = new AWS.CostExplorer({ region: "us-east-1" });

const createCostAndUsageParamsByAccount = (accountId: string) => ({
    TimePeriod: { Start: "2020-12-01", End: "2021-01-01" },
    Filter: {
        And: [{
            Dimensions: {
                Key: "LINKED_ACCOUNT",
                Values: [accountId]
            }
        }, {
            Not: {
                Dimensions: {
                    Key: "RECORD_TYPE",
                    Values: ["Credit", "Refund", "Tax", "Upfront", "Support"]
                }
            }
        }]
    },
    Granularity: "MONTHLY",
    Metrics: ["NET_AMORTIZED_COST", "UNBLENDED_COST"],
    GroupBy: [{ Key: "SERVICE", Type: "DIMENSION" },]
})

export type CostAndUsageRecord = {
    Period: string,
    Service: string,
    NetAmortizedCost: number,
}

export async function getCostAndUsage(accountId: string): Promise<CostAndUsageRecord[]> {
    const costAndUsageResult = await costexplorer.getCostAndUsage(createCostAndUsageParamsByAccount(accountId)).promise()
    const result = costAndUsageResult.ResultsByTime?.flatMap(r =>
        (r.TimePeriod && r.Groups) ?
            r.Groups.map(g => (
                {
                    Period: `${r.TimePeriod?.Start}`,
                    Service: g.Keys?.flat().join("") || "",
                    NetAmortizedCost: Number.parseFloat(g.Metrics?.NetAmortizedCost?.Amount || "NaN"),
                }
            ))
            : []
    )
    return result || []
}

ES2019ターゲットだと flatMap で type refinement が効くの感動した! クライアント向けだとどうかなって気はするけどこの種のダッシュボードは内部向けだろうから平気かなという気持ちになっている。

帰ってくるデータの全部の属性が optional なのが少し不便ですがまあいいでしょう。

src/pages/account/[accountId].tsx を編集して getStaticProps() でパラメータで受け取ったアカウントIDを使ってコストエクスプローラのデータを取得するようにしました。

src/pages/account/[accountId].tsx
import { GetStaticPaths, GetStaticProps } from "next";
import Link from "next/link";
import {
  CostAndUsageRecord,
  getCostAndUsage,
} from "../../aws/get_cost_and_usage";
import { getOrganizationAccounts } from "../../aws/get_organization_accounts";

type Props = {
  accountId?: string;
  costAndUsage?: CostAndUsageRecord[];
};

type Params = {
  accountId: string;
};

export const getStaticPaths: GetStaticPaths = async () => {
  const accounts = await getOrganizationAccounts();
  return {
    paths: accounts.flatMap((account) =>
      account.id ? `/account/${account.id}` : []
    ),
    fallback: true,
  };
};

export const getStaticProps: GetStaticProps<Props, Params> = async (
  context
) => {
  if (!context.params?.accountId) {
    return { props: {} };
  }
  const costAndUsage = await getCostAndUsage(context.params?.accountId);

  return { props: { accountId: context.params.accountId, costAndUsage } };
};

export default function Account(props: Props) {
  if (!props.accountId) {
    return <>AccountId is not provided.</>;
  }

  return (
    <>
      <Link href="/">Home</Link>
      <h1>AccountId: {props.accountId}</h1>
      <ul>
        {props.costAndUsage?.map((record,i) => (
          <li key={`i`}>
            {record.Period}: {record.Service} : {record.NetAmortizedCost}
          </li>
        ))}
      </ul>
    </>
  );
}

スクリーンショット 2020-12-14 8.04.26.png

動いた。これでお手軽に自由なダッシュボードが構築できます。

なお API Call Limit

Cost Explorer APIはコールリミットが厳しく、呼び出し単価も高額な部類のAPIなので、フルの事前ビルドは現実的ではなく、ISR使えるなら正直使いたい。使えない場合は getStaticProps で利用するデータをうまく流速をコントロールして取得する、バッチで取得したものをキャッシュしておくなどの対処が必要そう。

ビルドの並列数はCPU数に依存するので...

シンプルなウェイト await new Promise((r) => setTimeout(r, 2000)); などをAPIコール後などに埋めた上で、以下のように next.config.js で CPU数指定をすると、1ページごとにビルドされるようになってAPIコール数のリミットを回避することに手元では成功した。かなり苦しい感じはする。

next.config.js
module.exports = {
    experimental: {
        cpus: 1
    }
}

おまけで .vscode/settings.json

organizeImports と fixAll.eslint があるとだいぶ快適でした。ふと思い立って node_modules をエクスプローラから除外したのですが、これによってかなり生活の質が向上しました。型定義ファイルを開いた時に node_modules がいちいち広がってそれを閉じるのがけっこう嫌だった模様です。

vscode/settings.json
{
  "editor.formatOnSave": true, 
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.organizeImports": true
  },
  "[typescript]": {
    "editor.defaultFormatter": "vscode.typescript-language-features"
  },
  "files.exclude": {
    "node_modules":true
  }
}

まとめ

  • 一年前と比べてブラウザIDEへの夢は失われた模様
  • node の aws-sdk は使い勝手悪い印象が強かったけど .promise() があって良くなってた
  • 現代の typescript ではぼっち演算子(?.)が使えるのでネスト構造がだいぶ楽
  • 現代の typescript / ES では flatMap が type refinement してくれるのかなり賢いと思った
  • この作りだとアカウントがいっぱいあるとフルビルドは厳しい。
  • 複数のパス/ページにまたがって共通のデータにアクセスしたいけどいいやり方あるかな。
  • あんまりコストエクスプローラ出てこなかった

ということで、12月15日は @astj さん、Goの話との噂。よろしくお願いします。

hatena-corp
「知る」「つながる」「表現する」で新しい体験を提供し、人の生活を豊かにする
https://hatenacorp.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away