↓メインコンテンツへスキップ
  1. Blogs/

Cloud Armorで送信元IPを拒否したら、リクエストはバックエンドに届かなかった

5 分
Terraform GoogleCloud LoadBalancing CloudArmor CloudLogging
0222-nnn
著者
0222-nnn
猫が好き
目次
Terraform-GoogleCloud - この記事は連載の一部です
パート 44: この記事

概要
#

Cloud Armor で送信元を絞ったとき、拒否されたリクエストはどこまで来ているのか。 バックエンドのログに出ないのは「届いていないから」なのか、「出していないだけ」なのか。

見えないものを「無い」と決めるのは危ないので、クエリ文字列に識別子を入れたリクエストを送って確かめます。同じ1回のリクエストを、クライアント・LBのログ・バックエンドのログの3か所で追えます。

あわせて、プレビューの動作も確かめます。ルールを有効にする前に「もし有効だったら何が止まるか」を見る仕組みです。

プレビューにしたルールは判定するだけで、実際には止めません。判定はログと専用メトリクスに残ります。

3つの状態で、応答・ログ・メトリクスの見え方を並べます。

Google Cloud のロードバランサを触ったことがある人向けです。構成の作り方は前々回で扱っており、ここでは Armor の差分だけを見ます。WAF ルールやレート制限は扱いません。

検証環境
#

  • Terraform v1.14.3、hashicorp/google v7.46.0
  • Google Cloud SDK 562.0.0

使用するコードは Advanced-Examples/09-regional-alb-cloud-armor です。

cd Advanced-Examples/09-regional-alb-cloud-armor
cp terraform.tfvars.example terraform.tfvars
# project_id / allowed_src_ips(自分のグローバルIPを /32 で)/ iap_member を書く
terraform init
terraform apply
terraform apply   # 2回目。理由は後述

create_deny_client = true にすると、許可リストに載っていない送信元として使える検証用 VM も作られます。access_config {} でエフェメラルな外部IPが付くので、そのIPで出ていきます。

apply は2回必要です。1回目でリソースは揃いますが、ポリシーの紐付けだけが残ります。2回目がこれです。

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

理由は設定が効いているかの確認で扱います。

ルールの構成
#

2本だけです。どちらの送信元も同じ VIP に入り、分かれるのは Armor の中です。

flowchart LR
    OK["許可リスト内
自分のグローバルIP"] --> FR["転送ルール(VIP)"] NG["拒否クライアントVM
エフェメラル外部IP"] --> FR FR --> AR{"Cloud Armor
tf-adv-elb09-armor"} AR -->|"p1000 allow"| BS["バックエンドサービス"] AR -->|"p2147483647 deny(403)"| X["403 を返して終わり"] BS --> VM["バックエンドVM
python3 -m http.server
/var/log/http.log"]

403 の矢印はバックエンドVMに届いていません。 それを推測で終わらせないのが、この記事の残りです。

ポリシーのリソースの中に書きます。以下は name と region を省いた抜粋です。

resource "google_compute_region_security_policy" "armor" {
  type = "CLOUD_ARMOR"

  rules {
    action   = "allow"
    priority = 1000
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = var.allowed_src_ips }
    }
  }

  rules {
    action   = "deny(403)"
    priority = 2147483647
    match {
      versioned_expr = "SRC_IPS_V1"
      config { src_ip_ranges = ["*"] }
    }
  }
}

2147483647 は既定ルールの優先度です。数が大きいほど後に評価されるので、どれにも当たらなかったものがここに落ちます。

既定ルールは省略できません。 正確には、省略すると別のものが作られます。

“There must always be a default rule (rule with priority 2147483647 and match “*”). If no rules are provided when creating a security policy, a default rule with action “allow” will be added.” — google_compute_region_security_policy

拒否を既定にしたい構成で書き忘れると、既定が許可になります。 google_compute_region_security_policy_rule を別リソースとして書く方法もありますが、既定ルールが離れた場所に置かれるぶん、書き忘れに気づきにくくなります。

応答ヘッダの違い
#

許可リスト内から。

$ curl -si http://VIP/
HTTP/1.1 200 OK
server: SimpleHTTP/0.6 Python/3.11.2
date: Fri, 11 Sep 2026 10:40:24 GMT
content-type: text/html

許可リスト外(拒否クライアント)から。

