S3 を活用して工数を削減させた、ファイルアップロード機能の設計と実装

こんにちは、開発本部・バックエンドエンジニアの江間です。

先日、 PR TIMES の新規機能としてプレスキット機能の提供が開始されました。

プレスキット機能では、画像コンテンツや PDF などのファイルのアップロード・ダウンロード、サムネイル画像の自動生成など機能を提供しています。

PR TIMES のソースコードはレガシーな PHP で書かれており、少しの機能追加であっても工数が増大したり、これまで通りに実装していくと技術的負債が増えてしまう恐れがありました。また、アップロードされるファイルの容量が大きいという特徴があるプレスキットでは、従来どおりオンプレミス上のストレージサーバーへアップロードすると残容量が更に枯渇させてしまう恐れがありました。

そのような様々ある問題点を回避しながら、今回、新規機能をリリースまで持っていきました。

この記事では、従来のストレージサーバーへアップロードではなく Amazon S3 (以降、 S3 )を活用し、更にレガシーコードへの変更を減らすことで工数を削減させた、ファイルのアップロード機能の設計と実装についてお話します。

目次

背景

プレスキットのファイルアップロード機能を開発するにあたり、以下の問題点がありました。

  • PR TIMES で使用されている PHP はバージョンが古いため、 AWS の SDK 等のライブラリ利用が難しい
  • コードそのものがレガシーな書かれ方をされていて、機能追加や修正が困難
  • アップロードが想定されるファイルの容量は大きいので、従来のオンプレミス上のストレージサーバーにアップロードすると容量が枯渇する恐れがある

逆に今活用できる状況としては

  • フロントエンドはモダンなコードにリプレイスが行われたため、様々な選択が行える(解説記事
  • S3 にアップロードすることで Fastly Image Optimiser を使えるようになった(解説記事

などがあります。

バックエンド側の制約のもと、これらのメリットを最大限に活かして、より少ない工数でリリースを目指す事にしました。

プレスキット機能とは

プレスキットとは、企業がメディアへ提供する素材(画像コンテンツや PDF など)のことです。

PR TIMES では、利用企業がプレスキットを登録し、メディア関係者を含む全ての利用者に対してプレスキットを表示、ダウンロード可能な機能を提供しました。

この機能を提供するにあたり、今回解説するファイルのアップロード機能で満たすべき技術的な要件としては

  • 企業ユーザー認証
  • コンテンツは S3 へアップロードする
  • ファイルのアップロード上限を10MB(運用後に増える可能性があり)
  • 適切なバリデーション
  • AWS SDK は使わない

などがあります。

構成

これらの要件を満たすために、プレスキットのアップロードは以下の構成になっています。

流れとしては

  1. PR TIMES の企業ユーザーで認証を行い、PHP のアプリケーションから非公開バケットの PUT 用署名付き URL を取得
  2. フロントエンドから非公開バケットへ PUT
  3. S3 への PUT をトリガーに Lambda を実行。バリデーション等を行い、公開用バケットへ PUT 。結果と取得したファイル情報を DynamoDB へ挿入。
  4. フロントエンドから API を叩いて DynamoDB の結果を確認
  5. アップロードが確認できたら PR TIMES のサーバーへアップロード内容を保存

となっています。

署名付きURLの発行

S3 へのファイルアップロードは署名付き URL を発行して行いました。

署名付き URL とは、指定のS3オブジェクトへの一時的なアクセス制限を与えるものになっています。 GET や PUT などが制御でき、 PUT 用の署名付き URL を使う事によって S3 へ直接ファイルのアップロードが可能となります。

選定理由

ファイルアップロードは様々なやり方があるかと思われますが

  • バックエンドのレガシーコードへの変更量を減らす(フロントエンドからアップロード)
  • 最大 10MB までのファイルをアップロード可能

などの理由から署名付き URL を使う方針にしています。

また、バックエンドでは PHP の変更量を最低限にするために API Gateway + Lambda を使って非公開バケットの署名付き URL の取得を行っています。

また、わざわざ署名付き URL を取得せずとも API Gateway + Lambda でそのまま S3 へアップロードする方法もあります。しかし、Lambda の呼び出しペイロードが 6MB であるため、「最大 10MB までのファイルをアップロード可能である」という条件を満たさない事から採用しませんでした。

署名付きURLの発行のフロー

署名付きURLの発行のフローは以下の通り。

PR TIMES のサーバーでは企業ユーザーの認証を行い、 API Gateway へリクエストを送るだけとなっています。

API Gateway では適切なリソースポリシーを設定します。これによって PR TIMES の IP アドレスだけのアクセスを許可します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "対象の Lambda 関数の ARN",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": [
                        "PR TIMES のサーバーのIPアドレス",
                    ]
                }
            }
        }
    ]
}

署名付きURLの発行の実装

