【個人学習】QuickSightを使ったEC2 AZ別サポート＆料金ダッシュボード作成

>※ご注意
本記事は「とりあえず動けばOK！」をモットーにした個人学習のアウトプットとして書いています。
前提の環境情報や権限の詳細、セキュリティ設定などは省略している箇所があります。

## はじめに

### 今回の最終目標（動くもの）
**EC2 AZ別サポート & 価格比較ダッシュボード**
EC2 をAZ単位でどのインスタンスタイプがサポートされているのか、1時間あたりの料金はいくらなのかを一覧で確認できるダッシュボードの作成です。

### 今回の背景
以下の記事が当初の動機です。

https://qiita.com/n0mura/items/bf553f7c61bd5ea720d4

私も業務において AWS を利用する機会があります。
AWS 運用の中で以下の点について比較検討する際に最新情報がすぐに取得できるツールがあれば便利だと感じたからです。

- 災害対策：どのリージョンでどのEC2インスタンスタイプがサポートされている？
- コスト最適化：他リージョンと比較したときの価格差は？

あとは、 AWS であまり普段触ったことのないサービス触ってみようくらいです。

### 本記事の目的
- バージニア北部、東京、大阪リージョンの各AZでサポートされている EC2 インスタンスタイプが確認できるダッシュボードを作成する過程の振り返り

- 対象AZ
    - バージニア北部
        - us-east-1a
        - us-east-1b
        - us-east-1c
        - us-east-1d
        - us-east-1e
        - us-east-1f
    - 東京
        - ap-northeast-1a
        - ap-northeast-1c
        - ap-northeast-1d
    - 大阪
        - ap-northeast-3a
        - ap-northeast-3b
        - ap-northeast-3c

## 手順実施後の状態（完成品）

### ▼QuickSightの公開ダッシュボード
- 上画面のグラフ：サポートされているインスタンスタイプ数を表示
- 下画面の表：[OS：Linux]でのUSD/1H 利用料金
　※上手く取得できなかったものは 9,999 表示、サポートされていない場合は空白
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/ff7cd7e6-b5a9-4cdf-90f9-8fcab8635c36.png)

### ▼構成図
計画当初は、AthenaやGlueを使う想定でしたが、データ量も少ないため
とりあえず動くを目標にシンプルな構成となっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/c4d67274-e25e-472f-8cec-fc3fc29d801d.png)

### ダッシュボードでわかること（解決したかったこと）
「今東京で動いているこのEC2 って大阪でも同じもの動くのか？」
「バージニア北部と東京ってどのくらいサポートされているインスタンスタイプに差があるんだ？」
→これを解決できます。

AWSでは随時、サービス拡充がされていますがリージョン単位、もっと言えばAZ単位でのサポート有無があります。
このダッシュボードでは、 EC2 のインスタンスタイプがそのAZでサポートされているのかを確認できます。

コンソールからは、以下の方法で確認できますが、他リージョンと一覧比較は難しいです。

https://dev.classmethod.jp/articles/tsnote-az-ec2-instance-types/

→最新の一覧が欲しい！って時にこのダッシュボードで確認ができます。
　※Lambdaで最新データを取得し、Quicksightのデータも更新する必要有り
 
料金はおまけで付けましたが、上手く取得できないものは、ひとまず9,999表示にしてます。
※空白のデータなしはそもそもサポートされていない判定

## 作成した手順
まずは動くものってことで色々と最適化されていない部分があったり、試行錯誤で作ったのであくまで私の過程を簡略化して説明します。

### １．サポートインスタンスタイプ&料金データ取得部分作成
#### １－１．必要リソース作成
- データ取得用Lambda関数：
    - 関数名：get_ec2_az_pricing
    - ランタイム：Python 3.12
    - 実行ロール：S3へのオブジェクト格納許可（s3:PutObject）を付与
- データ格納用S3バケット：
    - バケット名：ec2-avail-dash-results-20250504
    - フォルダ：data(LambdaのCSVデータ格納先)

#### １－２．Lambda関数の処理実装
- 環境変数：
    - キー：RESULTS_BUCKET
    - 値：ec2-avail-dash-results-20250504

<details><summary>ソースコード全文（lambda_function.py）</summary>

