私が歌川です

@utgwkk が書いている

ISUCON 14に「ミレニアムサイエンススクール」で参加して22位だった #isucon

はじめに

ISUCON 14にチーム「ミレニアムサイエンススクール」で参加しました。メンバーは自分と id:nonylene id:wass80 です。

最終スコアは28875点で、22位でした。やった~

gyazo.com

リポジトリはこれです。言語はGoです。なぜなら自分が最速で書けるため……。

github.com

やったこと

トレーシンググッズを入れる

初手ですね。前日に素振りしていたのでスムーズに入れることができました。といってもだいたいはコピペするぐらいだけど。

通知系エンドポイントが呼ばれる頻度を減らす

GET /api/app/notification GET /api/chair/notification が呼ばれまくっているのがCloud Traceの結果から一目瞭然でした。30ミリ秒に1回呼ばれとる!!

最初はコントローラー内で time.Sleep(time.Second) って書いてdelayを入れることで緩和しようとしたのですが、よく見るとレスポンスのJSONに retry_after_ms っていうフィールドがあるし、これで調整しようねってことじゃ~ん、ということに気づきました。いったん3000ミリ秒に1回呼ばれるようにしました。

GET /api/owner/chairs のレスポンスを3秒キャッシュする

明らかにやばいクエリが発行されているし、3秒キャッシュしていい、ってマニュアルに書いてあったのでさくっとキャッシュしました。

キャッシュ自体はできていたけど、キャッシュの参照・更新がうまく直列化できていなかったのであとでnonyleneさんに直してもらいました。mutexでガードする範囲が狭すぎたっぽい。

interpolateParams=true

いつものアレです。どうせ後でやるんだから最初から入れちゃえよ、ということで早い段階で有効にしました。

dsas.blog.klab.org

GET /api/app/notification のN+1クエリを潰す

相変わらず通知エンドポイントの負荷が高いので、まずはそこから取り組みました。ループの中で SELECT * FROM ride_statuses WHERE ride_id = ? ORDER BY created_at というクエリを発行しているのをINクエリで外に逃がすぐらいです。

足りないインデックスを足しまくる

とにかくインデックスが不足していたので、トレーシングに出てくるエンドポイントから発行されているクエリや、pt-query-digestの結果に出てくるクエリに対して効くインデックスを片っ端から足していました。

GET /api/app/rides のN+1クエリを解決する

ride_idごとの最新のride_statusesを取得するクエリをGitHub Copilotに丸投げしました。ちなみにこういうクエリになりました。

SELECT rs.*
FROM ride_statuses rs
INNER JOIN (
  SELECT ride_id, MAX(created_at) AS max_created_at
  FROM ride_statuses
  WHERE ride_id IN (?)
  GROUP BY ride_id
) latest
ON rs.ride_id = latest.ride_id AND rs.created_at = latest.max_created_at;

クーポンを考慮した料金計算ロジックが calculateDiscountedFare 関数に書いてあり、引数が nil かどうかで分岐していて嫌だな!! と思ったけど、ユースケースをよく見ると、POST /api/app/rides/estimated-fare でしか引数が nil になることはないようでした。

ということで、まずは POST /api/app/rides/estimated-fare から呼ぶ用の関数を分け、そのあと元の calculateDiscountedFare 関数の実装を整理しました。最終的には以下のようなシグネチャになりました。かなり引数を減らせましたね。

// before
func calculateDiscountedFare(ctx context.Context, tx *sqlx.Tx, userID string, ride *Ride, pickupLatitude, pickupLongitude, destLatitude, destLongitude int) (int, error)

// after
func calculateDiscountedFare(ctx context.Context, tx *sqlx.Tx, ride *Ride) (int, error)

ここまで整理できたら、あとは SELECT * FROM coupons WHERE used_by = ? というクエリでのN+1クエリを解決するだけですね。

引数が nil かどうかで分岐してもいい、してもいいが、このロジックを同じ関数に押し込めるのは見通しが悪すぎるんじゃないか?? と嘆くといった活動もやっていました。まあこれは出題意図通りではあるんじゃないか。

