Cloudflare の Private LB を使って IP Session Affinity を実現する

# Cloudflare の Private LB
Cloudflare では WARP クライアントからの通信に対して、Private-to-Private な負荷分散サービスを提供できます。

これまで負荷分散サービスは、公開ドメインに紐づけてサービス提供することが多かったですが、公開ドメインがない場合での Cloudflare Zero Trust と組み合わせた Private-to-Private なユースケースにも対応しています。

https://developers.cloudflare.com/load-balancing/private-network/tunnels-setup/

https://blog.cloudflare.com/eliminating-hardware-with-load-balancing-and-cloudflare-one/

>![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/249cbea9-2d48-a8f4-04cc-caa099d4e1af.png)

# IP Session Affinity における課題
Cloudflare Zero Trust と内部ロードバランサーを組み合わせた際には、IP Session Affinity に関して、以下のような課題が出る場合があります。

- 1 つの Cloudflare Tunnel ルートに対して、複数の `cloudflared` レプリカで冗長性とスケーラビリティを確保したい

https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/deploy-tunnels/deploy-cloudflared-replicas/

- ただし、その場合 `cloudflared` を経由する通信は SNAT されるため、その背後にある既存の内部ロードバランサーからすると、同じクライアントからの通信であっても、異なるレプリカを経由すると送信元 IP が変わって見える

- 既存の内部ロードバランサーで IP Session Affinity を採用している場合、 クライアントからはランダムに [`401 Unauthorized`](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/#401-unauthorized) のようなセッションエラーが散見される

https://hirotanoblog.com/a10-persistence/8542/

```mermaid:
%%{init: {'themeCSS': 'img {width: 48px; height:48px;}'}}%%
%%<img src='https://cf-icons.pages.dev/calls.svg' width='48' height='48'/><br>

graph LR

Eyeball[<img src='https://cf-icons.pages.dev/warp-client.svg' width='48' height='48'/><br>WARP]

subgraph Cloudflare
subgraph VNet [Virtual networks]
subgraph Tunnel [Tunnel Routes]
subgraph Subnet ["<div align=center>10.148.0.0/20<br><img src='https://cf-icons.pages.dev/file.svg' width='48' height='48'/>"]
10["10.148.x.x<br>(VIP)"]
end
end
end
end

subgraph Corp [Corporate Network]
subgraph Gateway1a [Connector 1]
cloudflared1a["<img src='https://cf-icons.pages.dev/Cloudflared.svg' width='48' height='48'/><br>cloudflared<br>(replica)"]
end
subgraph Gateway1b [Connector 2]
cloudflared1b["<img src='https://cf-icons.pages.dev/Cloudflared.svg' width='48' height='48'/><br>cloudflared<br>(replica)"]
end
subgraph LB ["On-prem internal LB<br>(F5, A10, etc)"]
LBDNS["<img src='https://cf-icons.pages.dev/load-balancing.svg' width='48' height='48'/><br>10.148.x.x<br>(VIP)"]
end

p10[<img src='https://cf-icons.pages.dev/applications.svg' width='48' height='48'/><br>Private Endpoint<br>10.148.0.10]
p20[<img src='https://cf-icons.pages.dev/applications.svg' width='48' height='48'/><br>Private Endpoint<br>10.148.0.20]

end

Eyeball --> 10
LBDNS --> |IP session affinity|p10 & p20
10 --> cloudflared1a & cloudflared1b
cloudflared1a  --> |Src IP 1|LBDNS
 cloudflared1b --> |Src IP 2|LBDNS


classDef hidden display: none;
classDef transparent fill-opacity:0,stroke-opacity:0;
%%style HTTPApp fill-opacity:0,stroke-opacity:0;
%%style TCPApp fill-opacity:0,stroke-opacity:0;
%%style Future fill:#ffd, stroke:#ccb, fill:#fff,stroke-dasharray: 5 5
```

# 解決案
その場合、以下のような解決策が考えられます。

① 既存の内部ロードバランサーで Cookie Session Affinity を採用する
② SNAT フリーな WARP Connector を採用する

https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/warp-connector/

③ Cloudflare の Private LB を使うことで、`cloudflared` レプリカの前段で負荷分散する

特に③では、IP Session Affinity を継続採用しつつ、既存の内部ロードバランサーに関する運用負担も軽減できる構成になっているため、以下のような構成を検証します。

```mermaid:
%%{init: {'themeCSS': 'img {width: 48px; height:48px;}'}}%%
%%<img src='https://cf-icons.pages.dev/calls.svg' width='48' height='48'/><br>

graph LR

Eyeball[<img src='https://cf-icons.pages.dev/warp-client.svg' width='48' height='48'/><br>WARP]

subgraph Cloudflare
subgraph LB [<div align='center'>Account-level<br>Private-to-Private LB]
LBDNS[<img src='https://cf-icons.pages.dev/load-balancing.svg' width='48' height='48'/><br>100.112.0.x]
end
subgraph VNet [Virtual networks]
subgraph Tunnel [Tunnel Routes]
subgraph Subnet ["<div align=center>10.148.0.0/20<br><img src='https://cf-icons.pages.dev/file.svg' width='48' height='48'/>"]
subgraph Pool
10[10.148.0.10]
20[10.148.0.20]
end
end
end
end
LB -.-> Monitor -.-> Pool

end

subgraph Corp [Corporate Network]
subgraph Gateway1a [Connector 1]
cloudflared1a["<img src='https://cf-icons.pages.dev/Cloudflared.svg' width='48' height='48'/><br>cloudflared<br>(replica)"]
end
subgraph Gateway1b [Connector 2]
cloudflared1b["<img src='https://cf-icons.pages.dev/Cloudflared.svg' width='48' height='48'/><br>cloudflared<br>(replica)"]
end
p10[<img src='https://cf-icons.pages.dev/applications.svg' width='48' height='48'/><br>Private Endpoint<br>10.148.0.10]
p20[<img src='https://cf-icons.pages.dev/applications.svg' width='48' height='48'/><br>Private Endpoint<br>10.148.0.20]

end

Eyeball --> LBDNS
LBDNS --> |IP Hash steering<br>between Endpoints|10 & 20
10 & 20 --> cloudflared1a & cloudflared1b
cloudflared1a & cloudflared1b --> p10 & p20


classDef hidden display: none;
classDef transparent fill-opacity:0,stroke-opacity:0;
%%style HTTPApp fill-opacity:0,stroke-opacity:0;
%%style TCPApp fill-opacity:0,stroke-opacity:0;
%%style Future fill:#ffd, stroke:#ccb, fill:#fff,stroke-dasharray: 5 5
```

# API トークン作成
Cloudflare の Private LB は、API 操作で作成する必要があります。

以下の Permission でアカウント API トークンを作成します。

https://developers.cloudflare.com/fundamentals/api/get-started/account-owned-tokens/#create-an-account-owned-token

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/1bab911b-039d-5b42-573d-9ed4e3896c63.png)