```lambda_function.py

# リージョン→ロケーション名マップ
LOCATION_NAMES = {
    'ap-northeast-1': 'Asia Pacific (Tokyo)',
    'ap-northeast-3': 'Asia Pacific (Osaka)',
    'us-east-1':      'US East (N. Virginia)',
}

# AWS セッション＆クライアント初期化
session        = boto3.Session()
pricing_client = session.client(
    'pricing',
    region_name='us-east-1',
    config=Config(signature_version='v4',
                  retries={'max_attempts': 8, 'mode': 'standard'})
)
s3_client   = session.client('s3')
ec2_clients = { r: session.client('ec2', region_name=r) for r in REGIONS }

# キャッシュ： (instance_type, location_name) → price
price_cache = {}

# ──────────────────────────────────────────────────────
def fetch_price(instance_type, location_name):
    """
    キャッシュ付きで Pricing API から USD 時間単価を取得。
    取得失敗時は "9999" を返す。
    """
    key = (instance_type, location_name)
    if key in price_cache:
        return price_cache[key]

    try:
        resp = pricing_client.get_products(
            ServiceCode='AmazonEC2',
            Filters=[
                {'Type':'TERM_MATCH','Field':'instanceType','Value':instance_type},
                {'Type':'TERM_MATCH','Field':'location','Value':location_name},
                {'Type':'TERM_MATCH','Field':'tenancy','Value':'Shared'},
                {'Type':'TERM_MATCH','Field':'operatingSystem','Value':'Linux'},
            ],
            MaxResults=1
        )
        price_list = resp.get('PriceList', [])
        if not price_list:
            raise ValueError("Empty PriceList")

        # JSON パース
        item     = json.loads(price_list[0])
        od_terms = item['terms'].get('OnDemand', {})

        # "Hrs" 単位の価格ディメンションを探す
        hrs_pd = None
        for sku in od_terms.values():
            for dim in sku.get('priceDimensions', {}).values():
                if dim.get('unit') == 'Hrs':
                    hrs_pd = dim
                    break
            if hrs_pd:
                break
        if not hrs_pd:
            raise ValueError("Hourly price dimension not found")

        price_str = hrs_pd['pricePerUnit'].get('USD', '')
        try:
            usd = float(price_str)
            if usd <= 0.0:
                raise ValueError("Non-positive price")
        except (TypeError, ValueError):
            usd = "9999"

    except Exception as e:
        logger.warning(f"Pricing lookup failed for {instance_type}@{location_name}: {e}")
        usd = "9999"

    price_cache[key] = usd
    return usd

# ──────────────────────────────────────────────────────
def get_ec2_az_pricing(region):
    """
    指定リージョンの全 AZ 提供情報を取得し、
    ユニークなインスタンスタイプを先にまとめて価格フェッチ、
    その後 AZ ごとに価格をマッピングしたリストを返す。
    """
    ec2       = ec2_clients[region]
    paginator = ec2.get_paginator('describe_instance_type_offerings')
    offerings = []
    for page in paginator.paginate(LocationType='availability-zone'):
        offerings.extend(page['InstanceTypeOfferings'])

    loc_name = LOCATION_NAMES[region]

    # ユニークなタイプごとに一回だけ価格取得
    unique_types = {o['InstanceType'] for o in offerings}
    for it in unique_types:
        fetch_price(it, loc_name)

    # AZ 毎にキャッシュ済み price をマッピング
    return [
        {
            'instance_type':      o['InstanceType'],
            'availability_zone':  o['Location'],
            'on_demand_price_usd': price_cache[(o['InstanceType'], loc_name)]
        }
        for o in offerings
    ]

# ──────────────────────────────────────────────────────
def lambda_handler(event, context):
    try:
        summary = {}
        for region in REGIONS:
            data = get_ec2_az_pricing(region)

            # --- CSV 組み立て
            buf    = StringIO()
            writer = csv.writer(buf)
            writer.writerow(['instance_type','availability_zone','on_demand_price_usd'])
            writer.writerows(
                (r['instance_type'], r['availability_zone'], r['on_demand_price_usd'])
                for r in data
            )

            # --- S3 アップロード
            key = f"{KEY_PREFIX}/{region}-ec2.csv"
            s3_client.put_object(
                Bucket      = BUCKET,
                Key         = key,
                Body        = buf.getvalue().encode('utf-8'),
                ContentType = 'text/csv; charset=utf-8'
            )
            logger.info(f"[{region}] s3://{BUCKET}/{key} ({len(data)} records)")
            summary[region] = {'records': len(data), 's3_key': key}

        return {
            'statusCode': 200,
            'body':       json.dumps({'message':'Completed','details':summary})
        }

    except Exception as e:
        logger.error("Unexpected error", exc_info=True)
        return {
            'statusCode': 500,
            'body':       json.dumps({'error': str(e)})
        }

```
</details>