ride_idごとの最新のride_statusesを非正規化する

「ride_idごとの最新のride_statuses」は頻繁に参照されているので、これを非正規化することにしました。

以下のようなテーブルを用意して、ride_statuses テーブルへINSERTされるたびにUPSERTするようにしました。

DROP TABLE IF EXISTS latest_ride_statuses;
CREATE TABLE latest_ride_statuses
(
  ride_id VARCHAR(26) NOT NULL COMMENT 'ライドID',
  status ENUM ('MATCHING', 'ENROUTE', 'PICKUP', 'CARRYING', 'ARRIVED', 'COMPLETED') NOT NULL COMMENT '状態',
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '状態変更日時',
  PRIMARY KEY (ride_id)
)
  COMMENT = 'ライドの最新ステータステーブル。ride_statusesをもとに構築される';

通知系エンドポイントが呼ばれる頻度を増やす

通知系エンドポイントの負荷が高いので3000msおきに呼ばれるようにして緩和していたのですが、そろそろいいんじゃないか、ということで500ms秒おきに呼ばれるようにしました。点数が6000点ぐらいから14000点ぐらいまで伸びてました。2倍になっててウケるね。

chair_idごとの最新のride_statusesを非正規化する

ride_statuses テーブルには chair_id カラムがなくて大変!! ということで手こずりましたがなんとか手なずけました。

GET /api/app/nearby-chairs で非正規化した行を参照するようにしたり、chair_idごとの最新の位置情報を非正規化してもらっていたのを参照するようにしたりして、めでたく GET /api/app/nearby-chairs のN+1クエリを完全に解消できました。

トレーシング系グッズを外す

ENABLE_TRACING=1 環境変数を指定したときだけトレーシング系のライブラリを有効にするようにしました。

やらなかったこと

POST /api/chair/coordinate の書き込みバッファリング

POST /api/chair/coordinate の呼ばれる頻度が高く、書き込みヘビーになっているし、レギュレーションに記載されている遅延の範囲で書き込みをバッファリングしてもいいのかな~と思っていましたが、他の箇所の改善に取り組んだり様子を見たりしていたので、最後まで着手しませんでした。

通知のSSE化

アプリケーションマニュアルをまったく読んでいなかったので、通知をServer-Sent Eventsとして送るようにしてもよい、ということに気づいていなかった……。

決済のリトライ

アプリケーションマニュアルをまったく読んでいなかったし、決済マイクロサービスがボトルネックにならなかったので手を入れていません。

アプリケーションマニュアルには Idempotency-Key ヘッダを指定できると書いてあるので、ちゃんと読んでいたらオッとなったと思います。Idempotency-Key ヘッダ好きだし実戦投入したいんだよな~。ここで安全にリトライできない決済マイクロサービスが動いていたら嫌すぎるな。

DBの垂直分割

最新のライドの状態などだけ別のDBに分ける、ということを考えましたが、競技時間中にうまい分割方法を思い付けず没になりました。

マッチングのキューをアプリケーション側で実装する

マッチングのキューにMySQLを使わず、goroutineとチャネルでキューを実装しようとしましたが、エンキューはされるけどデキューされないキューが完成してしまったため没になりました。普段やらないようなことをやるとすぐバグる、ということの典型っぽいですね。チャネルあんまり使わないんだよな~。

よかったこと

効果が分かりやすい改修から順番に着手した

トレースの結果を見て、レイテンシが大きすぎる・呼ばれる頻度が高いエンドポイントから順番に対応を検討し、対処する、というのを初動でちゃんとできていたと思います。

Grafanaでpt-query-digestの結果が見れるようになってからは、なんでもないSELECTクエリなのに上位に来ているものに対して必要なインデックスを貼る、というのを順番にやっていました。

ISUCONで上位に入るには何らかの飛び道具が必要になるんじゃないか、というイメージを持たれる方も多いと思いますが、このような地道な改善を避けて通ることはできないと自分は考えています。

こういった改修を支える可視化技術については id:nonylene さんの記事を見てください。

nonylene.hatenablog.jp

GitHub Copilotを活用した

