DevOps 2026年9月1日

プレビュー環境は閉じたPRの分が残ると思っていた。実際は開いたままのPRの分が先に消える設計だった

XECIN GitHub ActionsS3ライフサイクルプレビュー環境技術検証

先週、自社サイトのレビュー待ちPRが24本まで積み上がっているのを見て、片付ける前にひとつ気になったことがありました。

このリポジトリはPRを立てるたびに、S3のプレビュー用バケットへ /pr-{番号}/ というプレフィックスでサイト一式を丸ごと置きます。PR番号は今日の時点で392まで進んでいます。ということは、バケットの中には392世代分のサイトが積み上がっているのではないか。運用を考えると、まずそこを実測しておきたかった。

結論から言うと、私の見立ては外れていました。しかも「思ったより溜まっていなかった」という気楽な外れ方ではなく、消えてはいけないものが消える設計になっていた、という方向の外れ方でした。

「後始末する仕組みが無い」ことは、設定を読めばすぐ分かった

最初に読んだのはワークフローの発火条件です。

# .github/workflows/preview.yml(抜粋)
on:
  pull_request:
    types: [opened, synchronize, reopened]

# ...

      - name: Deploy to S3 (preview)
        run: |
          aws s3 sync ./dist \
            s3://$PREVIEW_BUCKET/pr-$PR_NUMBER/ \
            --delete \
            --cache-control "no-cache"

types に closed がありません。PRがマージされようがクローズされようが、このワークフローは動かない。つまりPRの終了に連動して /pr-{番号}/ を消すジョブは存在しない。

デプロイ側もプレフィックスの中しか触りません。--delete は付いていますが、これは同じ pr-{番号}/ の中で消えたファイルを追随させるためのものです。他のPRのプレフィックスには一切影響しません。

ここまでで「掃除する主体がどこにもいない」と判断しました。正直なところ、この時点で結論まで決めていました。392世代が積みっぱなしになっているはずだ、と。

実際に叩いたら、古いものから消えていた

裏取りのために、プレビューのURLへHTTPリクエストを投げて生死を測りました。読み取りだけなので副作用はありません。

# プレビューの生死を PR 番号で二分探索する
for n in 1 50 100 200 251 300 345 346 350 391 392; do
  code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 15 \
    "https://PREVIEW_HOST/pr-$n/")
  echo "pr-$n -> $code"
done

# 実行結果:
# pr-1   -> 403
# pr-50  -> 403
# pr-200 -> 403
# pr-251 -> 403
# pr-300 -> 403
# pr-345 -> 403
# pr-346 -> 200
# pr-350 -> 200
# pr-391 -> 200
# pr-392 -> 200

# 死んでいる側が何を返しているかも見ておく
curl -s -D - -L "https://PREVIEW_HOST/pr-345/" | head -8

# 実行結果:
# HTTP/1.1 403 Forbidden
# Content-Type: application/xml
# Server: AmazonS3
# X-Cache: Error from cloudfront
#
# <?xml version="1.0" encoding="UTF-8"?>
# <Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>

境界がはっきり出ました。pr-345 までは死んでいて、pr-346 から生きている。

GitHub側でこの2本の日付を引くと、pr-345 が 2026-07-31 作成、pr-346 が 2026-08-01 作成。実測した日は 2026-09-01 です。ちょうど31日。

掃除する主体はいないと思っていたのに、きれいに日数で切られていました。

犯人はIaCの中に書いてあった

探し方が悪かっただけで、答えはリポジトリに入っていました。

# infra/previewsite-prod-cloudfront.yml(抜粋)
  Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      LifecycleConfiguration:
        Rules:
          - Id: "delete-old-pr-previews"
            Status: Enabled
            Prefix: "pr-"
            ExpirationInDays: 30

delete-old-pr-previews。名前のとおりです。ワークフロー側ではなく、バケット定義の側に掃除役がいた。

