AWS S3 のディレクトリサイズをリスト化する

最近、ストレージの使用量が増えてきたんだけど、何にそんなに使っているの？
という問題について、AWS S3 でディレクトリとそのサイズをリスト化したいと思います。

参考：[Boto3 Docs](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html)

## 1 環境

今回は python スクリプトで作成します。
事前に AWS CLI で AWS アカウントを設定しておいてください。

 - Python 2.7 もしくは Python 3.6
 - AWS CLI
 - AWS SDK for Python (Boto3) 

計測したい S3 全バケットに対して以下の権限が必要です。

 - S3:ListBucket 
 - S3:ListAllMyBuckets  

次項から取得方法について解説します。
ともかく使ってみたい方は [3 スクリプトと実行方法](#3-%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%A8%E5%AE%9F%E8%A1%8C%E6%96%B9%E6%B3%95) に進んでください。

## 2 実現方法
### 2.1 S3 のバケットをリスト化する

バケットリストを取得します。

```Python
import boto3

client = boto3.client("s3")
responce = client.list_buckets()
```

レスポンスを確認します。

```Python
>>> import pprint
>>> pprint.pprint(responce)
{'Buckets': [{'CreationDate': datetime.datetime(2018, 8, 29, 3, 43, 30, tzinfo=tzutc()),
              'Name': 'bucket1'},
             {'CreationDate': datetime.datetime(2018, 8, 29, 3, 37, 50, tzinfo=tzutc()),
              'Name': 'bucket2'},
...
            ],
 'Owner': {...},
 'ResponseMetadata': {...}}
```

バケット名だけを抜き出してリスト形式にしておきます。

```Python
buckets = [dic['Name'] for dic in responce['Buckets']]
```

```Python
>>> pprint.pprint(buckets)
['bucket1',
 'bucket2',
...
]
```

### 2.2 バケットごとに全オブジェクト（ファイル）のサイズを集計する

`boto3.s3.list_objects_v2` 関数を使用して、バケット中のオブジェクト情報をすべて取得します。
今回はすべてのオブジェクトの情報が欲しいので、フィルタリングは行いません。
`Bucket` オプションにはバケット名を指定します。

```Python
bucket = buckets[0]
responce1 = client.list_objects_v2(Bucket = bucket)
```

結果を見てみます。
`Contents` の中にオブジェクトの情報がリストとして入っています。
`Key` がオブジェクトのパス、`Size` がオブジェクトのサイズ (byte) です。

```Python
>>> pprint.pprint(responce1)
{'Contents': [{'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'file1.txt',
               'LastModified': datetime.datetime(2018, 8, 31, 2, 33, 1, tzinfo=tzutc()),
               'Size': 4539,
               'StorageClass': 'STANDARD'},
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'fftw/20180831/file1.PNG',
               'LastModified': datetime.datetime(2018, 9, 14, 6, 4, 51, tzinfo=tzutc()),
               'Size': 72549432,
               'StorageClass': 'STANDARD'},
...
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'fftw/20180921/tools-1.9-t2/picture31.PNG',
               'LastModified': datetime.datetime(2018, 10, 1, 5, 52, 46, tzinfo=tzutc()),
               'Size': 5796024793,
               'StorageClass': 'STANDARD'}],
 'EncodingType': 'url',
 'IsTruncated': True,
 'KeyCount': 1000,    # 取得したオブジェクトの数
 'MaxKeys': 1000,     # 取得できるオブジェクト最大数
 'Name': 'bucket1',
 'NextContinuationToken': '(長い文字列)',
 'Prefix': '',
 'ResponseMetadata': {...}}
```

`list_objects_v2` で取得できるオブジェクトの数には上限があり、デフォルトは1000です。
1000 以上のオブジェクトが一つのバケットに存在する場合、1001 個めのオブジェクトから再度取得する必要があります。
以下のように、`ContinuationToken` パラメータにレスポンス中の `NextContinuationToken` を設定すると、その次のオブジェクトから取得できます。

```Python
next_token = responce1['NextContinuationToken']
responce2 = client.list_objects_v2(Bucket = bucket, ContinuationToken=next_token)
```

結果を見てみます。 `NextContinuationToken` に値が設定されていますので、まだ続きがあるようです。

```Python
>>> pprint.pprint(responce2)
{'Contents': [{'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'fftw/20180921/tools-1.9-t1/picture32.png',
               'LastModified': datetime.datetime(2018, 10, 1, 5, 52, 46, tzinfo=tzutc()),
               'Size': 3488992,
               'StorageClass': 'STANDARD'},
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'fftw/20180921/tools-1.9-t1/picture33.png',
               'LastModified': datetime.datetime(2018, 10, 1, 5, 41, 54, tzinfo=tzutc()),
               'Size': 5796024793,
               'StorageClass': 'STANDARD'},
...
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'surface/plot/sample/html/graph.html',
               'LastModified': datetime.datetime(2018, 10, 5, 9, 39, 33, tzinfo=tzutc()),
               'Size': 2644,
               'StorageClass': 'STANDARD'}],
 'EncodingType': 'url',
 'IsTruncated': True,
 'KeyCount': 1000,
 'MaxKeys': 1000,
 'Name': 'bucket1',
 'NextContinuationToken': '(長い文字列)', # ** 次のトークン **
 'Prefix': '',
 'ResponseMetadata': {...},
 'StartAfter': 'fftw/20180921/tools-1.9-t2/picture31.PNG'}
```

さらに次のリストを取得します。

```Python
next_token = responce2['NextContinuationToken']
responce3 = client.list_objects_v2(Bucket = bucket, ContinuationToken=next_token)
```

結果を見てみます。 `NextContinuationToken` キーがありませんので、すべて取得できたようです。
取得できたオブジェクトの数である `KeyCount` を見てください。
最大 1000 取得できるところを 72 しか結果が返ってこないことからもリストの最後であったことが判断できます。

```Python
>>> pprint.pprint(responce3)
{'Contents': [{'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'surface/plot/sample/index.html',
               'LastModified': datetime.datetime(2018, 10, 5, 9, 39, 33, tzinfo=tzutc()),
               'Size': 3744,
               'StorageClass': 'STANDARD'},
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'surface/plot/sample/js/plot.js',
               'LastModified': datetime.datetime(2018, 10, 5, 9, 39, 33, tzinfo=tzutc()),
               'Size': 48308,
               'StorageClass': 'STANDARD'},
...
              {'ETag': '"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"',
               'Key': 'test.json',
               'LastModified': datetime.datetime(2018, 8, 29, 4, 0, 30, tzinfo=tzutc()),
               'Size': 1062,
               'StorageClass': 'STANDARD'}],
 'EncodingType': 'url',
 'IsTruncated': False,
 'KeyCount': 72,    # 取得したオブジェクトの数
 'MaxKeys': 1000,   # 取得できるオブジェクト最大数
 'Name': 'bucket1',
 'Prefix': '',
 'ResponseMetadata': {...},
 'StartAfter': 'surface/plot/sample/html/graph.html'}
```

**メモ**

`ContinuationToken` パラメータの代わりに `StartAfter` パラメータを使用しても全リストを取得できるようです。
以下のようにしてコンテンツリストの最後のキーを渡すと、次のキーから返ってきます。
筆者の環境で確認した限りでは取得できる結果に違いはないようです。

```Python
last_key = responce1['Contents'][-1]['Key']
responce2 = client.list_objects_v2(Bucket = bucket, StartAfter = last_key)
```

### 2.3 集計する

上述までの関数で S3 全バケット全オブジェクトのサイズが取得できましたので、次にバケットごと、ディレクトリごとにサイズを集計します。
今回はバケットの第1階層でまとめたいと思います。
もう少し深い階層までほしい場合は適宜変更してください。

まずは全バケットの合計と各バケットごとの情報を入れるためのひな型を用意します。
`total` に全バケットのサイズ合計を入れたいと思います。

```Python
objects = {
    "buckets": {},
    "total": 0,
    "unit": "byte"
}
```

コンテンツ（オブジェクト）ごとにサイズを第1階層のディレクトリに加算します

```Python
import os

# バケットごとに1つ作成し、第1階層のディレクトリ名をキーとしてサイズを保持する
contents = {}
# バケット直下のファイル集計用キー
HOME_KEY = "ROOT DIRECTORY"

# 全バケット通してのサイズ合計
total = 0

for c in responce1['Contents']:
    # >>> pprint.pprint(c)
    # {'ETag': '"xxxxxxxxxxxxxxxxxxx"',
    #  'Key': 'file1.txt',
    #  'LastModified': datetime.datetime(2018, 8, 31, 2, 33, 1, tzinfo=tzutc()),
    #  'Size': 4539,
    #  'StorageClass': 'STANDARD'}
    dirname = os.path.dirname(c['Key'])
    if dirname == "":
        # バケット直下のファイルの場合
        dirname = HOME_KEY
    else:
        # ディレクトリ配下のファイルの場合
        dirname = dirname.split("/")[0]
    # オブジェクトサイズを加算する
    if not dirname in contents:
        contents[dirname] = 0
    contents[dirname] += c['Size']
    # 全サイズにも加算しておく
    total += c['Size']

# >>> pprint.pprint(contents)
# {'ROOT DIRECTORY': 4539, 'fftw': 251701, 'images': 170995750, 'surface': 2463}
objects["buckets"][bucket] = {"contents": contents, "total": total}
objects["total"] += total
```

集計オブジェクト `objects` を確認してみます。レスポンスの結果が入っています。

```Python
>>> pprint.pprint(objects)
{'buckets': {'bucket1': {'contents': {'ROOT DIRECTORY': 4539,
                                      'fftw': 251701,
                                      'images': 170995750,
                                      'surface': 2463},
                         'total': 171254453}},
 'total': 150494370689,
 'unit': 'byte'}
```

※ `contents` 中、"ROOT DIRECTORY" キーはバケット直下のファイル集計用に設けました。
　しかしバケットに "ROOT DIRECTORY" という名前のディレクトリが存在していればもちろん使えませんので、上記スクリプトで `HOME_KEY` を適切な値に変更する必要があります。

これでバケットごと、ディレクトリごとにサイズを集計することができました。
次項に実行ファイルとしてまとめたものを挙げておきます。
おまけ機能として csv 形式でも出力していますが、お好みに応じて出力形式は変更してください。

## 3 スクリプトと実行方法

以下が完成した python スクリプトです。

<details><summary>aws_s3_du.py (クリックして表示してください)</summary><div>

```Python:aws_s3_du.py
# -*- coding: utf-8 -*-
"""
Created on Fri Sep 21 11:48:05 2018

@author: Okada
"""

HOME_KEY = "ROOT DIRECTORY"
ROUND = 1000000

def main(prefix):
    
    import boto3
    import os
    import json
    import datetime
    
    # 出力ファイル名に使うため、現在時刻を取得
    now = datetime.datetime.now()
    
    # 出力ファイルの原型
    objects = {
        "buckets": {},
        "total": 0,
        "unit": "byte"
    }
    
    client = boto3.client("s3")
    
    # S3のバケットリストを取得
    responce = client.list_buckets()
    
    # バケットがなければ終了
    if not 'Buckets' in responce:
        print ("No Buckets.")
        return
    
    # レスポンスをリスト形式にする
    buckets = [dic['Name'] for dic in responce['Buckets']]
    
    # バケットを一つずつ処理する
    for bucket in buckets:
        contents = {}
        token = None
        total = 0
        
        # 全コンテンツの情報を取得してサイズを親ディレクトリに加算する
        while(1):
            # 全コンテンツが多い場合、一度に取得できないため、トークンを指定して順次取得する
            if token == None:
                responce = client.list_objects_v2(Bucket = bucket)
            else:
                responce = client.list_objects_v2(Bucket = bucket, ContinuationToken = token)
            
            # 空バケットであれば抜ける
            if not 'Contents' in responce:
                break
                
            # コンテンツごとにサイズを親ディレクトリに加算する
            for c in responce['Contents']:
                
                dirname = os.path.dirname(c['Key'])
                
                if dirname == "":
                    # バケット直下のファイルはルートディレクトリに加算する
                    dirname = HOME_KEY
                else:
                    # ディレクトリ配下のファイルは第1ディレクトリに加算する
                    dirname = dirname.split("/")[0]
                    
                if not dirname in contents:
                    contents[dirname] = 0
                
                contents[dirname] += c['Size']
                
                # 全サイズにも加算しておく
                total += c['Size']
                
            # next-token がなければ全コンテンツを回り切ったとみなして抜ける
            if not 'NextContinuationToken' in responce:
                break
            
            # 最後のファイルキーを token として保存する
            token = responce['NextContinuationToken']
        
        objects["buckets"][bucket] = {"contents": contents, "total": total}
        objects["total"] += total
    
    # json 形式で出力
    json.dump(objects, open("%s-%s.json" % (prefix, now.strftime("%Y%m%d-%H%M%S")), "w"), 
              indent=4, separators=(',', ': '))

    # json を csv 形式にして出力
    ## お好みの形にフォーマットしてください
    f = open("%s-%s.csv" % (prefix, now.strftime("%Y%m%d-%H%M%S")), "w")
    f.write("bucket-name,sub-directory-name,size(Mbyte)\n")
    f.write(",,%0.2f\n" % (float(objects["total"])/ROUND))
    
    for key1 in sorted(objects["buckets"].keys()):
        f.write("%s,,%0.2f\n" % (key1, float(objects["buckets"][key1]["total"])/ROUND))

        for key2 in sorted(objects["buckets"][key1]["contents"].keys()):
            f.write("%s,%s,%0.2f\n" % (
                key1, key2, float(objects["buckets"][key1]["contents"][key2])/ROUND)
            )
    f.close()
    
if __name__ == "__main__":
    import sys
    main(sys.argv[1])
```
</div></details>

適当な場所に aws_s3_du.py という名前で保存した後、パラメータとして出力ディレクトリとプレフィックスを指定して実行します。

```Bash
$ mkdir ./output
$ python aws_s3_du.py ./output/summary
```

csv 形式と json 形式で実行日と時間をファイル名につけて出力します。

```Bash
$ tree output
output
├── summary-20181005-114143.csv
└── summary-20181005-114143.json

0 directories, 2 files
```

## 4 出力ファイルの解説

### 4.1 json

json ファイルでは、バケットごとに総使用量と第 1 階層のディレクトリ別使用量を表示しています。
バケット直下のファイルは `ROOT DIRECTORY` にまとめています。
単位は byte です。 AWS から取得できる形式のまま出力しています。

```Python
{
    "buckets": {
       "bucket1": {
            "contents": {
                "fftw": 251701,
                "images": 170995750,
                "surface": 2463,
                "ROOT DIRECTORY": 4539
            },
            "total": 171254453,
        },
        "bucket2": {
            "total": 89431332,
            "contents": {
                "animation": 89426805,
                "ROOT DIRECTORY": 4527,
            }
        },
...
    },
    "total": 814976547207,
    "unit": "byte"
}
```

### 4.2 csv

csv ファイルでは視認性を考慮して出力ファイルの単位を Mbyte にしています。
"sub-directory-name" が空の行がバケットの全サイズですので、この行に色を付けるとわかりやすくなると思います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/226829/ef1ca565-7b03-25fa-d87f-b1847f0c7838.png)

## 5 まとめ

今回は単なる python スクリプトとして作成しました。
AWS Lambda 等に登録しておき、定期的に実行して結果を S3 等に出力することも可能ですが、ファイル数が多い場合はそれなりに時間がかかりますので注意が必要です。
参考までに、筆者の環境では 50 万ファイルで 10 分程度かかります。

全オブジェクトのサイズを取得して手元で合計する力業で実現していますが、いずれ AWS 側にディレクトリを指定したらサイズ等のサマリが返ってくる I/F が実装されるといいなと思います。

以上です。