Webサービスを開発する場合、画像配信はtoB/toC問わず必要になることが多いです。人財活用プラットフォームHRMOSの評価管理は2019年にリリースされた機能ですが、データ連携・インフラ整備・セキュリティ強化など、リリースに向けて様々な準備が必要でした。今回はその中から、AWSのサーバーレスを活用して、セキュアに画像を配信する仕組みを構築した時の取り組みをご紹介します。

画像配信を新たに構築した経緯

評価管理をリリースするにあたり、従業員データベースに設定されている顔写真を評価管理に取り込んで、プロフィール画像として利用可能にすることになりました。PoCの段階では、プロフィール画像はユーザーが任意に設定できる画像しかなかったため、画像配信はSNSのプロフィール画像のような公開画像が前提で作られており、以下のような構成でした。

旧画像配信構成図

顔写真は個人情報に該当するためセキュアに配信する必要がありますが、画像の閲覧をログイン必須にしてしまうと、Slackやメールなどの通知でプロフィール画像を利用できなくなってしまい、UXが低下してしまいます。そこで、ログインユーザーしかURLを知ることができず、画像の閲覧はログイン不要にできる署名付きURLで配信する仕組みを構築することにしました。

セキュアな画像配信を構築する

AWSで署名付きURLを実現する方法はS3とCloudFrontの2パターンありますが、S3の署名付きURLはS3に直接アクセスすることになるため、他のAWSサービスを挟むことができません。CloudFrontの署名付きURLであれば、CloudFrontの後ろは他のAWSサービスに繋いでいくことができますので、CloudFrontの署名付きURLを採用する方が自由度が高くなります。

旧画像配信にはリサイズ機能がありましたので、新画像配信にもリサイズ機能を用意しなければいけません。CloudFrontの署名付きURLを採用するのであれば、オリジンにAPIGatewayを設定してLambdaに繋ぐのではなく、Lambda@Edgeを利用することも考えられます。しかしLambda@Edgeはレスポンスサイズ1MB制限がありますので、リサイズした画像だけでなくオリジン画像も配信することを考えると採用できませんでした。

以上のことから、AWSが公開しているサーバーレスソリューションの1つServerless Image Handlerをベースに、CloudFrontの署名付きURLを利用する形で構築し、以下のような構成になりました。

新画像配信構成図

旧画像配信と比べると、S3のbucketを private 設定にした上でCloudFrontの署名付きURLを利用することでセキュアになりました。また、配信のフローが1本化されたことでシンプルな構成になりました。

署名付きURLをバックエンドで生成する

署名付きURLで画像を配信できる状態になったので、次はバックエンドのAPIでプロフィール画像URLを返している箇所で、署名付きURLを生成するように変えていきます。

署名付きURLの生成にはCloudFrontキーペアが必要ですが、機密情報のためAPIやTerraform、Dockerなどの設定ファイルに記載することは避けたい所です。SecretsManagerにCloudFrontキーペアのパブリックキーとプライベートキーをそれぞれ登録し、ECSのタスク定義でコンテナ起動時にSecretsManagerから取得して環境変数に設定するとよさそうです。環境変数に設定されていれば、APIの設定ファイルでも使用することができます。

前述の通りTerraformの設定ファイルにもCloudFrontキーペアは持たせたくありませんので、Terraformでは仮の値でSecretsManagerにCloudFrontキーペアを保存する場所だけ作り、後から正しい値に更新する手順を踏みます。SecretsManagerは、マネジメントコンソールではプライベートキーのような改行がある文字列が登録できませんので、CLIで登録するかbase64でエンコードするか、いずれかを行う必要があります。今回は文字列を見てもそのままではプライベートキーが分からないようにbase64でエンコードし、API起動時にデコードして使用するようにしました。

キーペア取得シーケンス図

署名付きURLは元となるURLと有効期限を使用して生成するため、フロントエンドから利用したい画像サイズを指定して発行する方法を採用すると、APIとの通信回数が増えてしまいます。フロントエンドでプロフィール画像を利用する際のサイズはデザイン仕様で決まっているため、あらかじめデザイン仕様に則ったサイズを指定した署名付きURLを複数生成して返す形にしました。

1
2
3
4
5
6
{
  images: {
    128: "https://example.com/images/128?Expires=有効期限&Signature=署名&Key-Pair-Id=キーペアID",
    256: "https://example.com/images/256?Expires=有効期限&Signature=署名&Key-Pair-Id=キーペアID"
  }
}

以上の対応で、署名付きURLをバックエンドで生成し、フロントエンドで最適なサイズのプロフィール画像を選択して表示することができるようになりました。

バックエンドのパフォーマンスを改善する

バックエンドのAPIから署名付きURLを返す機能のリリース後、ユーザー情報をリスト表示している画面で使用しているAPIのレイテンシが、リリース前より悪化していることがわかりました。原因は署名処理でした。署名処理は1回あたり15ms程度かかっており、並列で行っているとはいえ大量に実行されたことで、パフォーマンスに影響が出ていました。

スクラム開発をしていると、スプリント中に実行するタスクはプランニングで決めていて、新たなタスクが発生したら次のスプリント以降で実行するか今実行するか、優先順位をチームで検討して再計画しなければいけません。しかし、私が所属している部では、プランニングで決めるタスクとは別に、個人が思い思いの改善タスクを行える枠を部の方針として用意してあり、スプリントの予算からも除外してプランニングをしています。今回はその枠でパフォーマンス改善を実施することにしました。