指定は30日なのに実測が31日だったのは、日数指定のライフサイクルが「オブジェクトの作成時刻に日数を足し、その結果を次のUTC 0時に切り上げる」という計算をするからです。0時に丸める分だけ、実効の寿命は指定値より最大1日長くなる。しかも削除自体は非同期のキューで処理されるので、切り上げた時刻ちょうどに消える保証もありません。ドキュメントにはこう書いてあるけれど、実運用で「31日」という具体的な数字を自分の目で見るまでは、正直あまり実感がなかった部分です。

今思えば、.github/workflows/ だけを読んで「掃除する仕組みが無い」と結論づけたのが浅かった。後始末の設定が、デプロイの設定とは別のディレクトリの、別の記法(CloudFormation)で書かれていたわけです。ワークフローを読む人とインフラ定義を読む人が同じとは限らない。この分断そのものが、今回いちばん効いた原因だと考えています。

「PRの状態」と「オブジェクトの年齢」は別物

ここが本題です。

ライフサイクルルールは、そのオブジェクトに紐づくPRが開いているのか閉じているのかを一切知りません。知っているのは、作成から何日経ったかだけ。一方でワークフローは、PRが閉じたことを知る手段(closed イベント)を持っているのに、それを使っていない。

結果として、真逆の2つの要件が同じ「30日」という1つの数字で処理されています。

PRの状態本来望ましい後始末実際の挙動(作成日から30日)ズレの向き
マージ済み / クローズ済み閉じた時点で即削除最大30日そのまま配信され続ける掃除が遅すぎる
オープン(レビュー待ち)閉じるまで残す最後のpushから31日で消える掃除が早すぎる

両方とも実測で確認できました。

まず「遅すぎる」側。閉じているのにまだ配信されているプレビューが、pr-346 から pr-363 まで18本あります。すべて 200 を返します。全PR 392本のうち閉じているのは368本(マージ311・クローズ57)なので、割合としては4.9%。件数は小さいものの、レビューが終わって用済みになった環境が最大1か月生き残る状態です。

次に「早すぎる」側。こちらのほうが運用上は厄介でした。

# 開いているPRについて、最後のpushからプレビューが消えるまでの残日数を出す
git fetch --quiet --depth 1 origin "+refs/pull/*/head:refs/remotes/pr/*"
for n in $(gh pr list --state open --limit 60 --json number --jq ".[].number" | sort -n); do
  pushed=$(git log -1 --format=%cI "refs/remotes/pr/$n" | cut -c1-10)
  age=$(( ( $(date -d 2026-09-01 +%s) - $(date -d "$pushed" +%s) ) / 86400 ))
  printf "%s\tlast_push=%s\tage=%dd\tleft=%dd\n" "$n" "$pushed" "$age" "$((31-age))"
done

# 実行結果(先頭5本):
# 364  last_push=2026-08-12  age=20d  left=11d
# 365  last_push=2026-08-13  age=19d  left=12d
# 371  last_push=2026-08-13  age=19d  left=12d
# 372  last_push=2026-08-14  age=18d  left=13d
# 373  last_push=2026-08-15  age=17d  left=14d

いちばん古い #364 の余命は11日。

11日という数字が危ういのは、このリポジトリのマージが毎日ぱらぱら進むのではなく、まとめて一気に片付けるスタイルだからです。マージが実際に行われた日を並べてみると、7月末は 07-22 / 07-23 / 07-25 / 07-26 / 07-28 と細かく動いているのに、その次は 08-08 まで飛びます。11日の無風期間です。さらに 08-15 の次は 08-28 で、こちらは13日空いている。

直近で観測できた無風期間は11日と13日。#364 の余命11日は、その無風期間とほぼ同じ長さです。次のまとめマージが一度でも遅れれば、まだ開いているPRのプレビューが、誰も操作していないのに消える。

しかも復旧の手段がありません。ワークフローの発火条件は opened / synchronize / reopened の3つで、開いているPRを再デプロイするには新しいpushを起こすしかない。中身を変える予定がなくても空コミットを積むことになります。

そして消えたあとの見え方が、先ほど測った403です。111バイトの生のXMLでした。404ページですらない。OAC構成では存在しないオブジェクトへのリクエストがS3の AccessDenied として返ってくるので、CloudFront側のカスタムエラーレスポンスを当てていないとこうなります。

