Amazon S3 を活用したビルドバイナリ共有環境を構築してみた


[AWS for Games Advent Calendar 2023](https://qiita.com/advent-calendar/2023/aws-for-games) の 4 日目の記事です。

# 概要
ゲーム開発をしていると、開発者やQA担当者にビルドバイナリを共有する場面が発生します。しかし、ゲームのビルドバイナリは数 GB ~ 数十 GB になることも多く、保存領域と転送帯域の確保が課題になります。また、ビルドバイナリは機密性が高いため、セキュリティについての考慮も必要です。

以上の課題を踏まえ、コスト効率に優れた AWS のストレージサービスである Amazon S3 を活用し、ゲーム開発向けのセキュアなビルドバイナリ共有環境を構築してみようと思います。

# 構築するアーキテクチャ
本投稿で構築するアーキテクチャは 2023 年度 GIC の公演 `Game production in the Cloud` の `Build Sharing Soulution` を参考にします ([リンク](https://www.youtube.com/watch?v=dOJdbwbhJeA&t=1752s))。ただし、内容の複雑さを避けるため、グローバル対応を見据えた Amazon CloudFront の導入はスコープ外とします。

今回構築するアーキテクチャ図を次に示します。

![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/93b490de-1ef5-96d1-afed-de8fd68129e8.png)

ビルドバイナリのアップロードは、ビルドサーバーとして稼働している Amazon EC2 上から直接行います。ビルドサーバーには S3 への Permission を有するロールを付与します。

ビルドバイナリのダウンロードは、S3 の**署名付き URL** を活用します。署名付き URL は期限付きのアクセス許可を付与された S3 オブジェクトへの URL です。その署名付き URL が正しいセキュリティ認証情報を有する主体が発行したものであれば、誰でもファイルにアクセスすることができます。

ここで *「アップロードの時と同じように、ダウンロードするマシンに認証情報を置けば直接アクセスできるのでは？」* と思った人、正解です。しかし、ダウンロードを行うのが開発者のローカルマシンである場合、その全てに認証情報を配布するのは非効率ですし、セキュリティの面でも問題があります。この場合は、認証情報を持つマシンは一つだけにして、そのマシンが署名付き URL を配布する方が良いでしょう。

本投稿では、署名付き URL の配布をサーバーレスで行います。具体的には、AWS Lambda で署名付き URL を発行する関数を作成し、Amazon API Gateway で API を公開します。また、ユーザー認証は Amazon Cognito を利用します。API Gateway へのリクエストは Cognito の認証情報を付与して行います。

# 手順
以降より、アーキテクチャの構築手順をまとめます。

**注意:** 本投稿の手順を手元のアカウントで試す場合は AWS の利用料金が発生します。リソースの削除忘れには十分ご注意ください。

## 1. S3 バケットの作成
まずは、S3 バケットを作成します。

マネジメントコンソールで S3 の画面を開き「バケットの作成」をクリックします。

バケットの設定は以下のようにします。

- **AWS リージョン**: ap-northeast-1
- **バケット名**: 任意のバケット名を入力します。全世界でユニークでなければならないので、日付やランダムな文字列を組み合わせましょう。

それ以外は全てデフォルトのままで「バケットを作成」をクリックします。

## 2. Lambda 関数の作成
次に、署名付き URL を発行する Lambda 関数を作成します。

マネジメントコンソールで Lambda の画面を開き「関数の作成」をクリックします。

関数の設定は以下のようにします。

- **関数名**: get_presigned_url
- **ランタイム**: python 3.11

それ以外は全てデフォルトで「関数の作成」をクリックします。

### コードの入力・デプロイ
Lambda 関数の内容は以下の通りとします。

```python
import json
import boto3
from botocore.exceptions import ClientError

def lambda_handler(event, context):
    # イベントからファイル名とバケット名を取得
    object_name = event['queryStringParameters']['object_name']
    bucket_name = event['queryStringParameters']['bucket_name']

    # Presigned URLの有効期限（秒）
    expiration_time = 3600  # 1時間

    # S3クライアントの作成
    s3_client = boto3.client('s3')

    # Presigned URLの生成
    try:
        presigned_url = s3_client.generate_presigned_url(
            'get_object',
            Params={
                'Bucket': bucket_name,
                'Key': object_name
            },
            ExpiresIn=expiration_time
        )

        # 生成したPresigned URLを返す
        return {
            'statusCode': 200,
            'body': json.dumps({
                'presigned_url': presigned_url
            })
        }

    except ClientError as e:
        # エラーが発生した場合はエラーメッセージを返す
        return {
            'statusCode': 500,
            'body': json.dumps({
                'error_message': str(e)
            })
        }
```

Deploy ボタンも忘れず押してください。

![スクリーンショット 2023-12-02 15.25.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/7de1c8e7-1125-5e6e-c6e2-3741a5c92d4b.png)

### IAM ロールの設定
S3 にアクセスできる認証情報で署名するために、Lambda 関数に付与されている IAM ロールの設定を変更します。

「設定 > アクセス権限」と遷移し、ロール名をクリックします。IAM の画面が別タブで開かれるかと思います。

![スクリーンショット 2023-12-01 17.03.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/3d35362e-ab9f-abd3-e131-dcaf59a0bb7a.png)

「許可ポリシー」の欄で「許可を追加 > ポリシーをアタッチ」をクリックします。

![スクリーンショット 2023-12-01 16.46.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/6ab5b6e2-9877-a59b-7ba6-59dcec697306.png)

検索欄に `AmazonS3FullAccess` と入力し、チェックボックスをオンにして「許可を追加」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/523fb93c-f65e-6acc-faaf-5746252ade24.png)

これで IAM ロールの設定が完了しました。

## 3. Cognito の設定
次に Lambda 関数をトリガーする API Gateway エンドポイントを作成するのですが、その前に Cognito の準備をしておきます。

### ユーザープールの作成
マネジメントコンソールで Cognito の画面を開き「ユーザープールを作成」をクリックします。

ユーザープールの設定は以下のようにします。設定項目が多いのでご注意ください。
なお、説明で触れていない項目に関しては全てデフォルトとします。

#### ステップ 1 サインインエクスペリエンスを設定
 「ユーザー名」と「E メール」のチェックボックスをオン

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/8b34782b-713c-2824-7304-a08c469e72c3.png)

#### ステップ 2 セキュリティ要件を設定
 「多要素認証」の項目で「MFA なし」を選択

![スクリーンショット 2023-12-01 17.25.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/e1f67db0-211d-33a4-596f-654a50ae14e5.png)

#### ステップ 3 サインアップエクスペリエンスを設定
全てデフォルトのままとします。

#### ステップ 4 メッセージ配信を設定
「E メールプロバイダー」で「Cognito で E メールを送信」を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/3e8ba7b1-7763-35b6-8a79-454a4bfd1ec9.png)

