Как сохранить исходный IP-адрес запроса после балансировки нагрузки в кластере K8s

Введение

Развертывание приложений не всегда сводится к простому установке и запуску, иногда также приходится учитывать проблемы сети. В этой статье описывается, как в кластере k8s сделать так, чтобы сервис мог получить исходный IP запроса.

Приложения, предоставляющие услуги, обычно зависят от входной информации. Если входная информация не зависит от пятерки (исходный IP, исходный порт, целевой IP, целевой порт, протокол), то такое приложение имеет низкую связанность с сетью и не нуждается в деталях сети.

Поэтому большинству людей нет необходимости читать эту статью. Если вы интересуетесь сетями или хотите расширить кругозор, можете продолжить чтение, чтобы узнать о дополнительных сценариях сервисов.

Статья основана на k8s v1.29.4. В тексте частично смешиваются термины pod и endpoint; в контексте статьи их можно считать эквивалентными.

Если есть ошибки,欢迎 исправить, я timely исправлю.

Почему теряется информация о исходном IP?

Сначала уточним, что такое исходный IP. Когда A отправляет запрос B, а B пересылает запрос C, хотя C видит в протоколе IP исходный IP как IP B, в этой статье IP A считается исходным IP.

Есть два основных типа поведения, приводящих к потере исходной информации:

  1. Преобразование сетевых адресов (NAT), цель — экономия публичных IPv4, балансировка нагрузки и т.д. Это приводит к тому, что сервер видит исходный IP как IP устройства NAT, а не реальный исходный IP.
  2. Прокси (Proxy), обратный прокси (RP, Reverse Proxy) и балансировщик нагрузки (LB, Load Balancer) относятся к этой категории, в дальнейшем统称为 прокси-серверы. Такие прокси-серверы пересылают запросы backend-сервисам, но заменяют исходный IP на свой собственный.
  • NAT простыми словами — это обмен пространством портов на пространство IP. Адреса IPv4 ограничены, один IP может отображать 65535 портов, и в большинстве случаев эти порты не исчерпываются, поэтому несколько подсетей IP могут использовать один публичный IP, отличаясь портами. Форма использования: public IP:public port -> private IP_1:private port. Подробнее читайте в Преобразование сетевых адресов
  • Прокси-сервисы используются для скрытия или экспонирования. Прокси-сервер пересылает запрос backend-сервису, заменяя исходный IP на свой, чтобы скрыть реальный IP backend-сервиса и защитить его безопасность. Форма использования: client IP -> proxy IP -> server IP. Подробнее читайте в Прокси

NAT и прокси-серверы очень распространены, большинство сервисов не могут получить исходный IP запроса.

Это два распространенных способа изменения исходного IP. Дополнения欢迎.

Как сохранить исходный IP?

Вот пример HTTP-запроса:

ПолеДлина (байты)Смещение битаОписание
Заголовок IP
Исходный IP40-31IP-адрес отправителя
Целевой IP432-63IP-адрес получателя
Заголовок TCP
Исходный порт20-15Номер отправного порта
Целевой порт216-31Номер целевого порта
Номер последовательности432-63Для идентификации потока байтов, отправленного отправителем
Номер подтверждения464-95Если установлен флаг ACK, то это следующий ожидаемый номер последовательности
Смещение данных496-103Количество байтов от начала данных относительно заголовка TCP
Зарезервировано4104-111Зарезервированное поле, не используется, устанавливается в 0
Флаги2112-127Различные контрольные флаги, такие как SYN, 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. Это обычно делается на прокси-сервере, после чего прокси-сервер отправляет запрос backend-сервису, и backend может получить исходный IP через это поле.

Обратите внимание: нужно гарантировать, что прокси-сервер находится до устройства NAT, чтобы получить реальный исходный IP запроса whoami. В продуктах Alibaba Cloud есть отдельная категория товара Load Balancer, позиция которого в сети отличается от обычных серверов приложений.

Руководство по операциям в 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 содержит один контейнер, запускающий сервис whoami.

Создание Service

Можно создать сервис типа NodePort или LoadBalancer для внешнего доступа или ClusterIP для внутреннего доступа кластера, плюс Ingress для экспонирования внешнего доступа.

NodePort доступен через NodeIP: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, а не исходный IP запроса whoami.

Обратите внимание, это не правильный IP клиента, это внутренние IP кластера. Вот что происходит:

  • Клиент отправляет пакет на node2:nodePort
  • node2 заменяет исходный IP пакета на свой IP (SNAT)
  • node2 заменяет целевой IP пакета на Pod IP
  • Пакет маршрутизируется на node1, затем на endpoint
  • Ответ Pod маршрутизируется обратно на node2
  • Ответ Pod отправляется клиенту

Схематично:

Настройка externalTrafficPolicy: Local

Чтобы избежать этого, в Kubernetes есть функция сохранения исходного IP клиента. Если установить service.spec.externalTrafficPolicy в Local, kube-proxy будет проксировать запросы только на локальные endpoints, не пересылая трафик на другие узлы.

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 нескольких узлов кластера, есть вероятность, что доступ не сработает. Нужно убедиться, что DNS-записи содержат только IP узлов с endpoints (pod).

Эта настройка имеет цену: теряется способность балансировки нагрузки в кластере. Клиент получит ответ только при доступе к узлу с 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 всегда как IP Pod Ingress Controller на узле с endpoint.

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 означает две вложенные service перед endpoint. На рисунке ниже показаны различия.

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, затем endpoint whoami.
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.

Также нужно установить в configmap ingress-nginx-controller параметр use-forwarded-headers в true, чтобы Ingress Controller распознавал поля X-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 в основном в том, что backend NodePort обычно не развертывается на каждом узле, а backend 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, развернутый в форме daemonset на всех узлах роли loadbalancer, с установкой externalTrafficPolicy: Local сохраняет исходный IP и балансировку нагрузки.

Ссылки