実はGitHub Copilotを使っていなかったのですが、このたびトライアルを開始しました。

Ride 型のスライスの変数 rides から Ride.ID のスライスを作る、のような典型的な処理なら、rideIDs := とだけ書いてTabキーを押すだけで勝手に完成します。

sqlx.In 関数を使いつつ、複数のIDを指定して chairs テーブルをクエリする、ぐらいの関数はちょっと修正したら使えるコードが出てきます。

最初は確か以下のようなコードが生成されました。このコードはそのままでは動きません。

func getChairsByIds(ctx context.Context, tx *sqlx.Tx, chairIDs []string) (map[string]*Chair, error) {
    var chairRows []*Chair
    if err := tx.SelectContext(ctx, &chairRows, "SELECT * FROM chairs WHERE id IN (?)", chairIDs); err != nil {
        return nil, err
    }

    chairs := make(map[string]*Chair)
    for _, chair := range chairRows {
        chairs[chair.ID] = chair
    }
    return chairs, nil
}

プレースホルダにスライスを直接渡すことはできません。IN句を使ったクエリを発行するときは、プレースホルダの数とスライスの長さを合わせたクエリを組み立てる必要があります。この場合は sqlx.In 関数を使うことで簡単にクエリを組み立てられます。

また、sqlx.In 関数に対して空スライスを渡すと実行時エラーが返ります。したがって、chairIDs が空の場合はearly returnするべきです。

加えて、引数としてトランザクション *sqlx.Tx を受け取ってもいいけど、 sqlx.QueryerContext interfaceを受け取るようにしておくと *sqlx.DB も渡せるようになって使う側の制約が小さくできていいですね。

ということで、自分が考えて書き直した正解のコードはこう:

func getChairsByIds(ctx context.Context, q sqlx.QueryerContext, chairIDs []string) (map[string]*Chair, error) {
    if len(chairIDs) == 0 {
        return nil, nil
    }

    query, args, err := sqlx.In("SELECT * FROM chairs WHERE id IN (?)", chairIDs)
    if err != nil {
        return nil, err
    }
    var chairRows []*Chair
    if err := sqlx.SelectContext(ctx, q, &chairRows, query, args...); err != nil {
        return nil, err
    }

    chairs := make(map[string]*Chair)
    for _, chair := range chairRows {
        chairs[chair.ID] = chair
    }
    return chairs, nil
}

ちゃんと動作するコードに書き換えるのは最初の1回ぐらいで、あとは func getOwnersByIds のように関数名を書くだけで欲しい・ちゃんと動作する実装が生成されるようになりました。これまでは自分の手を速く動かすことでカバーしていたのですが、こういう実装がポンと出てくるのはいいですね。

書いててだんだん思ったけど、ジュニアメンバーに対するコードレビューのような気持ちでCopilotと向き合うのが大事なんだろうなと思いました。

いまいちだったこと

マニュアルをちゃんと読まなかった

これ毎年書いてる気がしていて反省が活かされていないのでは?? 初手で実装を見て直すところに集中してしまってマニュアルに意識が向いていないかも。

何が点数に寄与しているのか明確でない時間が長かった

16時ぐらいに25000点ぐらいを記録していたけど、そこから競技終了間近まで苦しい時間が続いていました。wassに配車マッチングの仕組みを改善してもらっても20000点も出ない、長時間マッチングされずに確率的にFAILする、という感じで明らかに不穏。

答えはマッチングの間隔でした。0.1秒ごとにマッチングさせる設定にしていたけどいつの間にか0.5秒ごとに戻ってしまっていたため、点数の伸びが悪かったり、長時間マッチングせずにFAILしたりしていました。0.1秒ごとにマッチングさせるように戻した上でベンチマークを走らせたのが最終スコアになります。

このあたりはもうちょっと冷静に状況を分析できたらよかったかな~。

「ミレニアムサイエンススクール」について

適当にチーム名をつけて申し込んだままにしていたら、チーム名の変更ができる期間を過ぎており、そのまま出場しました。

dic.pixiv.net

おわりに