#### ステップ 5 アプリケーションを統合
特に設定項目が多いのでご注意ください！

- **ユーザープール名**: developer
- **ホストされた認証ページ**: 「Cognito のホストされた UI を使用」をオンにします
- **Cognito ドメイン**: 任意の文字列とします。グローバルで一意にする必要があります
- **アプリケーションクライアント名**: build-binary-share
- **許可されているコールバック URL**: `http://localhost` （httpsではないので注意）
- **高度なアプリケーションクライアントの設定** 欄を開き「OAuth 2.0 許可タイプ」を「暗黙的な付与」のみとします

<br>
<details><summary>画像は長いので折りたたみ</summary>

![スクリーンショット 2023-12-01 17.32.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/bb0dfff4-412e-5e3e-d196-f4b554ed08c7.png) ![スクリーンショット 2023-12-01 17.34.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/58bab5c0-e0e9-2ceb-3543-675e1f2bc530.png) ![スクリーンショット 2023-12-01 17.37.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/53870e37-786f-17f5-80ed-f0168d46d688.png)

</details>
<br>

許可されているコールバックに `http://localhost` を指定していますが、これにより、Cognito の 認証ページからローカルに立ち上げたダウンロードアプリへ認証情報を引き継いでリダイレクトされるようになります。

あとは「次へ」をクリックしてユーザープールの作成を完了させます。

### Cognito ユーザの作成
ついでに、Cognito ユーザを作成しておきましょう。

作成した `developer` ユーザプールの画面を開き「ユーザー」の欄で「ユーザーを作成」をクリックします。

![スクリーンショット 2023-12-02 15.45.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/4ce11e78-f8ce-5ac8-ff10-022396a44d9c.png)

ユーザの設定は以下のようにします。

- **招待メッセージ**: 「E メールで招待を送信」を選択
- **ユーザ名**: 任意のものを入力
- **E メールアドレス**: 任意のものを入力
- **仮パスワード**: 「パスワードの生成」を選択