$ curl -si http://VIP/
HTTP/1.1 403 Forbidden
content-type: text/html; charset=UTF-8
content-length: 134
date: Fri, 11 Sep 2026 10:40:00 GMT
via: 1.1 google

<!doctype html>...<title>403</title>403 Forbidden

server: SimpleHTTP/0.6 Python/3.11.2 が 403 にはありません。 200 のほうはバックエンドの Python が名乗っており、今回返った 403 は Google 側の定型ページでした。

ただしServer ヘッダを出すかは任意なので、これは手がかりであって判定ではありません。 Armor が止めたかどうかは、次のログで確かめます。

同じリクエストを3か所で追う
#

応答から推測できますが、同じリクエストを3か所で突き合わせます。?probe= に一意の文字列を入れ、応答コードも記録しておきます。

この文字列はアクセスログにもLBのログにもそのまま残るので、grep で1件に絞れます。

$ curl -sS -o /dev/null -w '%{http_code}\n' "http://VIP/?probe=ALLOWED-MARK2"
200
$ # 拒否クライアントから
$ curl -sS -o /dev/null -w '%{http_code}\n' "http://VIP/?probe=DENIED-MARK2"
403

LBのログを、その文字列で引きます。

gcloud logging read 'logName=~"external_regional_requests" AND httpRequest.requestUrl:"MARK2"' \
  --freshness=15m --format=json
probe=DENIED-MARK2    status=403 outcome=DENY    priority=2147483647
probe=ALLOWED-MARK2   status=200 outcome=ACCEPT  priority=1000

バックエンドは python3 -m http.server で、アクセスログが /var/log/http.log に出ます。

$ sudo grep -c 'ALLOWED-MARK2' /var/log/http.log
1
$ sudo grep -c 'DENIED-MARK2' /var/log/http.log
0

3つを並べます。

probe の値 クライアント LBのログ バックエンド
ALLOWED-MARK2 200 ACCEPT / p1000 1件
DENIED-MARK2 403 DENY / p2147483647 0件

同じリクエストについて、Armor が拒否し、バックエンドのアクセスログには記録されていません。 片方だけでは「出していないだけ」の可能性を消せません。

sequenceDiagram
    participant C as クライアント
    participant AR as Cloud Armor
    participant LG as LBのログ
    participant BE as バックエンド

    C->>AR: GET /?probe=ALLOWED-MARK2
    AR->>BE: p1000 allow に一致
    BE-->>C: 200
    AR-->>LG: ACCEPT / p1000
    Note over BE: http.log に1件

    C->>AR: GET /?probe=DENIED-MARK2
    AR--xBE: p2147483647 deny(403)
    AR-->>C: 403
    AR-->>LG: DENY / p2147483647
    Note over BE: http.log に0件

2本目の --x が、この記事で確かめたかった1本です。3か所を同じ識別子で引けたので、0件が「届いていない」だと言えます。

これはバックエンドの負荷という点でも意味があります。この送信元IPルールに一致したリクエストは、バックエンドへ転送されないからです。ただし大量に送られたときの挙動や負荷の変化は、今回の検証に含めていません。

もうひとつ、優先度1000の許可に一致した時点で判定が決まります。つまり許可リスト内からの攻撃は、この構成では素通りです。WAF やレート制限を併用するなら、1000 より小さい優先度に置くかどうかを決める必要があります。

ログには両方残る
#

リージョン外部 ALB のログには、判定した結果が入ります。バックエンドサービスでログを有効にしている前提です。

log_config {
  enable      = true
  sample_rate = 1.0
}

sample_rate を下げた環境では、ログの件数を全リクエスト数として扱えません。

gcloud logging read 'logName=~"external_regional_requests"' --freshness=10m --format=json
 200  src=MY_GLOBAL_IP   enforced=tf-adv-elb09-armor/ACCEPT/p1000
 403  src=35.200.19.47   enforced=tf-adv-elb09-armor/DENY/p2147483647

jsonPayload.enforcedSecurityPolicy に、ポリシー名・結果・当たったルールの優先度が入ります。403 は優先度 2147483647、つまり既定ルールで落ちたと分かります。

35.200.19.47 は拒否クライアントに付いたエフェメラル外部IPです。Armor が見るのは、そのリクエストが実際に出ていったときの送信元IPなので、VMの内部IP(10.81.0.3)ではありません。