今年の問題も程よいボリューム感で解きごたえがあったと思います。最後までやりたい施策が尽きることもなく、終盤以外はずっと手を動かすことができました。来年も上位入賞して「偶数回だけ本選に出る」のジンクスを破れるといいですね。

川2024 (1/1 桂川)

これは 川見てる Advent Calendar 2024 - Adventar 6日目の記事です。5日目の担当は katsyoshi さんでした。

賑やかしを兼ねて記事を書きます。

正月に嵐山に行って桂川を見ました。電電宮に行って情報安全護符やお守りを更新する、というのを毎年やっています。


まだまだ枠がいっぱい空いている (2024/12/6 19:35時点) ので、川などについて思い出した方はぜひ記事を投稿してみてください。

adventar.org

壊れたRSSフィードを修復するWebアプリケーションを書いた

表題のものを書きました。

github.com

使い方

GET /rss というエンドポイントに、 url というクエリパラメータでRSSフィードのURLを指定してリクエストすることで、修復したRSSを返してくれます。

例: https://patchrss.dt.r.appspot.com/rss?url=https://adventar.org/calendars/6386.rss

モチベーション

2024/12/2 22:57 追記: 以下の不具合はAdventarの方で修正してもらえました。

アドベントカレンダーのRSSフィードをSlackで購読しようとしたところ、以下の不具合を踏みました。

github.com

Adventarのリポジトリのissueにコメントしたので、直してもらえる (あるいは自分で直しにいく?) のを祈りつつ、直るまでの間もRSSフィードを購読できるようにしよう、というのが直接のモチベーションです。あとはHonoへの入門も兼ねています。

実装

やっていることは非常にシンプルです。

  • 指定されたURLをRSSフィードとしてパースする
  • W3C Feed Validation Service, for Atom and RSSでエラーが出ないようなRSSフィードになるように書き換える
    • link 要素の値がURLじゃないならURLにする
    • 不足しているXML名前空間の指定を足す
    • guid 要素の値がパーマリンクかどうかを埋める

あとは申し訳程度のいたずら対策として、patchrss自身に対して無限ループになるようなリクエストを送ろうとしたらエラーになるようにしてみています。

https://github.com/utgwkk/patchrss/blob/62ffe86664efccb168f42023019abb146e51e6de/src/index.ts#L38-L42

ホスティング

ホスティングはGoogle App Engineに任せています。AWS Lambda Function URLも検討したけど、Cache-Control レスポンスヘッダでキャッシュを効かせたいとか、用意するコンポーネントを最小にしたいとかを考えた結果、GAEに落ち着きました。自分でちょっと使うぐらいのWebアプリケーションなら無料枠の範囲に収まるだろうし、デフォルトでエッジキャッシュが使えるのが嬉しいですね。

おわりに

https://patchrss.dt.r.appspot.com/ でホストしていますが、予告なく停止する可能性があります。もし継続して使いたい場合は、自分でGoogle App Engineにデプロイするなどしてください。難しいことはしてないので、環境変数と実行環境さえ用意できればだいたいどこでも動くと思います。

それでは、2025年もよきRSSライフを!

紅白ぺぱ合戦に参加した #cohackpp

connpass.com

「ぱ」陣営として参加しました。普段書いているプログラミング言語に合わせると「ご」陣営*1になるんじゃないでしょうか。

結婚記念パーティー? 技術カンファレンス? 90人規模? というよくわからない状態でおもしろ全部で小田原まで来ましたが、いい状態だったと思います。すてにゃんとあすみさんのどちらも、人に育てられ、人を育て、そして人を巻き込みまくる、というのがよく表れていた会でした。

緊急アンケートに「DJをやる」と書いて送ったら採用されててウケました。しかしすてにゃんがDJをやるのなら普段と変わらないのでは????

blog.stenyan.jp

ドレスコードはRubyKaigi 2022のTシャツでした。参加者一覧を見るにRubyistは多くなさそう、onkさんが着てこなければ被らずにいける、という読みでした。いかがでしたか?

私とすてにゃん