![スクリーンショット 2023-12-01 17.42.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/ff568d60-26b0-e620-5bb3-511cdffa521d.png)

入力したメールに `Your temporary password` というタイトルでメールが来ます。本文に仮パスワードが記載されているので控えておきましょう（本文末尾のピリオドはパスワードではないのでご注意ください）

### 認証ページの確認
設定は以上ですが、ユーザーが作成できているか確認しましょう。

`developer` ユーザープールの画面から「アプリケーションの統合 > アプリクライアントと分析」と遷移し、`binary-share-client` をクリックします。

![スクリーンショット 2023-12-02 15.50.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/d71dd6cc-c9db-758b-0dcc-b0320cc84e78.png)
〜〜〜
![スクリーンショット 2023-12-02 15.52.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/f782cbc4-773d-ac1a-dfbc-03c3689f16ad.png)

アプリケーションクライアントの画面に遷移後「ホストされた UI を表示」をクリックします。

![スクリーンショット 2023-12-01 17.48.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/edb59281-1305-a5d3-fe52-3342f0f08917.png)

Cognito の認証画面に遷移するので、ユーザ名と仮パスワードを入力しましょう。初回ログイン時はパスワードの変更を求められるので任意のパスワードを入力します。

正しく認証できれば、ブラウザのエラー画面に遷移するかと思います。まだ localhost にダウンロード用のアプリケーションサーバを立てていないので、これは正常な動作になります。

Cognito に関しては以上になります。長い...

## 4. API Gateway
では、API Gateway のエンドポイントを作成し、Lambda 関数のトリガーとして設定します。

### エンドポイントの作成
マネジメントコンソールで **Lambda の画面**を開きます。

Lambda 関数 `get_presigned_url` の画面で「関数の概要」から「トリガーを追加」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/b5eca39c-a6f5-492d-f783-a507f3707861.png)

トリガーの設定画面では以下のようにします。

- **ソースを選択**: 「API Gateway」を選択
- **Create a new API** のラジオボタンをオン
- **Security**: 「Create JWT authorizer」を選択
- **Identity source**: $request.header.Authorization
- **Issuer**: `https://cognito-idp.ap-northeast-1.amazonaws.com/{ユーザープールID}`
- **Audience**: `binary-share-client` のクライアントID

ユーザープールID と クライアントID は cognito の画面で確認可能です。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/27318908-9753-e64e-79ae-f5a08a03918f.png)
![スクリーンショット 2023-12-01 18.09.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/1b4ffd13-5481-e348-232c-e0e7f645b427.png)

設定が終わったら「追加」をクリックします。

### CORS の設定
次に、CORS の設定を行います。この設定をしておかないと、localhost に立てたダウンロード用のアプリケーションから API の呼び出しができません。

マネジメントコンソールで API Gateway の画面を開き、先ほど作成した `get_presigned_url-API` の画面を開きます。

まず、API に紐づくメソッドを GET だけに変更します。左のメニューから「Routes」を選択し、「ルートの詳細」にある「編集」ボタンをクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/a172f3ac-9dd7-0989-fd34-f120f9acc031.png)

メソッドの設定を `ANY` から `GET` に変更して「保存」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/7eb4f6cc-493f-3e81-e22f-73bd7706e713.png)

次に、左のメニューから「CORS」をクリックして、「設定」をクリックします。

（画像は設定後の画面です）

![スクリーンショット 2023-12-02 17.54.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/6f9a8e28-617e-7264-14af-d39b7b9357c3.png)

CORS の設定は以下の通りとします。

- **Access-Control-Allow-Origin**: *
- **Access-Control-Allow-Headers**: authorization
- **Access-Control-Allow-Methods**: *

それ以外はデフォルトのままとして「保存」をクリックします。

## 5. ビルドマシンの立ち上げ
ビルドマシンを想定して EC2 インスタンスを立ち上げます。本投稿においてゲームのビルド作成は本筋ではないため、あくまでも仮想のビルドマシンです。

### インスタンスの設定
マネジメントコンソールで EC2 の画面を開き「インスタンスを起動」をクリックします。

インスタンスタイプが `t2.micro（無料利用枠の対象）` であることを確認します。

基本的な設定は何も変えずに「高度な詳細」を開きます。「IAM インスタンスプロフィール」の横にある「新しい IAM プロファイルの作成」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/5001fba4-aa2c-1d32-b899-623b88a3c2bf.png)