外部IPを持たないVMなら、設定済みの Public NAT を経由します。その場合の送信元はNATの外部IPです。

その外部IPを許可すると、同じIPに変換される他のVMも同じ許可ルールに当たります。 今回はこの経路を実測していません。

プレビュー: 止める前に何が止まるかを見る
#

いきなり有効にすると、意図しないものまで落ちます。プレビューにすると、判定はするが実際には止めません。

既定ルールはプレビューにできない
#

まず既定ルールをプレビューにしようとすると、エラーになります。

$ gcloud compute security-policies rules update 2147483647 \
    --security-policy=tf-adv-elb09-armor --region=asia-northeast1 --preview
ERROR: (gcloud.compute.security-policies.rules.update) Could not fetch resource:
 - Invalid value for field 'resource.preview': 'true'. Cannot preview the default rule,
   consider creating another rule that matches all to simulate the default rule.

全トラフィックに一致する別ルールで代替するよう案内されます。

許可より前に、プレビューの拒否を置く
#

自分のIPを拒否するルールを、許可(1000)より前の 900 に入れます。

$ gcloud compute security-policies rules create 900 \
    --security-policy=tf-adv-elb09-armor --region=asia-northeast1 \
    --src-ip-ranges="MY_GLOBAL_IP/32" --action="deny-403" --preview
優先度 900          deny(403)  preview=True   src=['MY_GLOBAL_IP/32']
優先度 1000         allow      preview=False  src=['MY_GLOBAL_IP/32']
優先度 2147483647   deny(403)  preview=False  src=['*']

20秒おきに送り続けます。

  20秒: HTTP 200
  ...
 200秒: HTTP 200

応答は 200 のままです。ログを見ると違いが出ています。

 200 src=MY_GLOBAL_IP  enforced=ACCEPT/p1000  preview=-/p-       ?probe=PREVIEW-TEST-7
 200 src=MY_GLOBAL_IP  enforced=ACCEPT/p1000  preview=DENY/p900  ?probe=PREVIEW-TEST-8
 200 src=MY_GLOBAL_IP  enforced=ACCEPT/p1000  preview=DENY/p900  ?probe=PREVIEW-TEST-10

enforcedSecurityPolicy は ACCEPT/p1000(実際に適用されたのは許可)、previewSecurityPolicy は DENY/p900(有効にしていたら止めていた)。

200 が返るのは、プレビューの拒否が適用されないからです。公式ドキュメントは、プレビューに一致しても Cloud Armor は他のルールの評価を続ける、と書いています。

今回は優先度900の判定を記録したまま評価が進み、優先度1000の許可が適用されて 200 になりました。プレビューだから 200 なのではありません。後続で適用されたルールの結果です。

このルールを有効にしたら何件が拒否されるかを、実際には拒否せずに見積もれます。

ただし、この900は gcloud で足したもので Terraform の管理外です。terraform plan はこれを検出しません。

$ terraform plan
No changes. Your infrastructure matches the configuration.

$ terraform plan -refresh-only
Note: Objects have changed outside of Terraform
      + priority = 900

state には2本しか無く、実機には3本あります。それでも No changes です。

試したあと消し忘れても、通常の plan では気づけません。 -refresh-only で見るか、gcloud 側で確かめます。

反映には時間がかかる
#

20秒間隔の観測では、TEST-7 に previewSecurityPolicy がなく、TEST-8 で初めて確認できました。最初に反映を確認したのは約160秒後です。 実際に切り替わった時刻を測ったわけではありません。

拒否ルールを有効にしたときも同じでした。ポリシーを紐付けた直後は 200 が返り、しばらく経ってから 403 になります。ここは3回測って 90秒 / 46秒 / 45秒 でした。

これらは観測値で、保証された反映時間ではありません。 3回で倍近く振れているので、待ち時間として当てにする数字でもありません。ただ「設定したのに効かない」と判断する前に待つ必要があることは分かります。

メトリクスは Armor 側と LB 側にある
#

Cloud Armor 専用のメトリクス
#

許可・拒否・プレビューが、そのまま数えられます。

networksecurity.googleapis.com/https/request_count
networksecurity.googleapis.com/https/previewed_request_count
  resource.type = network_security_policy
  labels        = backend_target_name, blocked