呼び出される Lambda 関数では UUID を用いて S3 のオブジェクトキー名を決定し、署名付き URL を発行しています。(Go実装の参考

また、可読性のためにオブジェクトキー名にプレフィックスを付けています。プレフィックスはpress_kit/でクエリパラメータから取得したtargetを繋げたものになります。

// クエリパラメータからtargetを取る
// targetは適当にバリデーションをしているが省略
trg := req.QueryStringParameters["target"]
// Create S3 service client
svc := s3.New(sess)
u, err := uuid.NewRandom() // UUIDv4
if err != nil {
return newFailedResponse(), nil
}
key := "press_kit/" + trg + "/" + u.String()
// 生成したオブジェクトキーに対するPUTアクセス用リクエスト
sreq, _ := svc.PutObjectRequest(&s3.PutObjectInput{
Bucket: aws.String(os.Getenv("PRIVATE_BUCKET")),
Key: aws.String(key),
})
// 15分間だけ有効な署名付きURLを生成する
urlStr, err := sreq.Presign(15 * time.Minute)
if err != nil {
return newFailedResponse(), nil
}

こうして発行された署名付き URL は、 S3 の URI にクエリパラメータが付いている以下のような構造になっています。

https://xxxxxxxx.s3.ap-northeast-1.amazonaws.com/press_kit/{target}/{uuid}?X-Amz-Algorithm=xxxxx&X-Amz-Credential=xxxxx&X-Amz-Date=xxxxx&X-Amz-Expires=xxxxx&X-Amz-Security-Token=xxxxx&X-Amz-SignedHeaders=host&X-Amz-Signature=xxxxx

S3 へのアップロード

署名付き URL を利用することで S3 へアップロードすることが出来ました。しかし、ファイルのアップロードではバリデーションが必須です。また、ファイルが JPEG の場合、本来公開したくない EXIF 情報が含まれている場合があるので、それらを取り除く加工処理も必要です。

そして、ファイル容量や拡張子などの情報を取得する必要があるため、この段階でファイル情報を取得する必要があります。

公開用バケットへアップロードするまでのフロー

署名付き URL からアップロードされる先は非公開バケットとし、そのアップロードをトリガーに Lambda を発火させてバリデーションとファイル情報の取得(また JPEG の場合は加工処理)を行います。

問題がなければ公開用バケットにアップロードします。

この時、 Lambda は S3 PUT をトリガーにしているため、直接ユーザーにレスポンスを返す事が出来ません。なので、結果は DynamoDB へ保存して別途用意した API でバリデーションの結果を確認します。

S3 の CORS の設定

署名付き URL から S3 へアップロードする場合、何も設定していない状態では同一オリジンポリシーによって PUT が出来ません。そのため、アップロード用のバケットへ CORS を設定する必要があります。

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT"
        ],
        "AllowedOrigins": [
            "https://prtimes.jp"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 3000
    }
]

Lambda の実装

S3 の PUT をトリガーにする Lambda は多くの処理を行っています。そのため、ここでは流れを理解出来るように一部省略されたコードを提示します。

「画像の変換処理(JPEGの場合)」と「公開用のS3バケットへアップロード」については、注意した点があるので次項で詳しく解説しています。

