はじめに
今回はかなりややこしく、ニッチな内容です
ほぼ自分用のメモになっています
Web サービスからファイルをアップロードし、 S3 に保管したいことがあると思います
特に ECS などで立ち上げたコンテナの場合、コンテナ上のストレージはあくまでも一時的なもの、いつ消えても良いものなので、必ず S3 のような別の場所に移動して永続化することになります
このとき、当然 S3 には誰でもアクセスできてはいけないので、 S3 へのアクセスを制限しておいて、 Server のコンテナだけに S3 への PutObject 等の権限を付与します
アップロードしたいファイルが少量である場合、このような実装でなんの問題もありません
しかし、例えば数百、数千、数万の画像をアップロードするようなシステムの場合、これを毎回サーバー経由で実行すると、メモリやストレージを圧迫してしまう危険性があります
本記事では STS を使って、サーバー負荷なくファイルを S3 へアップロードする方法を記載します
また、これを AWS Copilot でデプロイする場合の権限設定方法について実装例を示します
Phoenix でサーバー経由の S3 アップロード
例えば Elixir の Phoenix LiveView の場合、以下のように実装します
フロントエンドでは Phoenix.Component.live_file_input
を使用してファイル入力を作ります
そして、 form の送信時(phx-submit
) に save
イベントを発生させます
詳細は公式ドキュメントを参照
フロントエンド側
<.simple_form
for={@form}
id="sample-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.live_file_input upload={@uploads.avatar} />
<.button>アップロード</.button>
</.simple_form>
バックエンド側では画面マウント時に allow_upload
でファイル入力できるようにしておき、必要に応じて拡張子やファイル数などを指定します
そして save
イベントを受け取ったとき consume_uploaded_entries
内で ExAws.S3
を使ってアップロードします
バックエンド側
defmodule AppWeb.SampleLive.Index do
use AppWeb, :live_view
...
@impl true
def mount(_params, _conn, socket) do
socket
|> allow_upload(:avatar, accept: ~w(.jpg .jpeg .png), max_entries: 1)
|> then(&{:ok, &1})
end
...
def handle_event("save", _params, socket) do
consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
path
|> ExAws.S3.Upload.stream_file()
|> ExAws.S3.upload(<S3のバケット名>, <アップロード先のS3キー>)
|> ExAws.request!()
end)
end
...
end
一点注意すべきなのは consume_uploaded_entries
による処理の終了後、サーバー上からはファイルが消える、ということです
.live_file_input
を使った時の処理の流れ
- ユーザーが
.live_file_input
でファイルを選択する - この時点でサーバー上の一時ディレクトリーにファイル自体はアップロードされている
-
consume_uploaded_entries
を実行すると、サーバー上の一時ディレクトリー内にあるファイルを参照し、それぞれに対してコピーやアップロードなどの処理を施して永続化する - 各ファイルに対する処理の実行後、一時ディレクトリー内のファイルは自動的に削除される
Phoenix で STS を使用した S3 アップロード
STS は一時的な AWS 認証情報を作るための仕組みです
これを使うと、フロントエンドに対して一時的に S3 へのアップロード権限を委任することができます
当然、ユーザー認証を実装していたり、委任する範囲(アクションと S3 バケット、 S3 キーなど)を限定することが条件です
ざっくり、以下のような流れで実装します
説明のため、実際に動かしたコードを最小限に削っています
このままで動く保証をするものではありません
まず、 STS で与える権限の大元となるマスター用 IAM ロールを準備します
この IAM ロールには大きめの権限を与えておきます
この辺りの定義は後述します
フロントエンド側では通常のファイル入力を作ります
ボタンの方でファイル入力の ID をパラメータとして送信します(ファイル入力が複数ある場合のため)
フロントエンド側
<input type="file" id="file-input" />
<.button phx-click="save" phx-value-input_id="file-input">アップロード</.button>
バックエンド側は save
イベントを受け取けて一時認証情報を発行します
このとき、 STS にマスター用 IAM ロールを指定し、更に詳細な権限と有効期限を指定します
マスター用 IAM ロールでは S3 バケット上の全体のプレフィックスを指定しておき、こちらではユーザー毎やグループ毎などで細かく指定するイメージです
取得した認証情報をフロントエンド側に got_tmp_portraits_credentials
イベントで通知します
バックエンド側
defmodule AppWeb.SampleLive.Index do
use AppWeb, :live_view
...
@impl true
def handle_event("save", %{"input_id" => input_id}, socket) do
credentials = assume_role()
socket
|> push_event("got_tmp_portraits_credentials", %{
input_id: input_id,
tmp_credentials: credentials
})
|> then(&{:noreply, &1})
end
defp assume_role() do
doc = assume_role_request().body
%{
"accessKeyId" => doc.access_key_id,
"secretAccessKey" => doc.secret_access_key,
"sessionToken" => doc.session_token,
"expiration" => doc.expiration
}
end
defp assume_role_request() do
ExAws.STS.assume_role(<ロールのARN>, <一意のセッション ID>, get_policy())
|> ExAws.request!()
end
defp get_policy(group_id, photo_studio_id) do
[
duration: <有効期間 >,
policy: %{
"Version" => "2012-10-17",
"Statement" => [
%{
"Action" => [
"s3:PutObject"
],
"Effect" => "Allow",
"Resource" => [<操作を許可する S3 キー>]
}
]
}
]
end
end
フロントエンド側では got_tmp_portraits_credentials
イベントをフックで受け、取得した認証情報を使って S3 にアップロードします
フロントエンド側(フック用の JS)
const UploaderHook = {
mounted () {
this.handleEvent('got_tmp_portraits_credentials', async (params) => {
await uploadFiles(params)
})
const uploadFiles = async (params) => {
const s3Config = {
credentials: {
accessKeyId,
secretAccessKey,
sessionToken
},
params: {
Bucket: <S3バケット名>
},
region: <AWSリージョン名>
}
const files = Array.from(document.getElementById(params.input_id).files)
await Promise.all(files.map(async (file) => {
await uploadFile(file, s3Config)
})
}
const uploadFile = (files, s3Config) => {
const putObjectParams = {
Bucket: <S3バケット名>,
Key: <S3キー>,
Body: file
}
const s3 = new S3(s3Config)
return s3.putObject(putObjectParams).promise()
}
}
}
export default UploaderHook
権限設定
実のところ、ここまでは前置きです
ここからが AWS Copilot を使ったときに私がハマったポイントで、この記事を書く理由です
ただ、同じことにハマる人がいるとは思えないため、ほぼ自分のためのメモになります
STS を使ってコンテナから一時認証情報を作成する場合、以下のようなリソース、設定が必要になります
AWS Copilot は自動的に IAM ロールを生成し、コンテナ起動時にはそのロールから委任した IAM ユーザーを使用しています
例えばコンテナから S3 へアクセスする場合、この IAM ユーザーに S3 へのアクセス権限を付与することになります
コンテナへのアクセス権限(IAMポリシー)追加は、アドオンの設定ファイルに以下のように定義します
<サービス名>/addons/container-policy.yml
Parameters:
App:
Type: String
Description: Your application's name.
Env:
Type: String
Description: The environment name for the service.
Name:
Type: String
Description: The name of the service.
Resources:
ContainerPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: 2012-10-17
Statement:
- Sid: S3Access
Effect: Allow
Action:
- s3:PutObject
Resource:
- !Sub "arn:${AWS::Partition}:s3:::<S3バケット名>/*"
Outputs:
ContainerPolicyArn:
Description: "The ARN of the ManagedPolicy to attatch to the task role."
Value: !Ref ContainerPolicy
しかし、 STS のロールを使って一時認証情報を作成することを許可する場合、コンテナ側(Copilot の設定ファイル)ではなく、 STS 用のロールの設定で「誰ならこのロールを使って一時認証情報を発行できるか」を設定することになります
Terraform を使う場合、STSのマスター用IAMロールは以下のように定義します
resource "aws_iam_role" "sts-master-role" {
name = "<ロール名>"
description = "STS master"
assume_role_policy = data.aws_iam_policy_document.sts-master-iam-assume-role-policy.json
tags = local.common-tags
}
resource "aws_iam_policy" "sts-master" {
name = "<ポリシー名>"
policy = data.aws_iam_policy_document.sts-master-role-policy.json
}
resource "aws_iam_role_policy_attachment" "sts-master" {
role = aws_iam_role.sts-master-role.name
policy_arn = aws_iam_policy.sts-master.arn
}
data "aws_iam_policy_document" "sts-master-iam-assume-role-policy" {
statement {
sid = "IAMAssumeRole202310051100"
effect = "Allow"
principals {
type = "AWS"
identifiers = ["*"]
}
condition {
test = "StringLike"
variable = "aws:userid"
values = ["<AWS Copilot で生成される IAM ロールのロールID>:*"]
}
actions = [
"sts:AssumeRole",
]
}
}
data "aws_iam_policy_document" "sts-master-role-policy" {
statement {
sid = "S3Basic202309261600"
effect = "Allow"
actions = [
"s3:PutObject",
]
resources = [
"<許可するS3キー>",
]
}
}
ここで assume_role_policy
として、以下の指定をしているところが肝です
condition {
test = "StringLike"
variable = "aws:userid"
values = ["<AWS Copilot で生成される IAM ロールのロールID>:*"]
}
コンテナに割り当てられる IAM ユーザーはコンテナが起動されるたびに変化するため、直接的にユーザーを指定できません
そのため、許可するときには StringLike
で aws:userid
を <AWS Copilot で生成される IAM ロールのロールID>:*
として、ワイルドカードを使って指定します
ユーザー ID は IAM ユーザー名や IAM ロール名とは別の値であることに注意してください
ユーザー ID は IAM ユーザーや IAM ロールが生成されるときに自動的に設定される値であ理、こちらからは設定できない値であるため、作成後に参照する必要があります
Copilot で作成したロールは以下のコマンドで確認できます
$ copilot svc show --resources
...
AWS::IAM::Role <ロール名>
取得したロール名を使って、ロールIDを取得します
$ aws iam get-role --role-name <ロール名>
{
"Role": {
"Path": "/",
"RoleName": "<ロール名>",
"RoleId": "<ロールID>",
...
このようにして取得したロールIDを Terraform に変数経由で渡すことで、コンテナから一時認証情報が設定されるようになります
まとめ
フロントエンドと S3 で直接通信することで、ファイルアップロード処理のサーバー負荷を最小化することができます
また、 assume_role_policy
を適切に設定することで、特定のコンテナにのみ一時認証情報発行の権限を設定することができます