LoginSignup
15
4

AWS Copilot + Phoenix + S3 + STS でサーバー負荷なく画像をアップロードする

Last updated at Posted at 2023-11-20

はじめに

今回はかなりややこしく、ニッチな内容です
ほぼ自分用のメモになっています

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 ユーザーはコンテナが起動されるたびに変化するため、直接的にユーザーを指定できません
そのため、許可するときには StringLikeaws: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 を適切に設定することで、特定のコンテナにのみ一時認証情報発行の権限を設定することができます

15
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
4