#### Lambda関数の処理実装(振り返りと学び)
##### AZごとのインスタンスタイプ取得について

https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-usage-pagination.html

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/instance-discovery.html#instance-discovery-cli

EC2クライアントのPaginatorを利用
```python
paginator = ec2_clients[region].get_paginator('describe_instance_type_offerings')
offerings = []
for page in paginator.paginate(LocationType='availability-zone'):
    offerings.extend(page['InstanceTypeOfferings'])
```

##### 価格取得について

https://qiita.com/SAITO_Keita/items/b4057639416da076600a

https://techblog.ap-com.co.jp/entry/2025/05/01/134327

https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/price-changes.html

https://stackoverflow.com/questions/51853618/rest-api-aws-ec2-pricing-per-hour

価格表クエリAPIおよび価格表一括APIは、us-east-1 , eu-central-1 , ap-south-1 のみで提供されている。
 - そのため、今回はus-east-1に固定して取得
 - 料金はOSやテナンシーでフィルタリング
 - 例外取得時は9999
 　→ はじめは `Confirm Price Support`の文字列にしていたが、QuickSightのピボットテーブルでは数値しか上手く扱えず代替案として変更した。
```python
pricing_client = session.client(
    'pricing',
    region_name='us-east-1',
    config=Config(signature_version='v4',
                  retries={'max_attempts': 8, 'mode': 'standard'})
)
# ──────────────中略──────────────────────────────────────
resp = pricing_client.get_products(
        ServiceCode='AmazonEC2',
        Filters=[
            {'Type':'TERM_MATCH','Field':'instanceType','Value':instance_type},
            {'Type':'TERM_MATCH','Field':'location','Value':location_name},
            {'Type':'TERM_MATCH','Field':'tenancy','Value':'Shared'},
            {'Type':'TERM_MATCH','Field':'operatingSystem','Value':'Linux'},
        ],
        MaxResults=1
    )
```

##### 改善ポイント
API呼び出しが多すぎて非常に完了まで遅いのです...
- 価格表は別で取得して、ローカルで価格表をもとにフィルタリングした処理へ変更する
- Lambda関数をリージョンごとに分ける

```text:ChatGPT
AWS Price List Bulk API + S3
aws pricing list-price-lists → aws pricing get-price-list-file-url で最新の CSV/JSON URL を取得し、download_file で S3 に保存。Athena や Glue でカタログ化してクエリ。


JSON Index ファイル直接利用
/offers/v1.0/aws/AmazonEC2/current/index.json をダウンロードし、products → terms.OnDemand を一度にロード。SKU ルックアップ＋価格抽出を Python 辞書操作で実行。

Cost and Usage Report（CUR）＋Athena
AWS Billing の CUR にオンデマンド料金を含めた CSV を S3 に出力し、Athena でクエリすることで、実コストベースでの価格取得が可能。

AWS Data Exchange
サードパーティが提供する EC2 価格データセットを定期取得。自前で API 実装する手間を省けます。
```
ChatGPTからも以下のアドバイスをもらったので次回からAPI呼び出しを削減する視点と対応を心がけようと思います。

#### １－３．サポートインスタンスタイプ&料金データ取得結果確認
Lambda関数実行後、3リージョン分のcsvファイルがS3に格納されていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/b598c1ec-13f3-4052-a83f-760cdcd104a4.png)


**結果の正誤性確認方法**
私は以下の観点で確認しました。
- 各リージョンでAZごとのサポートされているインスタンスタイプの数が出力されているCSVデータ内の数と一致しているか？

CloudShellなどで以下を実行し、(例)ap-northeast-1でサポートされているインスタンスタイプの一覧を取得
CSVデータとCLIの取得結果をエクセルで比較しました。
```python:ap-northeast-1
aws ec2 describe-instance-type-offerings\
    --region ap-northeast-1\
    --location-type availability-zone\
    --query "InstanceTypeOfferings[*].{InstanceType:InstanceType,AZ:Location}"\
    --output table
```

- ランダムなCSVデータをAWS 管理コンソールで表示されている料金と一致しているか？
一致していない場合CLIを実行してみて、取得結果の確認
条件が悪いのかもしれませんが、インスタンスタイプによってはCLIで`0.000000`が返ってくるがあったので、これらは`9999`という処理にしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/d8f7e291-d56e-42cb-8cf9-2cf2d5d138f4.png)