さらに悪いことに、デプロイ時にPRへ自動投稿される「プレビュー環境」コメントは残り続けます。6月に閉じた #251 のコメントを開くと、いまもプレビューURLのリンクが載っていて、踏むとこのXMLが出ます。レビューしようとした人から見れば「権限が無いのかな」としか読めない見え方です。

コストの話をすると、この設計はどちらにも寄っていない

コスト比較を入れておきます。私はここを見ないと判断できないたちなので。

ビルド1回分の dist を実測すると 27.2MB / 274ファイル(112ページ)。生きているプレビューは現在42本ぶん(オープン24+閉じたまま残る18)なので、おおよそ1.1GB。東京リージョンのS3 Standardは1GBあたり月$0.025なので、月あたり$0.03弱、日本円で5円に届きません。

仮に保持する期間残るプレビュー数(概算)ストレージ月額(概算)運用上の評価
30日(現状)42本 / 約1.1GB約$0.03開いているPRが巻き込まれる
90日約120本 / 約3.2GB約$0.08巻き込みはほぼ消えるが用済みが残る
全期間保持(392本)392本 / 約10.4GB約$0.26金額としては誤差

全部残しても月$0.26、40円弱です。つまり30日という数字は、コスト対策としては過剰で、リンク切れ対策としては不足している。どちらの目的から見ても最適ではない値が、たまたま真ん中に置かれていた、というのが今回の実測から見えた姿でした。

ここは好みが分かれるところですが、私はこういう「両方の要件をひとつのパラメータで妥協的に満たしている設定」を見つけたら、値をいじるのではなく条件を分けるべきだと考えています。値の調整では、どちらかを必ず犠牲にするので。

直すとしたら、判断材料を2つに分ける

方針としては、削除の判断を「年齢」から「PRの状態」に移し、年齢のほうは取りこぼしを拾う保険として残す形が素直だと考えています。

  • PRが閉じたら消す。types に closed を足して削除ジョブを回す
  • 開いているPRは消さない。ライフサイクルの日数は保険として長め(90日程度)に倒す
  • 保険が発動しても気づけるように、開いているPRのプレビューが生きているかを定期的に測る

3つめのために、いま手元で使い捨てに書いた検査がこれです。

#!/usr/bin/env bash
# 開いているPRのプレビューが生きているかを確認する
# 改善の余地あり: CloudFront経由のcurlで総当たりしているため、
# PR数に比例して遅く、CDNのキャッシュ状態にも結果が左右される。
# 本来は S3 の ListObjectsV2 で pr- プレフィックスを直接数えるべきで、
# 「200が返る = 中身が正しい」ことまでは保証していない。
set -u
ng=0
for n in $(gh pr list --state open --limit 100 --json number --jq ".[].number"); do
  code=$(curl -s -o /dev/null -w "%{http_code}" -L --max-time 10 \
    "https://PREVIEW_HOST/pr-$n/")
  if [ "$code" != "200" ]; then
    echo "EXPIRED: pr-$n (http=$code) -- PRは開いているのにプレビューが無い"
    ng=$((ng+1))
  fi
done
echo "expired=$ng"

# 実行結果: expired=0(24本すべて生存。最短の #364 で残り11日)

いまは0件です。ただしこれは「まだ誰も踏んでいない」というだけで、11日後には1件になり得る。

振り返って

調べ始めた動機は「溜まっているはずだから掃除しよう」でした。実際に測ったら、溜まってはいなかった代わりに、レビュー待ちの列が長いほど手前のPRからプレビューを失う構造が見つかった。レビューが遅れることそのものより、レビューが遅れたときに検証手段まで一緒に失われることのほうが、運用としては重い問題だと考えています。

当面は、ライフサイクルの日数を延ばすより先に、開いているPRのプレビュー残日数を可視化するところから手を付けるつもりです。数字が見えていれば、少なくとも「気づかないうちに消えていた」は避けられるので。

そのうえで closed での削除を足して、年齢による削除を保険の位置まで下げる。順番としてはこの向きが安全だと考えています。設定を1つの数字で兼務させるのをやめる、という話でもあります。