---
layout: blog
title: K8sクラスターでロードバランサー後のリクエストのソースIPを保持する方法
categories: ネットワーク
tags: [ネットワーク, blog]
date: 2024-05-27 11:52:22 +0800
draft: false
toc: false
comments: true
---

引言

アプリケーションのデプロイは常に単純なインストール実行とは限りません。時にはネットワークの問題を考慮する必要があります。本文では、K8sクラスターでサービスがリクエストのソースIPを取得できるようにする方法を紹介します。

アプリケーションがサービスを提供する際は、一般的に入力情報に依存します。入力情報が5タプル(ソースIP、ソースポート、宛先IP、宛先ポート、プロトコル)に依存しない場合、そのサービスはネットワークとの結合性が低いため、ネットワークの詳細を気にする必要はありません。

したがって、ほとんどの人には本文を読む必要はありません。ネットワークに興味がある場合や視野を広げたい場合は、以下の本文を読み進めて、より多くのサービスシナリオを理解してください。

本文はK8s v1.29.4を基にしています。文中では一部podとendpointを混用していますが、本文のシナリオでは同等とみなせます。

誤りがあればご指摘ください。速やかに修正します。

なぜソースIP情報が失われるのか?

まず、ソースIPとは何かを明確にしましょう。AがBにリクエストを送信し、BがリクエストをCに転送する場合、Cが見るIPプロトコルのソースIPはBのIPですが、本文ではAのIPをソースIPとみなします。

主に以下の2つの動作でソース情報が失われます:

  1. ネットワークアドレス変換(NAT):公衆IPv4の節約やロードバランシングなどを目的とします。サービス側が見るソースIPはNATデバイスのIPとなり、真のソースIPではありません。
  2. プロキシ(Proxy)リバースプロキシ(RP, Reverse Proxy)とロードバランサー(LB, Load Balancer)がこれに該当し、以下ではプロキシサーバーと総称します。この種のプロキシサービスはリクエストをバックエンドサービスに転送しますが、ソースIPを自身のIPに置き換えます。
  • NATは簡単に言えばポート空間でIP空間を交換するものです。IPv4アドレスが限られているため、1つのIPアドレスで65535個のポートをマッピングでき、ほとんどの場合これらのポートを使い切らないため、複数のサブネットIPが1つの公衆IPを共有し、ポートでサービスを区別します。その使用形式は:public IP:public port -> private IP_1:private portです。詳細はネットワークアドレス変換を参照してください。
  • プロキシサービスは隠蔽または公開を目的とします。プロキシサービスはリクエストをバックエンドサービスに転送しつつ、ソースIPを自身のIPに置き換えてバックエンドサービスの実際のIPを隠し、セキュリティを保護します。その使用形式は:client IP -> proxy IP -> server IPです。詳細はプロキシを参照してください。

NATプロキシサーバーは非常に一般的で、ほとんどのサービスはリクエストのソースIPを取得できません。

これはソースIPを変更する一般的な2つの方法です。他にあれば補足をお願いします。

ソースIPをどのように保持するか?

以下はHTTPリクエストの例です:

フィールド長さ(バイト)ビットオフセット説明
IPヘッダー
ソースIP40-31送信元のIPアドレス
宛先IP432-63受信元のIPアドレス
TCPヘッダー
ソースポート20-15送信ポート番号
宛先ポート216-31受信ポート番号
シーケンス番号432-63送信元が送信したデータのバイトストリームを識別
確認番号464-95ACKフラグが設定されている場合、次の期待されるシーケンス番号
データオフセット496-103データ開始位置がTCPヘッダーからのバイト数
予約4104-111予約フィールド、使用せず0に設定
フラグ位2112-127SYN、ACK、FINなどの各種制御フラグ
ウィンドウサイズ2128-143受信側が受信可能なデータ量
チェックサム2144-159传输中のエラー検出に使用
緊急ポインター2160-175送信元が受信元に迅速に処理してほしい緊急データの位置
オプション可変176-…タイムスタンプ、最大セグメント長などを含む可能性
HTTPヘッダー
リクエスト行可変…-…リクエストメソッド、URI、HTTPバージョンを含む
ヘッダーフィールド可変…-…Host、User-Agentなどの各種ヘッダーフィールド
空行2…-…ヘッダーとボディ部分を分離
ボディ可変…-…オプションのリクエストまたはレスポンス本文