### ビルドマシン用 IAM ロールの作成
IAM ロールの設定画面に遷移するので「ロールを作成」をクリックします。

IAM ロールの設定は以下の通りとします。

- **サービスまたはユースケース**: EC2
- **許可ポリシー**: 検索欄に `AmazonS3FullAccess` と入力しチェックボックスをオンにします
- **ロール名**: BuildServerRole

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/4a2475ed-d103-5d72-8f0f-45a78052432c.png)

最後に「ロールを作成」をクリックします。

### ロールのアタッチ
EC2 インスタンスの設定画面に戻り「IAM インスタンスプロフィール」の項目で先ほど作ったロール `BuildServerRole` を選択します。

インスタンスの設定は以上です。「インスタンスを起動」ボタンをクリックしてください。

※ キーペアの作成を求められたら、適当な名前をつけてキーペアを作成しておきます。本投稿ではキーペアは使用しません。

## 6. ダウンロードアプリ（笑）の立ち上げ
開発者がビルドバイナリをダウンロードする際に利用する web アプリケーションをローカルに立ち上げます。こちらも、本投稿の本筋ではないため、非常に簡素なもので済ませてしまいます。

ということで、以下の javascript コードをローカルに保存し、node で実行してください。例えば `server.js` として保存したなら `node server.js` とします。**80番ポートで web サーバが起動します。**

<font color="Red">**{APIエンドポイントのID} の部分を、作成した API Gateway エンドポイントの ID に置き換える必要があることに注意してください。**
</font>

```javascript
const http = require("http");
const port = 80;

const _html = `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>

<label for="bucketName">Bucket Name:</label>
<input type="text" id="bucketName" name="bucketName" placeholder="Enter bucket name">
<label for="objectName">Object Name:</label>
<input type="text" id="objectName" name="objectName" placeholder="Enter object name">
<button onclick="sendApiRequest()">Get</button>
<br>
<label for="presignedUrl">Presigned URL:</label>
<textarea id="presignedUrl" rows="1" cols="50" readonly></textarea>

<script>
    function sendApiRequest() {
        const apiEndpoint = 'https://{APIエンドポイントのID}.execute-api.ap-northeast-1.amazonaws.com/default/get_presigned_url';
        const idToken = window.location.hash.substring(1).split('&').find(param => param.startsWith('id_token=')).split('=')[1];

        // バケット名とオブジェクト名を取得
        const bucketName = document.getElementById('bucketName').value;
        const objectName = document.getElementById('objectName').value;

        // Authorization ヘッダーに id_token をセット
        const headers = new Headers();
        headers.append('Authorization', 'Bearer ' + idToken);

        // APIリクエストパラメータにバケット名とオブジェクト名を追加
        const queryParams = new URLSearchParams();
        queryParams.append('bucket_name', bucketName);
        queryParams.append('object_name', objectName);

        // API呼び出し
        const url = apiEndpoint + '?' + queryParams.toString();
        fetch(url, {
            method: 'GET',
            headers: headers,
            mode: 'cors'
        })
        .then(response => response.json())
        .then(data => {
            const presignedUrl = data.presigned_url;
            document.getElementById('presignedUrl').value = presignedUrl;
            console.log('API Response:', data);
        })
        .catch(error => console.error('API Error:', error));
    }
</script>

</body>
</html>
`

const server = http.createServer((request, response) => {
    response.writeHead(200, {
      "Content-Type": "text/html"
    });

    const responseMessage = _html;
    response.end(responseMessage);
});

server.listen(port);
console.log(`The server has started and is listening on port number: ${port}`);

```

エンドポイントのIDは API Gateway の画面で確認可能です。

（黒塗りにしている部分に表示されているものが ID）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/3660b0e7-84d7-ec97-76d8-3e4a893b205a.png)

構築の手順は以上になります！お疲れ様でした。

# 使ってみる
## アップロード
まずは、ビルドサーバに見立てた EC2 からファイルをアップロードしてみます。...といっても、EC2 に接続して、コマンドを実行するだけですが。実際の開発現場では CI/CD で統合されたビルドパイプラインが走るイメージです。

### EC2 への接続
まず、EC2 に接続します。ビルドサーバーインスタンスを選択し「接続」をクリックします。

![スクリーンショット 2023-12-01 18.55.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/4d9e5393-3854-ca33-f0ed-487c46830b1a.png)

EC2 Instance Connect が選択されていることを確認し、そのままの設定で「接続」をクリックします。