# 変数設定

API 操作に必要なパラメータをセットします。

```shell:
export ACCOUNT_ID='YOUR_ACCOUNT_ID'
export APITOKEN='YOUR_APITOKEN'
```

# 前提となる構成
以下のドキュメントに従って、WARP-to-Tunnel のパスで通信できるように構成してください。

その際に作成された Virtual Network の ID を取得して、変数設定します。

```shell
curl -sX GET "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/teamnet/virtual_networks" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" | jq
    
export VNET_ID='YOUR_VNET_ID'
```


https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/cloudflared/

>![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/94479cd5-45f2-6f54-d86c-296d8885952b.png)

# Monitor 作成
まずヘルスチェックをおこなうための Monitor を、以下のコマンドで作成します。

HTTP ポート 80 に対して、パス `/` への `GET` リクエストのレスポンスコードが `200` であることを想定します。

https://developers.cloudflare.com/api/resources/load_balancers/subresources/monitors/methods/create/

```shell:
curl -sX POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/monitors" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "description": "HTTP Monitor",
      "type": "http",
      "port": 80,
      "interval": 60,
      "retries": 2,
      "timeout": 5,
      "expected_body": "",
      "expected_codes": "200",
      "follow_redirects": false,
      "allow_insecure": false,
      "probe_zone": "",
      "path": "/",
      "header": {},
      "method": "GET"
    }'| jq
```

# Monitor 取得
作成した Monitor は以下のコマンドで取得できます。

取得した Monitor の ID を変数設定して使用します。

https://developers.cloudflare.com/api/resources/load_balancers/subresources/monitors/methods/list/

```shell:
curl -sX GET "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/monitors" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" | jq

export MONITOR_ID='YOUR_MONITOR_ID'
```

# Pool の作成
次に、負荷分散先となるオリジンプールを Monitor と紐づけて作成します。

オリジンの IP アドレスは Virtual Network 内で定義されたルートの範囲内にあるものを使用します。

https://developers.cloudflare.com/api/resources/load_balancers/subresources/pools/methods/create/

`origin_steering` の `policy` を `hash` にすることで IP Session Affinity となります。

https://developers.cloudflare.com/load-balancing/understand-basics/traffic-steering/origin-level-steering/hash-origin-steering/