2013年~2014年ぐらいにTwitterで見かけてフォローしたのかな。消したツイートを掘り返すためにtweets.csvを見返してファクトチェックしました。くいなちゃんSNS*2とどっちが先だっけ? と思ったけどインターネット上に記録があんまり残っていない気がする。どうですか?

そのあと2015年の夏コミでオフで会ったみたいです。2015年は、自分が大学に進学して京都に引っ越した年です。いま当時の記事を見返すと全体的に若くてウケちゃうね。

blog.utgw.net

それからしばらくはTwitterやブログとかで見かけるぐらいだったと思います。いつの間にか自分がはてなに入社する形で同僚エンジニアになって、そして転職していきました。

小田原

これまで新幹線で通過しかしてこなかったけど、今回初めて降り立ちました。城が駅のすぐ近くにあるし、ちょっと歩いたところにブリュワリーがあるし、万葉の湯で夜を越せるし、きっといい街なんだろうと思いました。「いい街」の評価基準がこれでいいのか?

あすみさんの記事で紹介されていたスポットにあまり行けていないので次回はリベンジしたい。ずっとお腹が壊れていたので、翌日さっさと帰ってしまったのでした。

asumikam.com

小田原に止まり、名古屋までノンストップのひかりの存在も知り、復路はそれで帰りました。2時間おきに出ている便に乗ると、京都から乗り換えなしで比較的高速に来れて便利ですね。

写真コーナー

ビールの塔

「ぱ」は「ぱ」でも id:papix

緊急アンケートの採用景品ありがとうございます

ブチ上がっている

#stefafafanmemorial #papixmemorial

おわりに

結婚式ってたぶん子供の頃に1回だけぐらいしか行ったことがないし、今も誘われたとして行けるかどうかかなり怪しいと思うけど、こういうパーティーならいくらでも参加します。

PHPカンファレンス小田原2025の参加予定がGoogleカレンダーに入っているので、次回の小田原はそこになるんじゃないでしょうか。

ご結婚おめでとうございます。 id:stefafafan id:asumiso

*1:普段は主にGoを書いている

*2:かつて、そういったものが……

清渓川

これは 川見てる Advent Calendar 2024 - Adventar 1日目の記事です。

清渓川とは、ソウルの真ん中を流れる川です。京都でいうと鴨川や堀川に近いと思います。

歩いていく

強い巻き貝が私たちを出迎えてくれます。清渓川復元1周年記念オブジェだそうです。

www.seoulnavi.com

ピンクの生き物もいます。「ヘチ」と言うそうです。

japanese.seoul.go.kr

流れです。

周辺案内です。

ピラニアはいないようですが、小魚はいました。

いざというときに助けてくれるのは「高さ」なのかもしれません。

「異国の川」シリーズも集めていきたいですね。

おわりに

明日はnna774で「⊤川 もしくは ⊥川」です。

川見てるアドベントカレンダー、まだまだ日程が空いている (2024/11/25現在) ので、ふるってご参加ください。実家の近くにあった川 (具体的な場所は伏せる) の思い出とか、それぐらいのテンションでいいと思います。

adventar.org

AWSのIAMロールに必要な権限が付与されているかシミュレートするCLIツールを書いた

はじめに

表題のようなCLIツール aws-iam-policy-sim を書きました。

github.com

使い方

Statement フィールドに、以下のようなオブジェクトの配列が入っている、というJSONファイルを用意しましょう。

  • Action フィールドにアクション名もしくはその配列
  • Resource フィールドにリソースもしくはその配列

たとえば以下の通りです。うっすらお気づきの方もいると思いますが、実はポリシードキュメントのJSONがそのまま使えます。

{
  "Statement": [
    {
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:GetObjectTagging",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::example-bucket/*"
      ]
    },
    {
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::example-bucket"
    }
  ]
}

このJSONファイルを path/to/statement.json というパスに保存した上で、example-role という名前のIAMロールについてシミュレートしたい場合、以下のようなコマンドを実行します。

$ aws-iam-policy-sim --role-name example-role < path/to/statement.json
2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:PutObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:GetObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:GetObjectTagging resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:DeleteObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:ListBucket resource=arn:aws:s3:::example-bucket