直近3時間を ALIGN_DELTA で1区間にまとめた値です。

request_count           blocked=false  13件
request_count           blocked=true   15件
previewed_request_count blocked=true    4件

request_count の blocked=true が実際に拒否した数、previewed_request_count の blocked=true がプレビュー中のルールを有効にしていたら拒否されていた数です。本番に入れる前の影響範囲を、ここで見積もれます。

この2本は排他ではありません。公式ドキュメントは、プレビューの件数は通常の件数にも含まれる、と書いています。

つまりプレビューに一致したリクエストは、request_count の blocked=false と previewed_request_count の blocked=true の両方に乗ります。「実際にどうなったか」と「有効にしていたらどうなったか」が、別々に記録されます。

探し方に注意が要ります。メトリクス名に armor や securityPolicy は入っておらず、networksecurity.googleapis.com/https/ で始まります。

名前で検索すると「無い」と誤判定します。

ロードバランサ側のメトリクス
#

応答コード別に見たいときはこちらです。

loadbalancing.googleapis.com/https/external/regional/request_count
  resource.type = http_external_regional_lb_rule
  labels        = cache_result, client_country, protocol, response_code, response_code_class
response_code=200: 18 件
response_code=403: 14 件

グローバル用の名前では0件になります。 loadbalancing.googleapis.com/https/request_count は0件でした。リージョンは https/external/regional/ から始まります。

こちらの 403 は、アプリが返した 403 とも混ざります。 Armor が止めた数を見たいなら、上の blocked=true を使います。

backend_request_count では区別できません
#

ロードバランサ側には backend_request_count もあります。公式の定義はこうです。

The number of requests served by backends of Regional External HTTP(S) load balancer.

名前と定義からは「バックエンドまで届いた数」に読めます。拒否分が除かれるなら、request_count との差で止めた数が分かるはずです。

そうなりませんでした。 拒否だけを20回送り、その窓で両方を取りました。

request_count           response_code=403   21
backend_request_count   response_code=403   21

同じ値です。そして同じ文字列をバックエンドのアクセスログで数えると、こうなります。

$ sudo grep -c "ONLYDENY" /var/log/http.log
0

1件も届いていないものを、21件数えています。 この指標は「バックエンドに到達した数」ではありません。到達の有無を見たいなら、この記事でやったようにバックエンド側のログを直接見ます。

公式の定義文は、拒否されたリクエストを除くとは書いていません。名前から動作を推測すると間違えます。 ただしこれは今回の構成での実測で、仕様としての断定ではありません。

3つの状態のまとめ
#

クライアントの応答 バックエンドのログ LBのログ Armorのメトリクス
許可 200 残る enforced=ACCEPT/p1000 request_count blocked=false
拒否 403 残らない enforced=DENY/p2147483647 request_count blocked=true
プレビュー 通常の処理結果(今回は 200) 残る enforced=ACCEPT + preview=DENY request_count blocked=false と previewed_request_count blocked=true

応答だけを見ても、そのリクエストがプレビューのルールに一致したかは分かりません。 プレビューの拒否は適用されず、後続のルールの結果がそのまま返るためです。判別できるのはログと、専用メトリクスです。

設定が効いているかの確認
#

「ルールを書いた」と「効いている」は別です。terraform apply のあと、ポリシーがバックエンドサービスに紐付いているかを見ます。

$ gcloud compute backend-services describe tf-adv-elb09-bs --region=asia-northeast1 \
    --format="value(securityPolicy.basename())"
tf-adv-elb09-armor

ここが空なら、ルールをいくら書いても素通りします。あわせて terraform plan に差分がないことも見ます。

$ terraform plan -detailed-exitcode
No changes. Your infrastructure matches the configuration.   # exit 0

apply は2回必要でした
#

1回目の apply では、ポリシーが紐付きません。 コードに security_policy = ... と書いていてもです。作り直して1回だけ apply した直後がこれです。

$ gcloud compute backend-services describe tf-adv-elb09-bs --region=asia-northeast1 \
    --format="value(securityPolicy)"
(空)

$ terraform plan -detailed-exitcode
  # google_compute_region_backend_service.bs will be updated in-place
      + security_policy = ".../securityPolicies/tf-adv-elb09-armor"
Plan: 0 to add, 1 to change, 0 to destroy.