また、1つのプール内で同じ重みづけをすることで、Active-Active な負荷分散を想定します。

https://developers.cloudflare.com/load-balancing/load-balancers/common-configurations/#active---active-failover

>Create a load balancer with a single pool (`primary`) with multiple endpoints (`endpoint-1` and `endpoint-2`) and set the same `Weight` for each endpoint.

```shell:
export MONITOR_ID='YOUR_MONITOR_ID'
export VNET_ID='YOUR_VNET_ID'

curl -sX POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/pools" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "description": "HTTP-Pool",
      "enabled": true,
      "minimum_origins": 1,
      "monitor": "'$MONITOR_ID'",
      "name": "HTTP-Pool",
      "check_regions": [
        "NEAS"
      ],
      "origin_steering": {
        "policy": "hash"
      },
      "origins": [
        {
          "name": "HTTP-Origin-1",
          "address": "10.148.0.10",
          "enabled": true,
          "weight": 1,
          "virtual_network_id": "'$VNET_ID'"
        },
        {
          "name": "HTTP-Origin-2",
          "address": "10.148.0.20",
          "enabled": true,
          "weight": 1,
          "virtual_network_id": "'$VNET_ID'"
        }
      ]
    }' | jq
```

# Pool の取得
作成した Pool は以下のコマンドで取得できます。

取得した Pool の ID を変数設定して使用します。

https://developers.cloudflare.com/api/resources/load_balancers/subresources/pools/methods/list/

```shell
curl -sX GET "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/pools" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" | jq
```

# Account-level Private LB 作成

最後に、Account-level の Private LB を Pool と紐づけて作成します。

`fallback_pool` と `default_pools` には、同じ Pool を設定していますが、別途メンテナンスページを表示する Pool を用意して設定することもできます。

```shell:
export POOL_ID='YOUR_POOL_ID'

curl -sX POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "description": "HTTP-Private-LB",
      "proxied": true,
      "enabled": true,
      "session_affinity": "none",
      "steering_policy": "off",
      "fallback_pool": "'$POOL_ID'",
      "default_pools": [
        "'$POOL_ID'"
      ]
    }' | jq
```

# Account-level Private LB 取得
作成した Private LB は以下のコマンドで取得できます。

取得した Private LB の ID を変数設定して使用します。

```shell:
curl -sX GET "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" | jq
```

# Private LB VIP 取得
以下のように `100.112.0.x` の IP アドレスで、VIP が作成されます。

```shell
curl -sX GET "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/teamnet/routes" \
    -H "Authorization: Bearer $APITOKEN" \
    -H "Content-Type: application/json" | jq -r '.result[] | select(.tun_type == "load_balancer" and .deleted_at == null)'
```

```json:result.json
{
  "id": "97d99c24-aa91-49dd-b156-853bd3c0514e",
  "tun_type": "load_balancer",
  "network": "100.112.0.1/32",
  "tunnel_id": "312ce628-b249-4eaf-8efd-cb01afefc6f9",
  "comment": "automatically generated route for load balancer",
  "created_at": "2025-01-30T17:40:19.722081Z",
  "deleted_at": null,
  "virtual_network_id": "6ea83ca4-4272-4e8b-8443-7c34e69cb7bf",
  "tunnel_name": "load-balancing-tunnel-for-824cfe8a1f627de2e9481200a789a024",
  "virtual_network_name": "default"
}
```

# 動作確認
WARP = ON の状態で、Private LB 経由でページが表示されることを確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101361/b2fb24be-04d7-ce3a-4a80-49dd3573c176.png)

# 内部 DNS 
`100.112.0.x` と内部の FQDN `www.example.internal` と紐づけるには、以下のいづれかの方法で対応可能です。

- [DNS policies - Override · Cloudflare Zero Trust docs](https://developers.cloudflare.com/cloudflare-one/policies/gateway/dns-policies/#override)
- [Resolver policies · Cloudflare Zero Trust docs](https://developers.cloudflare.com/cloudflare-one/policies/gateway/resolver-policies/)
- [Local Domain Fallback · Cloudflare Zero Trust docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/route-traffic/local-domains/)
- [Cloudflare で Internal DNS を使う #dns - Qiita](https://qiita.com/khayama/items/4ebaf940cdeb81a9435b)

https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/route-traffic/warp-architecture/#dns-traffic

https://qiita.com/khayama/items/6b0c06887922ee52a072

# まとめ
Cloudflare の Private LB を使えば、IP Session Affinity を継続採用しつつ、既存の内部ロードバランサーに関する運用負担も軽減できます。

オンプレのネットワーク機器を含めた構成を検討する良い機会になればと思います。