上記のHTTPリクエスト構造を閲覧すると、TCPオプションリクエスト行ヘッダーフィールドボディが可変であることがわかります。そのうちTCPオプションのスペースは限定的で一般的にソースIP伝達に使用されず、リクエスト行は固定情報で拡張不可、HTTPボディは暗号化後修正不可のため、HTTPヘッダーフィールドのみがソースIP拡張伝達に適しています。

HTTPヘッダーにX-REAL-IPフィールドを追加してソースIPを伝達できます。この操作は通常プロキシサーバーで行われ、プロキシサーバーがリクエストをバックエンドサービスに送信すると、バックエンドサービスはこのフィールドからソースIP情報を取得できます。

注意:プロキシサーバーNATデバイスより前に配置されていることを保証する必要があります。これにより、真のリクエストのソースwhoamiを取得できます。阿里雲の製品ではロードバランサーが独立した商品カテゴリとして存在し、ネットワーク上の位置が通常のアプリケーションサーバーと異なります。

K8S操作ガイド

whoamiプロジェクトを例にデプロイします。

Deploymentの作成

まずサービスを作成します:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: docker.io/traefik/whoami:latest
          ports:
            - containerPort: 8080

このステップでDeploymentを作成し、3つのPodを含み、各podに1つのコンテナが含まれ、whoamiサービスが実行されます。

Serviceの作成

NodePortまたはLoadBalancerタイプのサービスを作成して外部アクセスをサポートするか、ClusterIPタイプのサービスを作成してクラスター内アクセス専用とし、Ingressサービスを追加して外部アクセスを公開します。

NodePortNodeIP:NodePortまたはIngressサービス経由でアクセス可能でテストに便利です。本節ではNodePortサービスを使用します。

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
spec:
  type: NodePort
  selector:
    app: whoami
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 30002

サービス作成後、curl whoami.example.com:30002でアクセスすると、返されるIPはNodeIPで、リクエストのソースwhoamiではありません。

注意:これは正しいクライアントIPではありません。これらはクラスターの内部IPです。起こっていることは:

  • クライアントがnode2:nodePortにデータパケットを送信
  • node2がデータパケットのソースIPアドレスを自身のIPアドレスに置き換え(SNAT)
  • node2がデータパケットの宛先IPをPod IPに置き換え
  • データパケットがnode1にルーティングされ、エンドポイントへ
  • Podの返信がnode2にルーティングされ返信
  • Podの返信がクライアントに送信

図で表す:

externalTrafficPolicy: Localの設定

この状況を避けるため、KubernetesにはクライアントソースIPを保持する機能があります。service.spec.externalTrafficPolicyをLocalに設定すると、kube-proxyはローカルエンドポイントにのみリクエストをプロキシし、他のノードにトラフィックを転送しません。

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
spec:
  type: NodePort
  externalTrafficPolicy: Local
  selector:
    app: whoami
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 30002

curl whoami.example.com:30002でテストすると、whoami.example.comがクラスターの複数ノードのIPにマッピングされている場合、一定割合でアクセス不可となります。ドメイン記録がendpoint(pod)所在ノードのIPのみを含むことを確認する必要があります。

この設定には代償があり、クラスター内のロードバランシング能力を失います。クライアントはendpointがデプロイされたノードにのみアクセスして応答を得られます。

アクセスパス制限

クライアントがNode 2にアクセスした場合、応答がありません。

Ingressの作成

ほとんどのサービスはユーザー向けにhttp/httpsを使用します。https://ip:port形式はユーザーに馴染みが薄いため、一般的にIngressを使用して上記のNodePortサービスをドメインの80/443ポートにロードバランシングします。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami-ingress
  namespace: default