以下のような CLI が開かれれば接続成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/b0c2754b-e0e3-4203-793a-078bbd7ac81b.png)

### ビルドの作成・アップロード
それでは、ビルドを模したファイルを作成します。以下のコマンドを実行してください。

```bash
touch awesome_game_build
```

ビルドが作成できましたね。

次に、以下のコマンドで S3 へアップロードを行います。

```
aws s3 cp awesome_game_build s3://{S3のバケット名}/awesome_game_build
```

アップロードできているかどうか確認しましょう。マネジメントコンソール上で S3 のバケットを開き、ファイルが存在するかを確認します。

![スクリーンショット 2023-12-01 23.16.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/b30d648a-7bdb-9b64-c8f9-c560a9723053.png)

### ビルドのダウンロード
改めて `3. Cognito の設定` で確認した認証ページを開き認証を行います。localhost にダウンロードアプリの web サーバが立ち上がっているなら、ダウンロードページに遷移するはずです。

「Bucket Name」にバケット名を、「Object Name」にオブジェクト名を入力して「Get」ボタンをクリックすると署名付き URL を取得することができます。

![スクリーンショット 2023-12-01 23.18.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625271/12a940ec-98f7-1c9d-9bbe-48ffe0744b5d.png)

署名付き URL が有効であることを確認しましょう。ブラウザで署名付き URL を開いた際にファイルがダウンロードできれば成功です！

## 後片付け
構築したリソースの後片付けを行います。削除するリソースは以下の通りです。

- **S3 バケットの削除**: S3 の画面からバケットを削除します。バケットは空にしないと削除できないので注意してください
- **Lambda 関数の削除**: Lambda の画面から関数を削除します
- **API Gateway エンドポイントの削除**: API Gatewa の画面からエンドポイントを削除します
- **Cognito ユーザープールの削除**: Cognito の画面から ユーザープールを削除します
- **EC2 インスタンスの終了**: EC2 の画面から立ち上げた EC2 インスタンスを終了します。

## 今後の展望
本投稿で構築したビルドバイナリ共有環境ですが、実際の開発で使うにはまだまだ改良が必要だと思います。最後に、今後考えられる拡張方法について考察したいと思います。

### ビルドパイプラインとの統合
今回は説明を簡単にするため、ビルドアップロードの部分はかなり簡略化しました。実際には、GitHub Actions や Jenkins  などで構築されたビルドパイプラインの最後に実行される工程になるでしょう。

ビルドパイプラインの最後に自動で S3 にアップロードする際は、本投稿で取り上げた AWS CLI を使う方法や、AWS SDK を組み込んで使う方法などがあります。

また、ビルドサーバーがオンプレミスに複数台存在するような場合は、認証情報をビルドサーバーに配布する方法以外にも、**アップロードも署名付き URL を経由する方法**が考えられます。

### Cognito によるバケット単位での認証
実際のゲーム開発では、ユーザーによってダウンロードできるバケットを制限したい場合があります。例えば、開発者は開発用ビルドと QA ビルド両方をダウンロードできるが、外部の QA 担当者は QA ビルドのみダウンロードできる、などが考えられます。

バケット単位での認証を行う場合は、Cognito のユーザーグループを使う方法が考えられます。ユーザーが属するグループによって付与する IAM ロール を切り替え、署名付き URL を生成することでバケットごとの認証を実現することができます。

### CloudFront によるグルーバル展開
今回は、グローバル対応を見据えた Amazon CloudFront の導入は行いませんでした。CloudFront を活用することで S3 バケットがあるリージョンから離れた場所のダウンロード速度を改善できます。

例えば国内開発、グローバル展開の場合、ビルドバイナリは東京リージョンの S3 に配置し、各ローカライズバージョンの動作確認は現地のチームに任せるという方法が考えられます。この際、東京リージョンから地理的に離れた位置におけるダウンロード速度を、CloudFrontの導入によって改善することができます。

## まとめ
今回は、ゲーム開発におけるビルドバイナリの共有環境を S3 上に構築し、セキュアにアクセスする方法について解説しました。S3 はコスト効率とセキュリティに優れたストレージサービスであり、ビルドバイナリのような単一で大きいファイルを管理する上では非常に優れたソリューションとなり得ます。

今回解説した内容が、皆様のゲーム開発の助けになれば幸いです。

(免責) 本記事の内容はあくまでも個人の意見であり、所属する企業や団体は関係ございません。