概要 #
ログイン状態をサーバ側のメモリに置いている。ロードバランサが毎回違うバックエンドへ振ると、数リクエストごとにログアウトする。同じクライアントを同じバックエンドへ送りたい。
Google Cloud のロードバランサには session_affinity があります。リージョン外部ALBで選べるのは6種類です。
NONE / CLIENT_IP / GENERATED_COOKIE / HEADER_FIELD / HTTP_COOKIE / STRONG_COOKIE_AFFINITY
この記事で比べるのは Cookie を使う2つです。違いは「誰が発行するか」ではありません。
GENERATED_COOKIE はLBが発行した Cookie を使います。一方 HTTP_COOKIE が使うのは、名前を指定した Cookie の値です。その名前の Cookie が無ければLBが生成します。
後者を使うとき、こう書きたくなります。
self.send_header("Set-Cookie", "ROUTE=backend-a; Path=/")
これはバックエンドの指定になりません。 固定はされますが、backend-a という綴りがバックエンド backend-a を指すわけではありません。実測では、ROUTE=backend-a を送ったリクエストが10回とも backend-a2 に届きました。
あわせて、固定が外れる条件と、効いていることをどこで確かめられるかも測ります。後者は予想と違いました。
Google Cloud のロードバランサを触ったことがある人向けです。構成の作り方はリージョンALBの回で扱っており、ここではアフィニティの差分だけを見ます。gRPC やステートフルなセッション管理の設計は扱いません。
検証環境 #
- Terraform v1.14.3、
hashicorp/googlev7.44.0 - Google Cloud SDK 562.0.0、curl 7.81.0
- 初回検証は2026年9月13日、再検証は2026年9月19日、いずれも
asia-northeast1
初回・再検証は別デプロイです。AFFLOG の Cookie 関連の記録(6件のログなど)は9月13日、request_count・backend_request_count の94件/30件を含む指標系の集計は9月19日の再検証結果です。断りのない数値は、それぞれの取得時点のデプロイでの集計としてください。Cookie の対応だけは、作り直す前と後の2回ぶんを並べています。
Cookie の値と VIP は、今回の2回のデプロイではいずれも異なりました。毎回必ず変わる保証があるわけではありません。 同じ値はそのままでは再現しない前提で読んでください。
ハッシュの対応も変わりましたが、これも必ず変わるとまでは確かめていません(後述)。手順は各節のコマンドで追えるようにしてあります。
保存記録の範囲について。 9月19日の再検証分は、コマンドの標準出力を terraform-gcp-examples/.local-logs/reverify-* にそのまま残しており、本文の集計値と突き合わせられます。9月13日の初回検証分(Set-Cookie の全文、plan/apply/destroy の出力、当時の残存確認など)は、この保存の習慣を厳密にする前のもので、作業時のメモ以上には独立して照合できません。両者を区別せずに読めるような書き方はしていません。
使用するコードはこちらです。
cd Advanced-Examples/10-regional-alb-session-affinity
cp terraform.tfvars.example terraform.tfvars
# project_id と iap_member を書く
terraform init
terraform apply
Apply complete! Resources: 28 added, 0 changed, 0 destroyed.
バックエンドは python3 の簡易サーバで、自分の名前(backend-a / backend-a2)を返します。あわせて ROUTE=<自分の名前> という Cookie も付けます。
構成 #
同じ NEG の2台に対して、バックエンドサービスだけを2つ用意しています。
flowchart LR
C["クライアント"] -->|":81"| BS1["bs-gclb
GENERATED_COOKIE"]
C -->|":83"| BS2["bs-route
HTTP_COOKIE + RING_HASH
Cookie名 ROUTE"]
BS1 --> NEG["neg(ゾーンa)"]
BS2 --> NEG
NEG --> A["backend-a
10.82.0.2:8080"]
NEG --> A2["backend-a2
10.82.0.3:8080"]
VIP は1つで、ポートで分かれます。バックエンドは同じ2台ですが、session_affinity と実効の locality_lb_policy の2つが違います。
# :81 — LB が Cookie を発行する
session_affinity = "GENERATED_COOKIE"
affinity_cookie_ttl_sec = 3600
# :83 — アプリの Cookie を使う
session_affinity = "HTTP_COOKIE"
affinity_cookie_ttl_sec = 3600
locality_lb_policy = "RING_HASH"
consistent_hash {
http_cookie {
name = "ROUTE"
path = "/"
ttl { seconds = 3600 }
}
}
どちらも locality_lb_policy に条件が付きます。 RING_HASH か MAGLEV のどちらかです。
For the
localityLbPolicyof the backend service, use eitherRING_HASHorMAGLEV. If you don’t explicitly set thelocalityLbPolicy, the load balancer usesMAGLEVas an implied default.
:81 は指定していないので暗黙の MAGLEV、:83 は明示した RING_HASH です。この差も結果に効きうるので、「session_affinity だけを変えた比較」ではありません。
サンプルに恒常差分があった #
測り始める前に1つ見つかりました。terraform apply を2回目に打つと、こうなります。
$ terraform apply
Apply complete! Resources: 2 added, 0 changed, 2 destroyed.
$ terraform plan -detailed-exitcode
(終了コード 2)
plan を読むと理由が出ます。
# google_compute_network_endpoint.ep["a"] must be replaced
~ instance = "tf-adv-elb10-a"
-> "https://www.googleapis.com/compute/v1/projects/YOUR_PROJECT_ID/zones/asia-northeast1-a/instances/tf-adv-elb10-a"
# forces replacement
instance に self_link を渡していました。 state には短い名前で入るため、設定側の self link と比べて毎回差分になり、NEGのエンドポイントが2つとも作り直されます。API 一般が名前だけを返すという意味ではありません。 provider v7.44.0 での挙動です。前に別のサンプルで踏んだのと同じ形です。
- instance = google_compute_instance.be[each.key].self_link
+ instance = google_compute_instance.be[each.key].name
$ terraform plan -detailed-exitcode
(終了コード 0)
この差分を抱えたまま測ると、結果が壊れます。 実際、直す前に get-health を見たとき、エンドポイントが入れ替わっている最中で1台しか登録されていませんでした。
tf-adv-elb10-a2 10.82.0.3 HEALTHY ← a が居ない
直したあとは2台とも揃います。
tf-adv-elb10-a 10.82.0.2 HEALTHY
tf-adv-elb10-a2 10.82.0.3 HEALTHY
Cookie は GCLB ではなく GCILB だった #
:81 に初めてリクエストすると、Set-Cookie が2つ返ります。
$ curl -si http://VIP:81/
HTTP/1.1 200 OK
set-cookie: ROUTE=backend-a2; Path=/; Max-Age=3600
set-cookie: GCILB="3b368b63947101f2"; Max-Age=3600; Path=/; HttpOnly
上がアプリ、下がLBのものです。名前は GCILB でした。
公式の説明では、リージョン外部・内部が GCILB、グローバルとクラシックが GCLB です。ロードバランサの種類で名前が変わります。
Max-Age=3600 は affinity_cookie_ttl_sec = 3600 と一致しました。
:83 はもう一段ややこしくなります。
$ curl -si http://VIP:83/
set-cookie: ROUTE=backend-a; Path=/; Max-Age=3600
set-cookie: ROUTE="068caf7b30e3bdb0"; Max-Age=3600; Path=/; HttpOnly
同じ名前の Cookie が2つ返っています。 http_cookie.name = "ROUTE" と書いたので、LBがアプリと同じ名前で自分の値を付けました。
LBが自分の値を発行するのは、リクエストに対象の Cookie が無いときだけです。 API仕様にそう書かれています。今回は初回リクエストに ROUTE を送っていないので、LBが生成しました。名前・ホスト・Path が同じなので、Cookie ストアにはあとに届いたほうだけが残ります。
初回と2回目以降では、Cookie ストアに残る値の決まり方が変わります。 初回はまだ ROUTE を送っていないのでLBが値を生成しますが、2回目以降はすでに ROUTE を送っているためLBは生成せず、アプリが返した値に更新されます。curl -b -c で同じ jar を持ち回り、毎回の値と応答を記録しました。
1回目 jarのROUTE="959c97f320b58494" 応答=backend-a
2回目 jarのROUTE=backend-a 応答=backend-a
3回目 jarのROUTE=backend-a2 応答=backend-a2
4回目 jarのROUTE=backend-a2 応答=backend-a2
5回目 jarのROUTE=backend-a2 応答=backend-a2
6回目 jarのROUTE=backend-a2 応答=backend-a2
1回目はLBの値、2回目はアプリの値が残りました。そして4回目以降は動きません。 backend-a2 という値が backend-a2 に向くので、応答のたびに同じ値が書き戻されます。
このアプリは毎回 ROUTE を返すので、収束先はアプリの値のほうでした。アプリが Cookie を返さない構成では違う動きになります。 今回は試していません。
どちらの Cookie が効いているのか #
2つ返るなら、固定しているのはどちらか。片方ずつ送って切り分けます。
まず :81(GENERATED_COOKIE)。
VIP=$(terraform output -raw vip)
JAR=$(mktemp)
curl -s -c "$JAR" "http://$VIP:81/" >/dev/null
GC=$(grep GCILB "$JAR" | awk '{print $7}') # 例: "290094c6a2f6343f"
# LB の Cookie だけを送る
for i in $(seq 1 10); do curl -s -H "Cookie: GCILB=$GC" "http://$VIP:81/"; echo; done | sort | uniq -c
10 backend-a
# アプリの Cookie だけを送る
for i in $(seq 1 5); do curl -s -H "Cookie: ROUTE=backend-a" "http://$VIP:81/"; echo; done | sort | uniq -c
3 backend-a
2 backend-a2
GENERATED_COOKIE はアプリの Cookie を見ていません。 効いているのは GCILB だけです。
値はハッシュされるだけで、名前ではない #
:83(HTTP_COOKIE)が本題です。3通り送りました。
| 送った Cookie | 10回の応答 |
|---|---|
ROUTE="6ac770cdb82b7c22"(LBが付けた値) |
backend-a ×10 |
ROUTE=backend-a(アプリが付けた値) |
backend-a2 ×10 |
ROUTE=backend-a2 |
backend-a2 ×10 |
固定はされます。どれも10回とも同じ先です。ところが backend-a と書いた Cookie が backend-a2 に向きました。
backend-a と backend-a2 が同じ先に落ちています。 違う文字列が必ず別のバックエンドになるわけでもありません。
この対応は、このデプロイのものです。 一度 destroy して作り直したところ、ROUTE=backend-a2 の行き先が backend-a から backend-a2 に変わりました。
ただし変えたのは構成全体なので、何が効いたかは切り分けていません。エンドポイントの入れ替えか、VIP か、別の要因か。作り直せば必ず変わる、とも言えません。
HTTP_COOKIE が見るのは値そのものです。RING_HASH はその値をハッシュしてリング上の位置を決め、そこから時計回りに最初に見つかるバックエンドを選びます。
値が何を意味するかは解釈されません。 ハッシュの入力になるだけで、backend-a という綴りとバックエンド backend-a のあいだに関係はありません。
違う文字列が違う行き先になるとは限りません。実測でも backend-a と backend-a2 が同じ先に落ちました。
sequenceDiagram
participant C as クライアント
participant LB as ロードバランサ
participant A as backend-a
participant A2 as backend-a2
C->>LB: GET / (Cookie なし)
LB->>A: 対象の Cookie が無い(選び方は未確認)
A-->>C: Set-Cookie: ROUTE=backend-a(アプリ)
LB-->>C: Set-Cookie: ROUTE="068caf..."(LB が同名で上書き)
Note over C: Cookie ストアには LB の値だけが残る
C->>LB: GET / (Cookie: ROUTE="068caf...")
LB->>LB: 値をハッシュ → リング上の位置
LB->>A: 同じ位置なので同じ先
アプリ側で「どのバックエンドか」を Cookie に書いても、行き先を指定したことにはなりません。 同じ値なら同じ先に行きやすい、というだけです。
同じ値を送れば必ず同じ先、でもありません。後述のとおり、その先が落ちれば別のバックエンドへ流れます。
別の構成では、たまたま同じ名前のバックエンドに対応することもあります。綴りと行き先が一致して見えても偶然です。
http_cookie.name をアプリと別の名前にすれば両方残ります。そのときLBが見るのは指定した名前のほうだけです。
Cookie が無いとどうなるか #
30回ずつ、Cookie を送らずにリクエストしました。
| ポート | backend-a |
backend-a2 |
|---|---|---|
:81 |
15 | 15 |
:83 |
18 | 12 |
固定されません。 :83 も、対象の Cookie が無ければ両方から応答がありました。
この集計から内部の選び方までは言えません。 HTTP_COOKIE は対象 Cookie が無ければLBが生成します。接続を再利用した場合の行き先も測っていません。
30回で 15/15、18/12 に割れたことを分散の仕様として読まないでください。1回の観測です。
固定が外れる条件 #
公式は「保証」と書いていません。
Session affinity provides a best-effort attempt to send requests from a particular client to the same backend as long as the number of healthy backend instances or endpoints remains constant. — Request distribution for external Application Load Balancers
今回扱う2方式では、選択先の削除・不健康化、所属グループの容量不足、構成されたバックエンド数の変化によって固定が外れ得ます。加えて健康なバックエンド数の変化も条件に入ります。ここには選択先以外のバックエンドが不健康になったり復旧したりする場合も含まれます(Losing session affinity)。
ここでは、選択先のサーバを止めるケースを扱います。GCILB で backend-a に固定したあと、その backend-a のサーバを止めます。
timestamp・送信Cookie・HTTPステータス・curlの終了コード・応答本文・ヘルスチェックの結果は、停止前後を通して1つのローカルログ(.local-logs/reverify2-failover.log)に残しました。このログはコミットしないため、記事末尾の再現手順で同じ形の記録を作れます。
VM には IAP 経由で入ります。
gcloud compute ssh tf-adv-elb10-a --zone=asia-northeast1-a --tunnel-through-iap \
--command='sudo pkill -f server.py'
停止前は両方 HEALTHY でした。
$ gcloud compute backend-services get-health tf-adv-elb10-bs-gclb --region=asia-northeast1
tf-adv-elb10-a2 HEALTHY
tf-adv-elb10-a HEALTHY
停止コマンドの実行後、tf-adv-elb10-a が UNHEALTHY になるまでポーリングしました(2回目のポーリング、約12秒後に確認)。
tf-adv-elb10-a2 HEALTHY
tf-adv-elb10-a UNHEALTHY
UNHEALTHY を確認したあとで、同じ GCILB Cookie を使って20回リクエストしました。20回とも 200 で、応答は20回とも backend-a2(生存側)でした。失敗(200 以外またはcurlの非0終了)は0回です。
次に tf-adv-elb10-a を復旧させます。リダイレクトを sudo の内側に入れないと権限で失敗します。
gcloud compute ssh tf-adv-elb10-a --zone=asia-northeast1-a --tunnel-through-iap \
--command='sudo bash -c "nohup python3 /var/www/server.py > /var/log/http.log 2>&1 & disown"'
HEALTHY に戻るまでポーリングしたあと(1回目のポーリング、約13秒後に確認)、同じ Cookie でもう一度20回送りました。20回とも 200、応答は20回とも backend-a(元のバックエンド)でした。
元に戻りました。
今回の観測では、同じ GCILB を再送すると、UNHEALTHY確認後は生存側へ、HEALTHY復旧確認後は元のバックエンドへ、それぞれ失敗0件で届きました。復旧時に必ず元へ戻るという保証ではありません。
公式は Cookie の値からバックエンドを参照する対応づけとして説明しています。ただしこの実験だけでは、Cookie の内部の持ち方までは分かりません。 試したのは1つの Cookie で、各20回でした。
sequenceDiagram
participant C as クライアント
participant LB as ロードバランサ
participant A as backend-a
participant A2 as backend-a2
C->>LB: Cookie: GCILB=4b41...
LB->>A: 固定
Note over A: サーバを止める → UNHEALTHY
C->>LB: 同じ Cookie
LB->>A2: 20回とも a2(失敗0回)
Note over A: 復旧 → HEALTHY
C->>LB: 同じ Cookie
LB->>A: 20回とも a に戻る(失敗0回)
切り替わりにかかった時間は測っていません。 止めてから最初のリクエストまでの間隔しか記録しておらず、ヘルスチェックの検出と振り分けの反映を分けられていないためです。詰めるなら検出時間の測り方と同じ形で、毎秒サンプリングしながら状態遷移の時刻を突き合わせます。
ログには応答したVMのIPが入っている #
ここは最初、確かめられないと書いていました。 resource.labels だけを見ていたためです。バックエンドサービスでログを有効にしてあります。
log_config {
enable = true
sample_rate = 1.0
}
この設定では送信 Cookie 自体はログに残りません。 log_config.request_headers で Cookie を指定すれば記録できますが、今回は試していません。以降で見るのは既定のログだけです。
クエリ文字列に識別子を入れて投げ、LBのログを引きます。
gcloud logging read 'resource.type="http_external_regional_lb_rule"' --freshness=25m --limit=30
resource.labels を見ると、バックエンドは NEG 名で入っています。
resource.labels.backend_name = tf-adv-elb10-neg
resource.labels.backend_target_name = tf-adv-elb10-bs-route
resource.labels.backend_type = NETWORK_ENDPOINT_GROUP
ここで止めると「VMは分からない」と結論してしまいます。 エントリ全体を出すと、別の場所にありました。
httpRequest.serverIp = 10.82.0.3:8080
httpRequest.remoteIp = 121.111.174.173:54718
httpRequest.latency = 0.008675s
httpRequest.serverIp が、応答したバックエンドのIPとポートです。 10.82.0.3 は backend-a2、10.82.0.2 は backend-a でした。
固定を追えるか試します。同じ GCILB で6回リクエストし、ログを引きます。
VIP=$(terraform output -raw vip)
JAR=$(mktemp)
curl -s -c "$JAR" "http://$VIP:81/" >/dev/null
GC=$(grep GCILB "$JAR" | awk '{print $7}') # 例: "290094c6a2f6343f"
for i in $(seq 1 6); do curl -s -H "Cookie: GCILB=$GC" "http://$VIP:81/?probe=AFFLOG-$i"; done
sleep 50 # ログの反映を待つ
gcloud logging read 'resource.type="http_external_regional_lb_rule" AND httpRequest.requestUrl:"AFFLOG"' \
--freshness=10m --format=json | jq -r '.[].httpRequest.serverIp' | sort | uniq -c
ログ件数: 6
serverIp 10.82.0.2:8080: 6 件
応答の本文も6回とも backend-a でした。アプリに識別子を返させなくても、どのVMが応答したかはログで追えます。
ただし「Cookie が原因で固定された」ことまではログに出ません。保存されたLBログには、送信された Cookie もアフィニティの判定理由も入っていません。分かるのは送信先が同じだったことまでです。
メトリクスは違いました。
直近30分を Monitoring API v3 で引き、points を合計したものです。
PROJECT=$(gcloud config get-value project)
ST=$(date -u -d '-30 min' +%Y-%m-%dT%H:%M:%SZ)
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
for M in request_count backend_request_count; do
curl -s -G -H "Authorization: Bearer $(gcloud auth print-access-token)" \
"https://monitoring.googleapis.com/v3/projects/$PROJECT/timeSeries" \
--data-urlencode "filter=metric.type=\"loadbalancing.googleapis.com/https/external/regional/$M\"" \
--data-urlencode "interval.startTime=$ST" --data-urlencode "interval.endTime=$END" \
| jq -r --arg m "$M" '.timeSeries[] | "\($m) \(.resource.labels.backend_name) \(.resource.labels.backend_target_name) " +
"\(.resource.labels.forwarding_rule_name) \(.metric.labels.response_code_class) " +
"合計 \([.points[].value.int64Value | tonumber] | add)"'
done
points は同じラベルの組でも複数の時系列に分かれることがあるので、まず全時系列の resource.labels と metric.labels を確認してから、同じ組み合わせをさらに合算しています。
request_count neg / bs-gclb / neg / fr-81 / 200 合計 94
request_count neg / bs-route / neg / fr-83 / 200 合計 30
backend_request_count neg / bs-gclb / neg / fr-81 / 200 合計 94
backend_request_count neg / bs-route / neg / fr-83 / 200 合計 30
backend_name は NEG 名のままで、VM単位には割れませんでした。 ログの serverIp にあたるラベルが見当たりません。
今回引いた2つの指標に、そういうラベルが無かった、というところまでです。 指標名を総なめしたわけではありません。
分配をメトリクスで見たいなら2つあります。バックエンドを別々の NEG に分けるか、ログの serverIp からログベースメトリクスを作るか。どちらも今回は試していません。
設定が効いているかの確認 #
$ gcloud compute backend-services describe tf-adv-elb10-bs-gclb --region=asia-northeast1 \
--format="value(sessionAffinity,affinityCookieTtlSec,localityLbPolicy)"
GENERATED_COOKIE 3600
$ gcloud compute backend-services describe tf-adv-elb10-bs-route --region=asia-northeast1 \
--format="value(sessionAffinity,affinityCookieTtlSec,localityLbPolicy,consistentHash.httpCookie.name)"
HTTP_COOKIE 3600 RING_HASH ROUTE
:81 の localityLbPolicy は空です。指定していないだけで、条件が無いわけではありません。 明示しなければ MAGLEV が使われます。
後片付け #
転送ルール・NAT・VM は課金されます。 転送ルールに割り当てた外部IPそのものは課金対象外ですが、転送ルールは有料です。検証が終わったら消します。
terraform destroy
Destroy complete! Resources: 28 destroyed.
残存を確認します。| grep -c だけで数えると、一覧の取得に失敗したときも0件になってしまうので、終了コードを見ます。
for r in instances addresses forwarding-rules backend-services target-http-proxies \
url-maps network-endpoint-groups health-checks firewall-rules networks routers; do
out=$(gcloud compute $r list --project=YOUR_PROJECT_ID --format="value(name)") \
|| { echo "$r: 一覧を取得できませんでした" >&2; exit 2; }
printf '%s: %s 件\n' "$r" "$(grep -c 'tf-adv-elb10' <<<"$out")"
done
11種類すべて0件でした。
VM は enable-oslogin = "TRUE" で作っているので、この接続ではプロジェクトの ssh-keys メタデータに鍵を足しません。
このTerraform構成はプロジェクトのSSHメタデータを管理していません。メタデータ方式で接続して鍵が入った場合、この構成の destroy では消えません。
まとめ #
HTTP_COOKIEは Cookie の値をハッシュするだけ。ROUTE=backend-aを送ったらbackend-a2に10回とも届いた。アプリ側で行き先を指定したことにはならないhttp_cookie.nameをアプリと同じ名前にすると、対象 Cookie を送っていないリクエストでLBも同名で発行する。Set-Cookieが2つ返り、Cookie ストアにはあとに届いたほうだけが残る。2回目以降はクライアントが既に Cookie を送るので、LBは生成しないGENERATED_COOKIEはアプリの Cookie を見ない。GCILBだけで固定され、ROUTEだけを送ると5回中backend-aが3回・backend-a2が2回に割れた- Cookie の名前は
GCILB。 リージョン外部・内部がGCILB、グローバルとクラシックがGCLB。ロードバランサの種類で変わる - Cookie が無ければ固定されない。 30回で 15/15、18/12 に割れた(各1回の観測)
- 固定先が UNHEALTHY になると外れた。 同じ Cookie が20回とも生存側へ、復帰後は20回とも元のバックエンドへ届いた。必ず元へ戻るという保証ではなく、試したのは1つの Cookie
- 公式は best-effort と書いている。 「健全なバックエンドの数が変わらないかぎり」という条件付き
- どのVMが応答したかは LB のログで追える。
httpRequest.serverIpにIPとポートが入る。同じ Cookie の6回が6件とも同じserverIpだった。resource.labelsだけを見ると NEG 名しか無く、「分からない」と誤って結論する - ただし今回のログ設定では「Cookie が原因で固定された」ことは出ない。 既定では送信された Cookie もアフィニティの判定理由も入らない。
log_config.request_headersで Cookie を記録する設定は試していない。分かるのは送信先までで、原因の裏づけには別の材料が要る - メトリクスでは割れなかった。
request_count/backend_request_countのbackend_nameは NEG 名。今回引いた2つの指標にVM単位のラベルが無かった、というところまで HTTP_COOKIEにはRING_HASHかMAGLEVが要る。未指定ならMAGLEV- NEGエンドポイントの
instanceにself_linkを渡すと恒常差分になる。 apply のたびに作り直され、測定が壊れる