AWSで作成したリソースをサイトから確認したい

# はじめに
AWSで放置しているリソースがあると怖い、でもそれぞれ個別に確認していくのは面倒...
ということでいくつかの種類のAWSリソースをブラウザから確認できるようなLambda関数を作成し、S3に配置したindex.htmlで参照できるようにしたいと考えました。
これが最適な方法では無いような気がしますが、こういった解決策もあるという一例として記事にしました。

# 1. 構成
![check_resource.drawio (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365485/943bcfee-3725-4abe-9f69-d566d7989763.png)

それぞれ解説します。

## EventBridge
ここではLambda関数を定期実行します。主にAWSを操作する平日の9時から18時で、あまり実行回数が多いとコストが心配なのでまずは30分に1回だけ実行することにしました。

## Lambda
boto3を使ってリソースの名前を取得していき、名前をリストとしたjsonをS3に保存しています。
**リソースを自動的に取得するのではなく、個別に設定が必要です。自動取得を求めていた人はごめんなさい！**

## S3
まずは取得したリソースをresources.jsonというファイルに保存しようと思いました。理由としては情報を出力する方法を決めていなかったので、後々活用しやすいように考えました。
最終的にはwebサイトの形式になり、index.htmlも同じバケットに保存しました。

## CloudFront
S3にresource.jsonがある都合上、直接のアクセスはしたくなかったので、CloudFrontからのアクセスに絞っています。

# 2. 詳細な設定
## Lambda
まずLambda関数を作ります。
Pythonを使用しました。
【追記】以下のコードについてコメントで補足を頂いたので、もっと綺麗に書きたい方は確認してみてください！！

```python
import boto3
import json
import os
from datetime import datetime, timezone, timedelta

def get_resource_details(region):
    resources = {}
    ec2 = boto3.client('ec2', region_name=region)
    rds = boto3.client('rds', region_name=region)
    s3 = boto3.client('s3', region_name=region)
    lambda_client = boto3.client('lambda', region_name=region)

    resources['EC2 Instances'] = []
    try:
        running_instances = ec2.describe_instances(Filters=[{'Name': 'instance-state-name', 'Values': ['running']}])
        for reservation in running_instances['Reservations']:
            for instance in reservation['Instances']:
                name = ''
                for tag in instance.get('Tags', []):
                    if tag['Key'] == 'Name':
                        name = tag['Value']
                        break
                resources['EC2 Instances'].append({'Id': instance['InstanceId'], 'Name': name or instance['InstanceId']})
    except Exception as e:
        resources['EC2 Instances'].append({'Error': str(e)})

    resources['RDS Instances'] = []
    try:
        available_rds = rds.describe_db_instances()
        for db_instance in available_rds['DBInstances']:
            resources['RDS Instances'].append({'Id': db_instance['DBInstanceIdentifier'], 'Name': db_instance['DBInstanceIdentifier']}) # RDS は Identifier が名前の役割を果たすことが多い
    except Exception as e:
        resources['RDS Instances'].append({'Error': str(e)})

    resources['S3 Buckets'] = []
    try:
        buckets = s3.list_buckets()
        for bucket in buckets.get('Buckets', []):
            resources['S3 Buckets'].append({'Name': bucket['Name']})
    except Exception as e:
        resources['S3 Buckets'].append({'Error': str(e)})
    
    resources['Lambda Functions'] = []
    try:
        response = lambda_client.list_functions()
        for function in response.get('Functions', []):
            resources['Lambda Functions'].append({'Name': function['FunctionName']})
        while 'NextMarker' in response:
            response = lambda_client.list_functions(Marker=response['NextMarker'])
            for function in response.get('Functions', []):
                resources['Lambda Functions'].append({'Name': function['FunctionName']})
    except Exception as e:
        resources['Lambda Functions'].append({'Error': str(e)})

    # 他の監視したいリソースの詳細情報を取得する処理をここに追加できます

    return resources

def lambda_handler(event, context):
    region = os.environ.get('AWS_REGION', 'ap-northeast-1') # デフォルトリージョン
    s3_bucket_name = os.environ.get('S3_BUCKET_NAME')
    s3_key = 'resources.json'

    if not s3_bucket_name:
        print("S3_BUCKET_NAME 環境変数が設定されていません。")
        return {
            'statusCode': 500,
            'body': json.dumps({'error': 'S3_BUCKET_NAME が設定されていません。'})
        }

    resource_data = {
        'timestamp': datetime.now(timezone(timedelta(hours=9))).isoformat(),
        'resources': get_resource_details(region)
    }

    try:
        s3 = boto3.client('s3')
        response = s3.put_object(
            Bucket=s3_bucket_name,
            Key=s3_key,
            Body=json.dumps(resource_data, default=str), # デフォルトでシリアライズできない型を文字列に変換
            ContentType='application/json'
        )
        print(f"S3 への書き込みに成功しました: {response}")
        return {
            'statusCode': 200,
            'body': json.dumps({'message': 'リソース詳細情報を S3 に書き込みました。'})
        }
    except Exception as e:
        print(f"S3 への書き込みに失敗しました: {e}")
        return {
            'statusCode': 500,
            'body': json.dumps({'error': f'S3 への書き込みに失敗しました: {str(e)}'})
        }

if __name__ == "__main__":
    # ローカルテスト用
    os.environ['S3_BUCKET_NAME'] = 'your-s3-bucket-name' # 実際のバケット名に置き換えてください
    result = lambda_handler(None, None)
    print(result)
```

このプログラムではサンプルとしてEC2、RDS、S3、Lambdaのリソース名を取得しています。他の種類のリソースを取得する場合は、プログラムの内容を参考にget_resource_details関数に各自追加してください。

**またresources.jsonを書き込むためのバケット名をS3_BUCKET_NAMEとして、使用するリージョンをAWS_REGIONとして環境変数に追加してください。**


## S3
ここではindex.htmlの内容を残しておきます。
参考としてざっくり１ファイルで書いているだけなので簡易的なものです。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AWS リソース情報</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
    <style>
        body {
            background-color: #f8f9fa;
        }
        .container {
            margin-top: 30px;
        }
        .resource-section {
            margin-bottom: 20px;
            border: 1px solid #dee2e6;
            border-radius: 0.25rem;
        }
        .resource-header {
            background-color: #e9ecef;
            padding: 0.75rem 1.25rem;
            border-bottom: 1px solid #dee2e6;
            border-top-left-radius: 0.25rem;
            border-top-right-radius: 0.25rem;
        }
        .resource-body {
            padding: 1.25rem;
        }
        .error {
            color: #dc3545;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1 class="mb-4">AWS リソース情報</h1>
        <div id="resource-info">
            <div class="alert alert-info" role="alert">
                リソース情報を読み込み中です...
            </div>
        </div>
    </div>

    <script>
        async function fetchResources() {
            try {
                const response = await fetch('/resources.json');
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
                const data = await response.json();
                displayResources(data);
            } catch (error) {
                document.getElementById('resource-info').innerHTML = `
                    <div class="alert alert-danger" role="alert">
                        リソース情報の取得に失敗しました: ${error}
                    </div>
                `;
                console.error("リソース情報の取得に失敗しました:", error);
            }
        }

        function displayResources(data) {
            const resourceInfoDiv = document.getElementById('resource-info');
            resourceInfoDiv.innerHTML = '';

            const timestamp = document.createElement('p');
            timestamp.classList.add('text-muted', 'mb-3');
            timestamp.textContent = `最終更新: ${new Date(data.timestamp).toLocaleString()}`;
            resourceInfoDiv.appendChild(timestamp);

            for (const [resourceType, resources] of Object.entries(data.resources)) {
                const section = document.createElement('div');
                section.classList.add('resource-section');

                const header = document.createElement('div');
                header.classList.add('resource-header');
                const title = document.createElement('h6');
                title.classList.add('mb-0');
                title.textContent = resourceType;
                header.appendChild(title);
                section.appendChild(header);

                const body = document.createElement('div');
                body.classList.add('resource-body');
                const list = document.createElement('ul');
                list.classList.add('list-unstyled');

                if (Array.isArray(resources) && resources.length > 0) {
                    resources.forEach(item => {
                        const listItem = document.createElement('li');
                        if (item.Name) {
                            listItem.textContent = `${item.Name}`;
                        } else if (item.Id) {
                            listItem.textContent = `ID: ${item.Id}`;
                        } else if (item.Error) {
                            listItem.classList.add('error');
                            listItem.textContent = `エラー: ${item.Error}`;
                        } else {
                            listItem.textContent = '情報なし';
                        }
                        list.appendChild(listItem);
                    });
                } else if (Array.isArray(resources) && resources.length === 0) {
                    const emptyMessage = document.createElement('p');
                    emptyMessage.classList.add('text-muted');
                    emptyMessage.textContent = '該当するリソースはありません。';
                    body.appendChild(emptyMessage);
                } else if (resources && resources.Error) {
                    const errorMessage = document.createElement('p');
                    errorMessage.classList.add('error');
                    errorMessage.textContent = `エラー: ${resources.Error}`;
                    body.appendChild(errorMessage);
                } else {
                    const noDataMessage = document.createElement('p');
                    noDataMessage.classList.add('text-muted');
                    noDataMessage.textContent = 'リソース情報の形式が不正です。';
                    body.appendChild(noDataMessage);
                }
                body.appendChild(list);
                section.appendChild(body);
                resourceInfoDiv.appendChild(section);
            }
        }

        // ページロード時にリソース情報を取得
        fetchResources();

        // 定期的にリソース情報を更新 (例: 1分ごと)
        setInterval(fetchResources, 60000);
    </script>
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.5.3/dist/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
</body>
</html>
```

Lambdaの実行結果をresources.jsonに出力したため、resources.jsonを取得して表示するだけのシンプルな構造になっていると思います。

また**セキュリティ上の理由から、S3静的ウェブサイトホスティングは使用せず、パブリックアクセスも完全にブロックしています。**
CloudFrontからのアクセスに限定するため、後述するOAC（オリジンアクセス制御）を設定します。

## CloudFrontの設定
CloudFrontではS3との接続のためにOACの設定を行いました。
はじめにオリジンの画面でOACの作成を行います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365485/c10780fe-cead-4035-9225-124bce3cdb98.png)

作成ができたら自動的に生成されるS3バケットにアタッチしましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365485/84302f0a-c285-465c-a1ee-e4dac0af12ae.png)

これをS3のバケットポリシーに貼り付けてうまくいかない場合は、オリジンが静的ウェブサイトホスティングの方になっている可能性があります(1敗)

## EventBridge
最後はEventBridgeの設定です。
cronで実行するよう設定しました。
個人的にはcronの設定に詰まったので共有しておきます。
`cron(0,30 1-9 ? * MON-FRI *)`

0,30という部分は30分に1回実行するために、"分"が0分のときまたは30分のときに実行する設定になっています。

1-9は**UTC**で1時から9時まで実行するように設定されています。

MON-FRIは平日に実行するように指定されており、この設定を行うためには3つ目の"日"の指定にあたる項目に?を入れておく必要があります。


## 結果
最後に出来上がった画面です。
![040EDD3A-6CD9-4572-A4BF-FCC6E203E15B_1_201_a.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2365485/39519a82-55de-42bf-847e-0c22adebe7a8.jpeg)
一部隠していますが、このように出力することができました！
ちなみにこのままだとこのページのリンクを開けば誰でも見られてしまう状態なので、自分の場合はWAFでアクセスできるIPを絞る方法で対処しました。

## おわりに
このプログラムによって不要なサービスが可視化され、より理解しやすくなりました！
リソースを定期的に取得し、それをブラウザから見られるようにするという基本的なアプリケーションモデルの勉強にもなったと思います。