```python:Asia Pacific (Tokyo) m5.xlarge
~ $ aws pricing get-products\
    --region us-east-1\
    --service-code AmazonEC2\
    --filters     Type=TERM_MATCH,Field=instanceType,Value=m5.xlarge\
    Type=TERM_MATCH,Field=location,Value="Asia Pacific (Tokyo)"\
    Type=TERM_MATCH,Field=tenancy,Value=Shared\
    Type=TERM_MATCH,Field=operatingSystem,Value=Linux\
    Type=TERM_MATCH,Field=preInstalledSw,Value=NA\
    Type=TERM_MATCH,Field=termType,Value=OnDemand\
    --max-results 1\
    --output json \
    jq -r '
>   .PriceList[0] 
>   | fromjson 
>   | .terms.OnDemand[] 
>   | .priceDimensions[] 
>   | select(.unit=="Hrs") 
>   | .pricePerUnit.USD
> '
0.2480000000
~ $ 
```

### ２．QuickSightセットアップ
今回のAWSアカウントではQuickSightを初めて利用したため、サインアップから行います。

https://docs.aws.amazon.com/ja_jp/quicksight/latest/user/signing-in.html

:::note alert
必ずQuickSightの利用料金をチェックしてから利用しようね！
:::

#### ２－１．QuickSightにサインアップ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/9fd698fa-96ad-4f7b-9019-e5484d38a50a.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/4d27e56a-b726-488d-8fbe-697395bc8bd5.png)

#### ２－２．S3バケットへの許可設定
今回はS3バケット内のデータをデータソースとするため、QuickSightにS3バケットへの許可設定を追加

QuickSightのアカウントページ > セキュリティとアクセス許可 > 
管理 > 対象のバケットを許可

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/0d1f95cd-4f9d-4ebc-948b-d10cf7b08699.png)

ローカルPCにデータソースアクセス用のマニフェストファイルを作成しておく

https://docs.aws.amazon.com/quicksight/latest/user/supported-manifest-file-format.html

- dataフォルダ配下すべてを指定し、フォーマットはCSV 
```json ec2-avail-dash-data.json
{
    "fileLocations": [
      { "URIPrefixes": ["s3://ec2-avail-dash-results-20250504/data/"] }
    ],
    "globalUploadSettings": {
      "format": "CSV",
      "delimiter": ",",
      "textqualifier": "\"",
      "containsHeader": "true"
    }
  }
  ```
  
#### ２－３．データセット新規作成
データセット：QuickSightで分析するデータの元

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/7381a3a0-3b51-45b9-9203-a99c0a36b6eb.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/3a2d8cd4-8759-4e92-b2d7-5e42c92e5090.png)


データセットとして登録できればOK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/f7d8b97f-ef27-4c5d-b8ad-d64ca9c17efc.png)
全てのデータが読み込まれていること
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/99a89c5a-2ac5-46a1-a3b8-323328a2fd90.png)

#### ２－４．データ分析
新しい分析 を作成し、先ほどのデータセットを設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/5c72f39c-864d-4a03-95da-bdf87793cc1b.png)

今回は水平棒グラフとピボットテーブルを利用
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/4b6ec8a3-91bc-487d-ba13-d855bb4dd493.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/ffb6814e-93f7-4d17-af0e-81687809dfde.png)

良い感じにデータを組み合わせて表示色など見せ方を変更すれば「公開」して完了！
※一応水平棒グラフの表示結果は事前に確認したAZでサポートされているインスタンスタイプの数が一致していることなどを確認する。

#### ２－５.ダッシュボードとして公開
公開する際に公開先のダッシュボードを選択（無ければ新規作成）する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/3d502f61-8da0-44e4-9727-0896291082d5.png)

#### 振り返り
https://docs.aws.amazon.com/ja_jp/quicksight/latest/user/conditional-formatting-for-visuals.html

ピボットテーブルの条件付き書式はテーブルと違い、行には適用できません。
列、または値を条件として利用できます。
→ このあたりは、触って調べてわかりました。

また、QuickSight側での権限管理も実際に利用しエラー画面なども確認が出来ました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/085fc57c-a12c-4d45-8926-ac5edeed4c42.png)


### ３．データの更新（今回の場合）
- 【Lambda】データ取得＆CSV作成Lambda関数を実行
- 【QuickSight】データセットを手動で「今すぐ更新」
→ 全ての行が取り込まれていること

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3879433/b9744b4b-a9c2-467a-a0f8-c0694b2aca79.png)
- 【QuickSight】ダッシュボードをリフレッシュ
- 以上で完了 分析から再公開の必要無し

以上！
不要リソースは削除しましょうね！