spec:
  ingressClassName: external-lb-default
  rules:
    - host: whoami.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami-service
                port:
                  number: 80

適用後、curl whoami.example.comでテストすると、ClientIPは常にendpoint所在ノード上のIngress ControllerのPod IPとなります。

root@client:~# curl whoami.example.com
...
RemoteAddr: 10.42.1.10:56482
...

root@worker:~# kubectl get -n ingress-nginx pod -o wide
NAME                                       READY   STATUS    RESTARTS   AGE    IP           NODE          NOMINATED NODE   READINESS GATES
ingress-nginx-controller-c8f499cfc-xdrg7   1/1     Running   0          3d2h   10.42.1.10   k3s-agent-1   <none>           <none>

IngressリバースプロキシでNodePortサービスを使用すると、endpointの前に2層のserviceが追加されます。下図は両者の違いを示します。

graph LR
    A[Client] -->|whoami.example.com:80| B(Ingress)
    B -->|10.43.38.129:32123| C[Service]
    C -->|10.42.1.1:8080| D[Endpoint]
graph LR
    A[Client] -->|whoami.example.com:30001| B(Service)
    B -->|10.42.1.1:8080| C[Endpoint]

パス1では、外部からIngressにアクセスすると、最初に到達するendpointはIngress Controllerで、次にwhoamiのendpointです。
Ingress Controllerは本質的にLoadBalancerサービスです。

kubectl -n ingress-nginx get svc

NAMESPACE   NAME             CLASS   HOSTS                       ADDRESS                                              PORTS   AGE
default     echoip-ingress   nginx   ip.example.com       172.16.0.57,2408:4005:3de:8500:4da1:169e:dc47:1707   80      18h
default     whoami-ingress   nginx   whoami.example.com   172.16.0.57,2408:4005:3de:8500:4da1:169e:dc47:1707   80      16h

したがって、前述のexternalTrafficPolicyをIngress Controllerに設定することでソースIPを保持できます。

同時に、ingress-nginx-controllerconfigmapuse-forwarded-headerstrueに設定し、Ingress ControllerX-Forwarded-ForまたはX-REAL-IPフィールドを認識できるようにします。

apiVersion: v1
data:
  allow-snippet-annotations: "false"
  compute-full-forwarded-for: "true"
  use-forwarded-headers: "true"
  enable-real-ip: "true"
  forwarded-for-header: "X-Real-IP" # X-Real-IP or X-Forwarded-For
kind: ConfigMap
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.10.1
  name: ingress-nginx-controller
  namespace: ingress-nginx

NodePortサービスとingress-nginx-controllerサービスの違いは、主にNodePortのバックエンドが通常各ノードにデプロイされないのに対し、ingress-nginx-controllerのバックエンドは通常各外部公開ノードにデプロイされる点です。

NodePortサービスでexternalTrafficPolicyを設定するとクロスノードリクエストが無応答になるのに対し、IngressはリクエストでまずHEADERを設定してからプロキシ転送するため、ソースIP保持ロードバランシングの両能力を実現します。

まとめ

  • アドレス変換(NAT)プロキシ(Proxy)リバースプロキシ(Reverse Proxy)、**ロードバランサー(Load Balance)**でソースIPが失われます。
  • ソースIP喪失を防ぐため、プロキシサーバー転送時に真のIPをHTTPヘッダーフィールドX-REAL-IPに設定してプロキシサービス経由で伝達します。多層プロキシの場合、X-Forwarded-Forフィールドを使用し、スタック形式でソースIPとプロキシパスのIPリストを記録します。
  • クラスターNodePortサービスでexternalTrafficPolicy: Localを設定するとソースIPを保持できますが、ロードバランシング能力を失います。
  • ingress-nginx-controllerをすべてのloadbalancerロールノードにdaemonset形式でデプロイする前提で、externalTrafficPolicy: Localを設定するとソースIPを保持しつつロードバランシング能力を保持します。

参考