個人開発で公開している Web サービスの 1 つについて、しばらく「利用者が増えない」と感じていました。
ところが、何人が訪れて何人が登録したのかを確かめる手段は、そのサービスにも、招待制で運用しているもう 1 つのサービスにも用意していませんでした。
構成はどちらも AWS SAM で組んだサーバーレスで、S3+CloudFront から静的なフロントを配信しています。
裏側は API Gateway+Lambda+DynamoDB で、認証は Cognito です。
アクセスログも、フロント側の計測も入れていない状態でした。
すでにある数字を読むだけで原因の性質が分かり、そのあと追加費用をほとんどかけずに日次の監視を後付けした記録です。
まず今ある数字を読むと、問題の性質が決まる
最初にやったのは、AWS がすでに記録している数字を CLI で読むことでした。
見たのは、CloudFront の日別リクエスト数、Cognito のユーザー一覧、Lambda の呼び出し数とエラー数の 3 つです。
CloudFront のメトリクスの説明にあるとおり、取得には us-east-1 を指定し、ディメンションの Region には常に Global を入れます。
実際に使ったコマンドからの抜粋です(ID はダミーです)。
aws cloudwatch get-metric-statistics --region us-east-1 \
--namespace AWS/CloudFront --metric-name Requests \
--dimensions Name=DistributionId,Value=E2XXXXXXXXXXXX Name=Region,Value=Global \
--start-time 2026-09-07T00:00:00Z --end-time 2026-09-21T00:00:00Z \
--period 86400 --statistics Sum \
--query "sort_by(Datapoints,&Timestamp)[].[Timestamp,Sum]" --output text
aws cognito-idp list-users --user-pool-id ap-northeast-1_XXXXXXXXX \
--query "Users[].[UserCreateDate,UserStatus]" --output text
2 つのサービスを A と B として、14 日分を実機で読んだ結果は次のとおりです。
- A: リクエストは 69〜1,599 件/日、登録ユーザーは私 1 人、Lambda の呼び出しは 14 日で 0 回
- B: リクエストは 61〜819 件/日、招待した 2 人はどちらも FORCE_CHANGE_PASSWORD(初回ログインが済んでいない状態)、Lambda は 273 回でエラー 0
A は画面へのリクエストこそあるものの、API を呼ぶ操作は 14 日間 1 回もありませんでした。
B は壊れていませんが、招待した人が最初のログインまで進んでいません。
どちらも障害ではなく、集客と最初の一歩の問題だったことが、1 回の読み取りで決まりました。
メトリクスでは訪問者数も4xxの中身も分からない
ただし、Requests は bot のアクセスも含んだ件数なので、訪問者数は分かりません。
4xx エラー率も、14 日平均で A が 0.1%、B が 40% と大きく離れていました。
A は SPA のため、CustomErrorResponses で 404 と 403 を 200 に書き換えています。
4xx 率は利用者に返したステータスで数えるので、書き換えた分は入りません。
B は 404 をそのまま返しますが、40% の中身まではメトリクスから読めません。
同じ指標でも配信の設定で見え方が変わるので、訪問者数や見られたページ、4xx の内訳を知るにはアクセスログが要ります。
メトリクスで気づいてログで中身を読む分担は、オブザーバビリティの記事で整理した信号の使い分けと同じです。
CloudFrontの標準ログを後付けする
ログには、CloudFront の標準ログのうち、S3 に出すレガシー方式を選びました。
ディストリビューションに Logging を足すだけで有効にでき、v2 方式のように配信の設定を us-east-1 で別に作らなくて済むからです。
実際に使っているテンプレートを、要点だけに絞った抜粋です。
LogsBucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred # ACL 必須(BucketOwnerEnforced は不可)
LifecycleConfiguration:
Rules:
- Id: expire-logs
Status: Enabled
ExpirationInDays: 60
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
# Origins などほかの設定は省略
Logging:
Bucket: !GetAtt LogsBucket.DomainName # bucket.s3.amazonaws.com
IncludeCookies: false
Prefix: cloudfront/
つまずきやすいのはバケットで、ACL が有効でないと配信されず、BucketOwnerEnforced のバケットは使えません。
ディストリビューションの更新時には CloudFront が ACL を書き換え、ログを書き込む awslogsdelivery アカウントに FULL_CONTROL を与えるとレガシー方式の公式ドキュメントに書かれています。
ファイル名は {prefix}{distributionId}.{YYYY-MM-DD-HH}.{id}.gz の形で、日時は UTC です。
CloudFront はログの有効化に料金をかけず、かかるのは S3 の保存と読み出しの分だけです。
私の規模なら月数円程度と見込んでいますが、実額はまだ確かめていません。
UAだけで数えると探索botが「人」に混ざる
ログは gzip で圧縮したタブ区切りで、#Fields: 行の列名で辞書にすると c-ip や cs(User-Agent) を名前で引けます。
最初は、User-Agent(以下 UA)に bot を示す語があるかどうかだけで人と bot を分けていました。
そのとき書いた判定を、骨格だけに簡略化したものです。
import gzip, io, re
BOT = re.compile(r"bot|crawl|spider|python-requests|curl/|go-http-client|gptbot|claudebot", re.I)
fields, human, bot = [], set(), set()
# body: S3 から取得した .gz のバイト列(取得部分は省略)
with gzip.open(io.BytesIO(body), "rt", encoding="utf-8") as f:
for line in f:
if line.startswith("#Fields:"):
fields = line[8:].split(); continue
if line.startswith("#"): continue
rec = dict(zip(fields, line.rstrip("\n").split("\t")))
ua = rec.get("cs(User-Agent)", "-")
(bot if ua == "-" or BOT.search(ua) else human).add(rec["c-ip"])
print(len(human), len(bot))
数えているのはユニーク IP なので、同じ回線の向こうに何人いても 1 になります。
この「訪問者数」は厳密な人数ではなく、増えたか減ったかを見るための指標です。
ところが、ログが届いた初日の B で「人 2 IP」と出た内訳を見ると、292 件のうち 291 件が 1 つの IP から来ていました。
クラウド事業者の IP 帯から 22 秒の間に、資格情報や設定ファイルを探すリクエストが並んでいたのです。
探された先は /.aws/credentials・/.git/・/.claude/settings.json などで、どれも 404 を返すか応答前に切れていて、実害はありません。
291 件のうち 280 件は、UA が Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 で途切れていました。
残る 11 件は、完全な Chrome の UA を名乗っていました。
どちらも bot を示す語が無いので、正規表現を素通りしたわけです。
そこで判定を 2 段に分けました。
- 1 段目は UA で見る: bot を示す語がある、UA が無い、
Mozilla/で始まらない、Chrome/NN のようなブラウザの製品名と版が無い - 2 段目は IP の振る舞いで見る: ドットファイル(
/.well-known/は除く)・PHP・WordPress 系・管理画面系のパスを 1 件でも踏んだ、または 4xx が 20 件以上かつ半分以上
2 段目に当たった IP は、UA がブラウザを名乗っていても全リクエストを bot に回します。
これで初日の B は人 1・bot 1 になりましたが、残った 1 もトップページを 1 回取っただけの古い Chrome で、bot の可能性が高いものの分け切れていません。
UA は名乗りにすぎないので、人か bot かは振る舞いで決めるほうが確かでした。
メトリクスでは読めなかった B の 4xx の中身も、ログで見えるようになりました。
探索 bot が踏んだ /.env や /xmlrpc.php などへの 404 が中心で、置いていない /robots.txt への 404 も毎日数件あります。
ログを入れた初日の数字は1日分ではない
標準ログのリファレンスによると、ログはふだん 1 時間以内に届きますが、一部は最大 24 時間遅れることがあります。
遅れた分も、リクエストが起きた時刻のファイル名で保存されます。
このため、集計の対象日はレポートを作る日の前日にし、日本時間の 1 日を UTC の前日 15 時台から当日 14 時台までのファイルで組み立てることにしました。
メトリクスも 1 時間単位で取り、率は「率×リクエスト数」で件数に直してから日ごとに足します。
もう 1 つの落とし穴は、ログを有効にした当日でした。
B のログは 22 時台からの 292 行で、同じ日のリクエスト数 403 の 72% にとどまり、A は 23 時台からの 3 行だけでした。
部分的な日の数字を 1 日分として読むと、推移を読み違えます。
対策として、最初のログファイルの時刻と、ログ行数をリクエスト数で割った比率を、レポートに並べて出すようにしました。
ファイル名の日時は辞書順がそのまま時系列順なので、先頭の 1 件を見れば始まりの時刻が分かります。
エラーはログの行とアラームの二段で拾う
エラーは、Logs Insights で [ERROR]・Traceback・Task timed out を含む行を抜き出して、種類ごとに数えています。
B の Lambda のロググループは初回の実行時に自動で作られたもので、保持期間が無期限のままでした。
同じ名前の LogGroup をテンプレートに足すと、「既に存在する」と言われてデプロイが失敗します。
代わりに別名のロググループを作り、Lambda の LoggingConfig で出力先を切り替えました。
保持期間は 14 日、形式は JSON にしています。
アラームは、Lambda の Errors と API の 5xx が 5 分間で 5 件、Lambda の Throttles が 5 分間で 1 件で鳴るようにしています。
HTTP API と REST API はメトリクスの名前とディメンションが違うので、混ぜると値が来ません。
HTTP API のメトリクスは 5xx と ApiId の組み合わせで、REST API は 5XXError と ApiName・Stage です。
通知は SNS のメール購読なので、購読確認のメールに応答するまでは届きません。
アラームが実際に鳴るところは、まだ確かめていません。
日次レポートでは、エラー・リクエストの急減(7 日平均の半分未満)・アラームのどれかがあればスクリプトが印を付け、私は所見だけを読んでいます。
最後に
監視の最初の目的は、「増えたか」を毎日 1 行で知ることでした。
ログが丸 1 日そろった 9 月 22 日から 26 日の 5 日間では、人と判定したユニーク IP は A が 1 日 6〜13、B が 12〜19 でした。
数字が毎日そろえば、SLI と SLO で「何をもって使えているとするか」を決める土台になります。
次の候補は、S3 以外にも出力できる標準ログの v2 方式や、ブラウザ側で利用者の体験を測る CloudWatch RUM です。
計測を足す前に今ある数字を 1 回読むだけで、障害なのか集客なのかが決まります。
以上です。










コメントを残す