原因は監査ログに出ていました。

$ gcloud logging read 'protoPayload.resourceName=~"tf-adv-elb09-bs"' \
    --format="value(timestamp,protoPayload.methodName)"
00:27:54  v1.compute.regionBackendServices.insert
00:27:38  v1.compute.regionBackendServices.insert

setSecurityPolicy が1件もありません。 プロバイダは作成時にこの呼び出しを送っていません。2回目の apply で初めて出ます。

00:05:28  v1.compute.regionBackendServices.setSecurityPolicy
00:05:15  v1.compute.regionBackendServices.update

ルールをインラインで書いても、別リソースで書いても同じでした。

紐付いていないと、許可リスト外からも 200 が返ります。 ルールは正しく作られているので、ポリシー側を見ても気づけません。apply のあとは、上の describe か terraform plan で確かめてください。

なお type = "CLOUD_ARMOR" を書かないと、ポリシーが毎回置き換えの対象になります。これは別の問題です。

以前この記事は、その置き換えが紐付けの原因だと書いていました。測り直して誤りと分かったので訂正します。

後片付け
#

$ terraform destroy

外部VIP・Cloud NAT・VM2台は、動いている間ずっと時間課金されます。 検証が終わったら消します。

まとめ
#

  • 今回の送信元IPルールで拒否されたリクエストは、バックエンドのアクセスログに残らなかった。 クエリの識別子で同じリクエストを追って確かめた。403 という応答コードだけでは、到達の有無は判断できない
  • 応答ヘッダは手がかりになる。 200 には server: SimpleHTTP/... があり、403 にはない。ただし Server は任意なので判定には使えない
  • enforcedSecurityPolicy にポリシー名・結果・優先度が入る。 2147483647 は既定ルール
  • 既定ルールはプレビューにできない。 全一致の別ルールを作る。gcloud で足したそのルールは、通常の terraform plan では検出されない
  • プレビューの拒否は適用されない。 判定はログの previewSecurityPolicy と、専用メトリクスの previewed_request_count に残る。応答は後続で適用されたルールの結果(今回は 200)
  • 反映に時間がかかる。 プレビューの反映を確認できたのが約160秒後。紐付け後に 403 へ変わるまでは3回測って 90秒 / 46秒 / 45秒。観測値であって、保証された反映時間ではない
  • Armor 専用のメトリクスがある。 networksecurity.googleapis.com/https/request_count の blocked ラベル。プレビューは previewed_request_count
  • 名前に armor は入っていない。 名前で検索すると「無い」と誤判定する
  • Armor が見るのは、出ていったときの送信元IP。 今回の拒否クライアントは自分の外部IPを持っており、内部IPではない
  • 1回目の apply ではポリシーが紐付かなかった。 監査ログに setSecurityPolicy が出ず、2回目で出た。紐付いていないと許可リスト外からも 200 が返る
  • 既定ルールを書かないと allow で作られる。 ポリシーのリソース内に rules を並べると、既定ルールが同じ場所に来る
  • backend_request_count では到達の有無を区別できなかった。 拒否だけ20回の窓で request_count と同じ21件。バックエンドのログは0件だった

参考資料
#

Terraform-GoogleCloud - この記事は連載の一部です
パート 44: この記事

関連記事

proxy-onlyサブネットを塞ぐと、リージョンALBはHEALTHYのまま504を返した
4 分
Terraform GoogleCloud LoadBalancing CloudLogging
draining=300のままNEGからバックエンドを外したら、gcloudが320秒終わらなかった
4 分
Terraform GoogleCloud LoadBalancing
GKEでPodの退避を起こしたら、監視に必要なメトリクスがCloud Monitoringに来なかった
8 分
Terraform GoogleCloud GKE Kubernetes CloudLogging CloudMonitoring
ドメイン認証がFAILEDになった9分後にAUTHORIZEDへ戻り、証明書はACTIVEになった
4 分
Terraform GoogleCloud LoadBalancing CertificateManager CloudDNS
terraform applyが成功してもVMの中は設定されていなかった話
4 分
Terraform GoogleCloud Ansible ComputeEngine CloudLogging
標準出力に1行JSONを書くだけなら、GKEではFluentdサイドカーが要らなかった
2 分
Terraform GoogleCloud GKE CloudLogging Fluentd