func ConvertPressKitFile(ctx context.Context, event events.S3Event) {
// 引数はRequestではない事に注意
for _, record := range event.Records {
bucket := record.S3.Bucket.Name
key := record.S3.Object.Key
// S3のPUTをトリガーにするので、ObjectKeyからTargetを取得する
trg, err := getTargetFromKey(key)
// 非公開バケットからオブジェクトをダウンロード
tmpFile, err := os.CreateTemp("/tmp", "srctmp_")
downloader := s3manager.NewDownloader(sess)
_, err = downloader.Download(
tmpFile.,
&s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
// ファイルの情報を取得
fi, err := converter.MakeFileInfo(tmpFile)
// 取得したファイル情報でバリデーション
verrs := validator.ValidateFileInfo(fi, trg)
if len(verrs) > 0 {
// バリデーションエラーのリストからレスポンスを生成してDynamoDBへPUT
err = dynamoDbPut(sess, newValidationErrorItem(key, verrs))
continue
}
// 画像の変換処理(JPEGだけ)
if fi.ContntType == converter.JPEGMIMEType {
dstFile, err := os.CreateTemp("/tmp", "dsttmp_")
err = converter.ExecStripExif(tmpFile.Name(), dstFile.Name())
}
// 公開用のS3バケットへアップロード
result, err := s3Upload(sess, fi.TmpName, createPublicS3Key(key, fi.Extension), fi.ContntType)
// DynamoDB へアップロード
// 取得したファイル情報(+オブジェクトキー)からレスポンスを生成してPUT
bytes, err := json.Marshal(fi)
err = dynamoDbPut(sess, newItem(key, 200, string(bytes)))
}
}
view raw lambda-main.go hosted with ❤ by GitHub

JPEG 画像から ICC プロファイル以外の EXIF 情報を削除

JPEG 画像のメタデータには、撮影場所や撮影日時などの情報が含まれます。

ユーザーの予期せぬ情報が含まれている可能性がある事を考慮すると、画像のメタデータは消す必要があります。

色味などの情報を残しつつ、余分な EXIF 情報を削除するコマンドがこちら。

convert in.jpg -auto-orient +profile '!icc,*' out.jpg

Go で実行する時は、'がそのまま解釈されてしまうので、''で囲まずにそのまま書く必要があります。

err := exec.Command("convert", trg, "-auto-orient", "+profile", "!icc,*", dst).Run()

参考

Surrogate-Key ヘッダーの設定

今回、 CDN として fastly を利用していますが、fastly ではサロゲートキーを用いることで選択的にキャッシュのパージが可能です。

fastly でサロゲートキーを用いるには、オリジンサーバーとなる S3 でx-amz-meta-surrogate-keyヘッダーを使用する必要があります。

Fastly Docs
Amazon S3 オリジンに対する Surrogate-Key ヘッダーの設定 | Fastly ヘルプガイド
Amazon S3 オリジンに対する Surrogate-Key ヘッダーの設定 | Fastly ヘルプガイド

Go で書く時は、まずx-amz-meta-xxxxxxxを key とした map を作ります。

amzMeta := make(map[string]string)
amzMeta["surrogate-key"] = strings.Replace(key, "/", " ", 1) // 値はS3オブジェクトキーのプレフィックスをスペース区切りにした

生成した map をmap[string]*string型に変換して、 s3manager.UploadInputの Metadata に指定します。

result, err := uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(os.Getenv("PUBLIC_BUCKET")),
Key: aws.String(key),
Body: f,
CacheControl: aws.String("public, max-age=31536000"), // 1 year
ContentType: aws.String(ctype),
Metadata: aws.StringMap(amzMeta), // 先程作ったmap
})
view raw put-s3.go hosted with ❤ by GitHub

DynamoDB の設計と実装

パーティションキーを S3 のオブジェクトキー名で設定し、Lambda 関数の実行結果のレスポンス(ステータスコードと JSON Body )を格納しています。

この機能は一定期間利用できれば十分なので、できれば利用されるストレージを小さくしたいです。

そこで  TTL を有効にすることで、アイテムに設定した有効期限が切れになった時に自動削除されるようにしています。

func newItem(key string, code int, body string) *Item {
return &Item{
ObjectKey: key,
TTL: (time.Now().AddDate(0, 0, 7)).Unix(), // 1週間後に消える
Response: &ResultResponse{
StatusCode: code,
Body: body,
},
}
}

アップロードの確認

アップロードの確認は非常にシンプルな構成です。

まずフロントエンドから S3 のオブジェクトキーを指定して API Gateway にリクエストを送ります。 API Gateway は Lambda を呼び出し、DynamoDB からアイテムを探索します。

見つかれば格納されたデータからレスポンスを作成して返し、見つからない場合は 404 を返します。

item, err := dynamoDbGet(sess, key)
if err != nil {
log.Println(err)
return newFailedResponse(), nil
}
if item == nil {
// Key が見つからなかった場合は404
return &Response{
StatusCode: 404,
Body: `{"message": "not found"}`,
Headers: headerCORS,
}, nil
}
// DynamoDB からレスポンスを取り出して返却する
return &Response{
StatusCode: item.Response.StatusCode, // 200/400/500 が返される
Body: item.Response.Body,
Headers: headerCORS,
}, nil
view raw check-item.go hosted with ❤ by GitHub

アップロードとは非同期的に動作する API なので、1回目のリクエスト時に必ずしも DynamoDB にデータが入っているとは限りません。そのため、この部分は複数回のリクエスト(+タイムアウト)という力技で解決しています。

データの保存

アップロードされたファイルとは別に、ファイルの情報などは PR TIMES のサーバーに保存します。

すでにファイルが S3 へアップロードされているため、レガシーなコードにファイルアップロードする仕組みは必要なく、 S3 の URI などの情報だけを保存する単純なデータベース操作だけとなっています。(もちろん、ここでもバリデーションがあります)

具体的な API の実装や、レガシーな書き方からの脱却は、別の記事で紹介される予定です。お楽しみに。

おわりに

ここまで読んで頂きありがとうございます

プレスキット機能におけるアップロード機能の設計と実装について解説しました。

SaaS やモダンなフロントエンドを沢山活用することで、レガシーな PHP コードへの修正を減らし負担を軽減させ、社内で不可能と思われていた機能追加を実現し、今回リリースする事が出来ました!

プレスキット機能はまだまだスタートしたばかりで、これからどんどん機能が追加される予定です。

まだまだやりたいことが沢山あります。これからの PR TIMES に乞うご期待ください!

以上、よろしくお願いします。

この記事を書いた人

株式会社PR TIMES 開発本部 バックエンドエンジニア

目次
閉じる