権限が足りない場合は aws-iam-policy-sim コマンドがexit status 1で終了します。

$ aws-iam-policy-sim --role-name example-role < path/to/statement.json
2024-12-03 08:22:00 INF msg=Allowed action=s3:PutObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF msg=Allowed action=s3:GetObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF msg=Allowed action=s3:GetObjectTagging resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 INF msg=Allowed action=s3:DeleteObject resource=arn:aws:s3:::example-bucket/*
2024-12-03 08:22:00 ERR msg="Implicit deny" action=s3:ListBucket resource=arn:aws:s3:::example-bucket

モチベーション

AWSのIAMロールにアタッチした許可ポリシーを整理したいけど、整理の過程で意図せず必要な権限を削ってしまわないか気になりますよね。こういうときにIAM Policy Simulatorを使うことで、指定したIAMロールから、指定したリソースに対するアクションが許可されるかどうか、がシミュレートできます。

docs.aws.amazon.com

しかし、どうしてもコンソールをポチポチ操作しないといけないので、シミュレートしたいものの数が多いと手間だしミスもしてしまうと思います。

さて、AWS CLIを使えばいいんじゃないか、ということはすぐに思いつくでしょう。ドキュメントを読んでみると、aws iam simulate-custom-policy というまさにこのためのコマンドが定義されています。

awscli.amazonaws.com

ですが、aws iam simulate-custom-policy コマンドを使って「あるIAMロールで指定したリソースに対するアクションが許可されるかどうか」をシミュレートするには手間がかかります。具体的には --policy-input-list 引数には許可ポリシーのARNではなくポリシードキュメント (JSON) を指定してあげる必要があります。つまり、こういう手順になるわけです。

  1. シミュレート対象のIAMロールの許可ポリシーのドキュメントを全て列挙する
  2. --policy-input-list 引数にJSONを渡す
  3. --action-names 引数や --resource-arns 引数の値を変えつつシミュレートしていく

これを整理前と整理後で繰り返す必要があります。シェルスクリプトを書いたら自動化はできるだろうけどJSONの取り扱いがやや面倒だったり、ポリシードキュメントを列挙するのが手間だったり*1します。

たぶん手慣れたプログラミング言語で書いてしまうのが早いだろうな~と思って1時間ぐらいやったらできました。

実装のみどころ

ポリシーを列挙するのにイテレータを使っているのがおしゃれなんじゃないでしょうか。iter.Seq2[V, error] 型のイテレータを返す実装パターンをやってみたけどこんな感じなのかな?

github.com

あとはslogを使っているところとかでしょうか。DEBUGレベルのログは -debug 引数に値を渡したときだけ出る、というのがslogのログレベルの仕組みで実現できています。ログ出力の見た目が読みやすいかどうかは諸説あるかも。

おわりに

年末のIAMロール権限掃除にぜひご活用ください。IAMユーザーやグループに対するシミュレートは、自分が必要に駆られていないので実装してません。

シェルスクリプトからAWS CLIを呼ぶのだとちょっと煩雑になる、という場合は、手慣れたプログラミング言語のAWS SDKを使ってCLIを書いてみるのがおすすめです。

*1:list-role-policiesとlist-attached-role-policiesを使い分ける必要があった

utgw.netをCloudFront + AWS Lambda Function URLに移行した

移行しました。ペライチなのでそんなに難しいことはないだろうと思ったけど、細々とハマりどころがありました。

utgw.net

前回の移行はこちら。

blog.utgw.net

前提

モチベーション

今回の移行のモチベーションは、「おもしろ全部」が8割、Next.js製WebアプリケーションをLambda Function URLで配信する練習をそろそろやっておくか、という気持ちが2割です。

最近は、Next.js製のBFFサーバーをいかにして無限にスケールさせるか、への関心が高まっています。Lambda関数としてデプロイできればスケーラビリティが得られるんじゃないか、ということはよく思うけど試せていなかったので、自分の個人サイトというおもちゃで試してみるか、と思った次第です。

やり方 & ハマりポイント

先に結論から言うと、Next.js製アプリケーションをLambda Function URLにデプロイする方法については以下の記事でだいたい解説されているので、そっちをまず読むのがいいと思います。

serverless.co.jp

ここから先は細々としたハマりどころを紹介するコーナーになります。

CloudFront経由でLambda Function URLを実行すると {"Message": null} というエラーが返る

以下のStackOverflowと同じことが発生していました。

stackoverflow.com

確かオリジンリクエストポリシーを AllViewer ではなく AllViewerExceptHostHeader に設定することで直ったはず。HostヘッダがCloudFrontのものに上書きされた状態でリクエストがLambda Function URLに到達しておかしくなっていたのかな。

S3にあるはずのオブジェクトパス指定してリクエストを送っても404が返ってくる

これも先述したのと同じでオリジンリクエストポリシーを AllViewerExceptHostHeader に変えたら直った気がする。このあたりはコンソールをポチポチいじって試行錯誤しまくったので少し曖昧です。

静的ファイルがないとき404ではなく403が返る

CloudFrontからS3のオブジェクトを参照できるようにするポリシーを設定するとき、コンソールで指示されるJSONをコピペすると、存在しないオブジェクトを参照したときにHTTP 404ではなく403が返ってきます。

既にピンと来ている人もいると思いますが、GetObject APIを叩くクライアントがListBucket APIを叩く権限がないと404が返ってこない例のアレです。

docs.aws.amazon.com

これはAPIのドキュメントにも書いてあるし、いろいろ思うところはあるけど変えづらいのでしょう……ということでバケットに対してListObject APIを実行する権限を足してやりましょう。

If you have the s3:ListBucket permission on the bucket, Amazon S3 returns an HTTP status code 404 Not Found error.

If you don’t have the s3:ListBucket permission, Amazon S3 returns an HTTP status code 403 Access Denied error.

ACMの証明書はus-east-1リージョンで発行する

1敗

Next.jsのレスポンスキャッシュ

App Routerのキャッシュ機構についてまだ把握しきれていなくて、ドキュメントをつまみ食いしながら設定していました。

nextjs.org

ページコンポーネントと同じモジュールから revalidate: number という変数をexportすることでキャッシュの有効期限を設定できるようです。たとえば以下のような変数を定義すると、レスポンスが10分間キャッシュされます。

export const revalidate = 1200

この設定でNext.js 13.4.9を使っていたときは Cache-Control: s-maxage=1200, stale-while-revalidate というレスポンスヘッダが付与されており、stale-while-revalidateって秒数の指定が要るのでは? と思っていたけど、Next.js 15.0.3に上げたら Cache-Control: s-maxage=1200, stale-while-revalidate=31534800 に変わりました。

CloudFrontはstale-while-revalidateに対応しているので、これでキャッシュの期限が切れたときのレイテンシが隠蔽しやすくなりそう。

aws.amazon.com

Next.jsレイヤでのリダイレクトをキャッシュさせる

CloudFrontで3xx系のステータスコードのレスポンスをキャッシュさせるには、オリジンから Cache-Control レスポンスヘッダを付与してやる必要があります。

Next.jsには next.config.js にリダイレクト設定を書く機能がありますが、これだけだと Cache-Control レスポンスヘッダが付与されません。

nextjs.org

いったん以下のようなミドルウェアを書いてしのいでいます。

import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/labs")) {
    const redirectUrl = new URL(
      request.nextUrl.pathname,
      "https://sugarheart.utgw.net"
    );
    return NextResponse.redirect(redirectUrl, {
      status: 308,
      headers: {
        "Cache-Control": "public, max-age=31536000, s-maxage=31536000",
      },
    });
  }
}

おわりに

aws-lambda-web-adapterや先人のブログ記事のおかげで、そんなに大きくハマることもなくNext.js製WebアプリケーションをCloudFront + Lambda Function URLの構成に移行できました。もっと複雑な構成になっている場合はいろいろあるかもしれません。

Lambda関数自体のデプロイはlambrollでやっていたのですが、こちらは全くハマることがなく使えました。便利ですね。

クラウド破産が見えてきたらまたお知らせします。