概要 #
Cloud Armor で送信元を絞ったとき、拒否されたリクエストはどこまで来ているのか。 バックエンドのログに出ないのは「届いていないから」なのか、「出していないだけ」なのか。
見えないものを「無い」と決めるのは危ないので、クエリ文字列に識別子を入れたリクエストを送って確かめます。同じ1回のリクエストを、クライアント・LBのログ・バックエンドのログの3か所で追えます。
あわせて、プレビューの動作も確かめます。ルールを有効にする前に「もし有効だったら何が止まるか」を見る仕組みです。
プレビューにしたルールは判定するだけで、実際には止めません。判定はログと専用メトリクスに残ります。
3つの状態で、応答・ログ・メトリクスの見え方を並べます。
Google Cloud のロードバランサを触ったことがある人向けです。構成の作り方は前々回で扱っており、ここでは Armor の差分だけを見ます。WAF ルールやレート制限は扱いません。
検証環境 #
- Terraform v1.14.3、
hashicorp/googlev7.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件だった