パフォーマンスを改善するにあたり、まず検討するべきことはボトルネックとなっている処理自体を速くするか処理の回数を減らすかです。今回のケースではAWS SDKの署名処理を使用していて署名処理自体の高速化は難しいため、署名処理の回数を減らす方向で考えました。

署名処理の回数を減らすにはキャッシュの利用が考えられます。署名付きURLをキャッシュするとキャッシュされている間は署名が固定されるため、ブラウザキャッシュが活かせるようになりますが、署名付きURLのキャッシュの有効期限と署名の有効期限の関係に気をつけながら実装しなければなりません。

例えば、署名を一定期間毎に更新する仕組みにすると、キャッシュの有効期限内にもかかわらず署名の有効期限が切れてしまうことが想定されます。署名の有効期限とURLキャッシュの有効期限を同一に設定した場合でも、署名の有効期限直前にキャッシュから取得してAPIのレスポンスとして返すことになり、ブラウザで画像を表示する段階で署名の有効期限が切れてしまう可能性があります。

そこで、URLキャッシュの有効期限を署名の有効期限より短くしつつ、URLキャッシュになかったら毎回署名付きURLを生成するようにしました。これにより、上記有効期限問題が解決できました。

有効期限の調整

次にURLキャッシュの場所ですが、最初はElastiCacheにキャッシュすることを考えました。しかし、署名はキーと有効期限日時が同じであれば同一になりブラウザキャッシュは活かせることと、消えてもいいデータであること、コンテナのメモリには余裕があり文字列のキャッシュなら影響も少ないことから、ローカルキャッシュを利用することにしました。これにより、将来的にElastiCacheに載せることを想定しつつ、まずはElastiCacheのコストを発生させない構成にすることができました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
trait ImageUrlSigner {
  // 画像URLを署名して返す
  def sign(url: URI, expireIn: Duration): String
}

class CloudFrontImageUrlSigner(
    signer: CloudFrontSigner,
    clock: Clock
) extends ImageUrlSigner {
  override def sign(url: URI, expireIn: Duration): String = {
    val jDuration = java.time.Duration.ofMillis(expireIn.toMillis)
    // 現在日の00:00:00.000000000を起点にして、同じ日・同じ有効期限の有効期限日時を整える
    val expireDateTime =
      LocalDateTime.of(LocalDate.now(clock), LocalTime.MIN).plus(jDuration)
    val dateLessThan = Date.from(expireDateTime.toInstant(ZoneOffset.UTC))
    // 同じ有効期限日時で同じURLなら署名も同じになり、各キャッシュが活かせる
    signer.sign(url.toString, dateLessThan)
  }
}

class CloudFrontSigner(keyPairId: String, privateKey: PrivateKey) {
  // CloudFrontキーペアを使用してURLを署名して返す
  def sign(url: String, dateLessThan: Date): String = {
    CloudFrontUrlSigner.getSignedURLWithCannedPolicy(
      url,
      keyPairId,
      privateKey,
      dateLessThan
    )
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class CaffeineCache[V](cache: Cache[String, CaffeineEntry[V]], clock: Clock) {
  // ローカルキャッシュから取得し、なかったらキャッシュする
  def getOrElse(key: String, ttl: Duration)(orElse: => V): V =
    get(key).getOrElse {
      val value = orElse
      put(key, value, ttl)
      value
    }

  def get(key: String): Option[V] = {
    val maybeEntry = Option(cache.getIfPresent(key))

    maybeEntry.flatMap { entry =>
      if (entry.isExpired(Instant.now(clock))) {
        remove(key)
        None
      } else {
        Some(entry.value)
      }
    }
  }

  def put(key: String, value: V, ttl: Duration): Unit = {
    val entry = CaffeineEntry(value, toExpiryTime(ttl))
    cache.put(key, entry)
  }

  def remove(key: String): Unit = cache.invalidate(key)

  private def toExpiryTime(ttl: Duration): Instant =
    Instant.now(clock).plus(ttl.toMillis, ChronoUnit.MILLIS)
}

object CaffeineCache {
  def apply[V](maximumSize: Long, clock: Clock): CaffeineCache[V] = {
    val cache = Caffeine
      .newBuilder()
      .maximumSize(maximumSize)
      .build[String, CaffeineEntry[V]]()

    new CaffeineCache(cache, clock)
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
trait CachedImageUrlSigner {
  // 署名した画像URLがキャッシュにあれば返し、なければ署名して返す
  def sign(url: URI, expireIn: Duration): String
}

class CaffeineCachedImageUrlSigner(
    signer: ImageUrlSigner,
    maximumSize: Long,
    clock: Clock
) extends CachedImageUrlSigner {
  private val cache = CaffeineCache[String](maximumSize, clock)

  override def sign(url: URI, expireIn: Duration): String = {
    // 同じURLでも有効期限によって署名が異なるのでキーも変える
    val key = s"signed-image-${url.getPath}-${expireIn.length}${expireIn.unit.name}"
    // 署名の有効期限よりキャッシュの有効期限を短くする
    val cacheExpiration = expireIn / 2

    cache.getOrElse(key, cacheExpiration) {
      signer.sign(url, expireIn)
    }
  }
}

以上の対応で、1回あたり15ms程度かかっていた署名処理はキャッシュが有効な間は1ms以下にすることができ、該当APIのパフォーマンスへの影響も最小限に抑えることができました。

まとめ

後回しになりがちな改善タスクがプロダクトの開発タスクと並行して行える環境で、一緒にHRMOSを創って行きたいエンジニアさんWANTED!

松本 陽介
松本 陽介

新規事業が好きな、ワクワク駆動のソフトウェアエンジニア。EMやPdMもやったりします。