Istio 中實現客戶端源 IP 的保持

騰訊雲原生發表於2022-06-08

作者

尹燁,騰訊專家工程師, 騰訊雲 TCM 產品負責人。在 K8s、Service Mesh 等方面有多年的實踐經驗。

導語

對於很多後端服務業務,我們都希望得到客戶端源 IP。雲上的負載均衡器,比如,騰訊雲 CLB 支援將客戶端源IP傳遞到後端服務。但在使用 istio 的時候,由於 istio ingressgateway 以及 sidecar 的存在,後端服務如果需要獲取客戶端源 IP,特別是四層協議,情況會變得比較複雜。

正文

很多業務場景,我們都希望得到客戶端源 IP。雲上負載均衡器,比如,騰訊雲 CLB支援將客戶端 IP 傳遞到後端服務。TKE/TCM 也對該能力做了很好的整合。

但在使用 istio 的時候,由於中間鏈路上,istio ingressgateway 以及 sidecar 的存在,後端服務如果需要獲取客戶端 IP,特別是四層協議,情況會變得比較複雜。

對於應用服務來說,它只能看到 Envoy 過來的連線。

一些常見的源 IP 保持方法

先看看一些常見 Loadbalancer/Proxy 的源 IP 保持方法。我們的應用協議一般都是四層、或者七層協議。

七層協議的源 IP 保持

七層的客戶端源 IP 保持方式比較簡單,最具代表性的是 HTTP 頭XFF(X-Forwarded-For),XFF 儲存原始客戶端的源 IP,並透傳到後端,應用可以解析 XFF 頭,得到客戶端的源 IP。常見的七層代理元件,比如 Nginx、Haproxy,包括 Envoy 都支援該功能。

四層協議的源 IP 保持

DNAT

IPVS/iptables都支援 DNAT,客戶端通過 VIP 訪問 LB,請求報文到達 LB 時,LB 根據連線排程演算法選擇一個後端 Server,將報文的目標地址 VIP 改寫成選定 Server 的地址,報文的目標埠改寫成選定 Server 的相應埠,最後將修改後的報文傳送給選出的 Server。由於 LB 在轉發報文時,沒有修改報文的源 IP,所以,後端 Server 可以看到客戶端的源 IP。

Transparent Proxy

Nginx/Haproxy 支援透明代理(Transparent Proxy)。當開啟該配置時,LB 與後端服務建立連線時,會將 socket 的源 IP 繫結為客戶端的 IP 地址,這裡依賴核心TPROXY以及 socket 的 IP_TRANSPARENT 選項。

此外,上面兩種方式,後端服務的響應必須經過 LB,再回到 Client,一般還需要策略路由的配合。

TOA

TOA(TCP Option Address)是基於四層協議(TCP)獲取真實源 IP 的方法,本質是將源 IP 地址插入 TCP 協議的 Options 欄位。這需要核心安裝對應的TOA核心模組

Proxy Protocol

Proxy Protocol是 Haproxy 實現的一個四層源地址保留方案。它的原理特別簡單,Proxy 在與後端 Server 建立 TCP 連線後,在傳送實際應用資料之前,首先傳送一個Proxy Protocol協議頭(包括客戶端源 IP/埠、目標IP/埠等資訊)。這樣,後端 server 通過解析協議頭獲取真實的客戶端源 IP 地址。

Proxy Protocol需要 Proxy 和 Server 同時支援該協議。但它卻可以實現跨多層中間代理保持源 IP。這有點類似七層 XFF 的設計思想。

istio 中實現源 IP 保持

istio 中,由於 istio ingressgateway 以及 sidecar 的存在,應用要獲取客戶端源 IP 地址,會變得比較困難。但 Envoy 本身為了支援透明代理,它支援Proxy Protocol,再結合 TPROXY,我們可以在 istio 的服務中獲取到源 IP。

東西向流量

istio 東西向服務訪問時,由於 Sidecar 的注入,所有進出服務的流量均被 Envoy 攔截代理,然後再由 Envoy 將請求轉給應用。所以,應用收到的請求的源地址,是 Envoy 訪問過來的地址127.0.0.6

# kubectl -n foo apply -f samples/httpbin/httpbin.yaml
# kubectl -n foo apply -f samples/sleep/sleep.yaml
# kubectl -n foo get pods -o wide
NAME                       READY   STATUS    RESTARTS   AGE    IP            NODE           NOMINATED NODE   READINESS GATES
httpbin-74fb669cc6-qvlb5   2/2     Running   0          4m9s   172.17.0.57   10.206.2.144   <none>           <none>
sleep-74b7c4c84c-9nbtr     2/2     Running   0          6s     172.17.0.58   10.206.2.144   <none>           <none>


# kubectl -n foo exec -it deploy/sleep -c sleep -- curl http://httpbin:8000/ip
{
  "origin": "127.0.0.6"
}

可以看到,httpbin 看到的源 IP 是127.0.0.6。從 socket 資訊,也可以確認這一點。

# kubectl -n foo exec -it deploy/httpbin -c httpbin -- netstat -ntp | grep 80
tcp        0      0 172.17.0.57:80          127.0.0.6:56043         TIME_WAIT   -
  • istio 開啟 TPROXY

我們修改 httpbin deployment,使用 TPROXY(注意httpbin的 IP 變成了172.17.0.59):

# kubectl patch deployment -n foo httpbin -p '{"spec":{"template":{"metadata":{"annotations":{"sidecar.istio.io/interceptionMode":"TPROXY"}}}}}'
# kubectl -n foo get pods -l app=httpbin  -o wide
NAME                       READY   STATUS    RESTARTS   AGE   IP            NODE           NOMINATED NODE   READINESS GATES
httpbin-6565f59ff8-plnn7   2/2     Running   0          43m   172.17.0.59   10.206.2.144   <none>           <none>

# kubectl -n foo exec -it deploy/sleep -c sleep -- curl http://httpbin:8000/ip
{
  "origin": "172.17.0.58"
}

可以看到,httpbin 可以得到 sleep 端的真實 IP。

socket 的狀態:

# kubectl -n foo exec -it deploy/httpbin -c httpbin -- netstat -ntp | grep 80                  
tcp        0      0 172.17.0.59:80          172.17.0.58:35899       ESTABLISHED 9/python3           
tcp        0      0 172.17.0.58:35899       172.17.0.59:80          ESTABLISHED -

第一行是 httpbin 的接收端 socket,第二行是 envoy 的傳送端 socket。

httpbin envoy日誌:

{"bytes_received":0,"upstream_local_address":"172.17.0.58:35899",
"downstream_remote_address":"172.17.0.58:46864","x_forwarded_for":null,
"path":"/ip","istio_policy_status":null,
"response_code":200,"upstream_service_time":"1",
"authority":"httpbin:8000","start_time":"2022-05-30T02:09:13.892Z",
"downstream_local_address":"172.17.0.59:80","user_agent":"curl/7.81.0-DEV","response_flags":"-",
"upstream_transport_failure_reason":null,"request_id":"2b2ab6cc-78da-95c0-b278-5b3e30b514a0",
"protocol":"HTTP/1.1","requested_server_name":null,"duration":1,"bytes_sent":30,"route_name":"default",
"upstream_cluster":"inbound|80||","upstream_host":"172.17.0.59:80","method":"GET"}

可以看到,

  • downstream_remote_address: 172.17.0.58:46864 ## sleep的地址
  • downstream_local_address: 172.17.0.59:80 ## sleep訪問的目標地址
  • upstream_local_address: 172.17.0.58:35899 ## httpbin envoy連線httpbin的local address(為sleep的IP)
  • upstream_host: 172.17.0.59:80 ## httpbin envoy訪問的目標地址

httpbin envoy 連線 httpbin 的 local address 為 sleep 的 IP 地址。

南北向流量

對於南北向流量,客戶端先請求 CLB,CLB 將請求轉給 ingressgateway,再轉到後端服務,由於中間多了 ingressgateway 一跳,想要獲取客戶端源 IP,變得更加困難。

我們以 TCP 協議訪問 httpbin:

apiVersion: v1
kind: Service
metadata:
  name: httpbin
  namespace: foo
  labels:
    app: httpbin
    service: httpbin
spec:
  ports:
  - name: tcp
    port: 8000
    targetPort: 80
  selector:
    app: httpbin
---
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: httpbin-gw
  namespace: foo
spec:
  selector:
    istio: ingressgateway # use istio default controller
  servers:
  - port:
      number: 8000
      name: tcp
      protocol: TCP
    hosts:
    - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin
  namespace: foo
spec:
  hosts:
    - "*"
  gateways:
    - httpbin-gw
  tcp:
    - match:
      - port: 8000
      route:
        - destination:
            port:
              number: 8000
            host: httpbin

通過 ingressgateway 訪問 httpbin:

# export GATEWAY_URL=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
# curl http://$GATEWAY_URL:8000/ip
{
  "origin": "172.17.0.54"
}

可以看到,httpbin 看到的地址是ingressgateway的地址:

# kubectl -n istio-system get pods -l istio=ingressgateway -o wide
NAME                                    READY   STATUS    RESTARTS   AGE     IP            NODE           NOMINATED NODE   READINESS GATES
istio-ingressgateway-5d5b776b7b-pxc2g   1/1     Running   0          3d15h   172.17.0.54   10.206.2.144   <none>           <none>

雖然我們在httpbin envoy開啟了透明代理,但 ingressgateway 並不能把 client 的源地址傳到httpbin envoy。基於 envoy 實現的Proxy Protocol,可以解決這個問題。

通過 EnvoyFilter 在 ingressgateway 和 httpbin 同時開啟Proxy Protocol支援。

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: ingressgw-pp
  namespace: istio-system
spec:
  configPatches:
  - applyTo: CLUSTER
    patch:
      operation: MERGE
      value:
        transport_socket:
          name: envoy.transport_sockets.upstream_proxy_protocol
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.transport_sockets.proxy_protocol.v3.ProxyProtocolUpstreamTransport
            config:
              version: V1
            transport_socket:
              name: "envoy.transport_sockets.raw_buffer"
  workloadSelector:
    labels:
      istio: ingressgateway
---
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: httpbin-pp
  namespace: foo
spec:
  configPatches:
  - applyTo: LISTENER
    match:
      context: SIDECAR_INBOUND
    patch:
      operation: MERGE
      value:
        listener_filters:
        - name: envoy.filters.listener.proxy_protocol
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.listener.proxy_protocol.v3.ProxyProtocol
        - name: envoy.filters.listener.original_dst
        - name: envoy.filters.listener.original_src
  workloadSelector:
    labels:
      app: httpbin

再次通過 LB 訪問 httpbin:

# curl http://$GATEWAY_URL:8000/ip
{
  "origin": "106.52.131.116"
}

httpbin 得到了客戶端的源 IP。

  • ingressgateway envoy 日誌
{"istio_policy_status":null,"protocol":null,"bytes_sent":262,"downstream_remote_address":"106.52.131.116:6093","start_time":"2022-05-30T03:33:33.759Z",
"upstream_service_time":null,"authority":null,"requested_server_name":null,"user_agent":null,"request_id":null,
"upstream_cluster":"outbound|8000||httpbin.foo.svc.cluster.local","upstream_transport_failure_reason":null,"duration":37,"response_code":0,
"method":null,"downstream_local_address":"172.17.0.54:8000","route_name":null,"upstream_host":"172.17.0.59:80","bytes_received":83,"path":null,
"x_forwarded_for":null,"upstream_local_address":"172.17.0.54:36162","response_flags":"-"}

可以看到,

  • downstream_remote_address: 106.52.131.116:6093 ## 客戶端源地址

  • downstream_local_address: 172.17.0.54:8000

  • upstream_local_address: 172.17.0.54:42122 ## ingressgw local addr

  • upstream_host: 172.17.0.59:80 ## httpbin 地址

  • httpbin envoy日誌
{"istio_policy_status":null,"response_flags":"-","protocol":null,"method":null,"upstream_transport_failure_reason":null,"authority":null,"duration":37,
"x_forwarded_for":null,"user_agent":null,"downstream_remote_address":"106.52.131.116:6093","downstream_local_address":"172.17.0.59:80",
"bytes_sent":262,"path":null,"requested_server_name":null,"upstream_service_time":null,"request_id":null,"bytes_received":83,"route_name":null,
"upstream_local_address":"106.52.131.116:34431","upstream_host":"172.17.0.59:80","response_code":0,"start_time":"2022-05-30T03:33:33.759Z","upstream_cluster":"inbound|80||"}

可以看到,

  • downstream_remote_address: 106.52.131.116:6093 ## 客戶端源地址
  • downstream_local_address: 172.17.0.59:80 ## httpbin地址
  • upstream_local_address: 106.52.131.116:34431 ## 保留了客戶端IP,port不一樣
  • upstream_host: 172.17.0.59:80 ## httpbin地址

值得注意的是,httpbin envoyupstream_local_address保留了客戶端的 IP,這樣,httpbin 看到的源地址 IP,就是客戶端的真實 IP。

  • 資料流

相關實現分析

TRPOXY

TPROXY 的核心實現參考net/netfilter/xt_TPROXY.c

istio-iptables會設定下面的 iptables 規則,給資料包文設定標記。

-A PREROUTING -p tcp -j ISTIO_INBOUND
-A PREROUTING -p tcp -m mark --mark 0x539 -j CONNMARK --save-mark --nfmask 0xffffffff --ctmask 0xffffffff
-A OUTPUT -p tcp -m connmark --mark 0x539 -j CONNMARK --restore-mark --nfmask 0xffffffff --ctmask 0xffffffff
-A ISTIO_DIVERT -j MARK --set-xmark 0x539/0xffffffff
-A ISTIO_DIVERT -j ACCEPT
-A ISTIO_INBOUND -p tcp -m conntrack --ctstate RELATED,ESTABLISHED -j ISTIO_DIVERT
-A ISTIO_INBOUND -p tcp -j ISTIO_TPROXY
-A ISTIO_TPROXY ! -d 127.0.0.1/32 -p tcp -j TPROXY --on-port 15006 --on-ip 0.0.0.0 --tproxy-mark 0x539/0xffffffff

值得一提的是,TPROXY 不用依賴 NAT,本身就可以實現資料包的重定向。另外,結合策略路由,將非本地的資料包通過本地 lo 路由:

# ip rule list
0:	from all lookup local 
32765:	from all fwmark 0x539 lookup 133 
32766:	from all lookup main 
32767:	from all lookup default 

# ip route show table 133
local default dev lo scope host

TPROXY 的更多詳細介紹參考這裡

Envoy 中 Proxy Protocol 的實現

  • proxy protocol header format

這裡使用了Version 1(Human-readable header format),如下:

0000   50 52 4f 58 59 20 54 43 50 34 20 31 30 36 2e 35   PROXY TCP4 106.5
0010   32 2e 31 33 31 2e 31 31 36 20 31 37 32 2e 31 37   2.131.116 172.17
0020   2e 30 2e 35 34 20 36 30 39 33 20 38 30 30 30 0d   .0.54 6093 8000.
0030   0a                                                .

可以看到,header 包括 client 和 ingressgateway 的IP:PORT資訊。更加詳細的介紹參考這裡

  • ProxyProtocolUpstreamTransport

ingressgateway 作為傳送端,使用ProxyProtocolUpstreamTransport,構建Proxy Protocol頭部:

/// source/extensions/transport_sockets/proxy_protocol/proxy_protocol.cc

void UpstreamProxyProtocolSocket::generateHeaderV1() {
  // Default to local addresses (used if no downstream connection exists e.g. health checks)
  auto src_addr = callbacks_->connection().addressProvider().localAddress(); 
  auto dst_addr = callbacks_->connection().addressProvider().remoteAddress();

  if (options_ && options_->proxyProtocolOptions().has_value()) {
    const auto options = options_->proxyProtocolOptions().value();
    src_addr = options.src_addr_;
    dst_addr = options.dst_addr_;
  }

  Common::ProxyProtocol::generateV1Header(*src_addr->ip(), *dst_addr->ip(), header_buffer_);
}
  • envoy.filters.listener.proxy_protocol

httpbin envoy作為接收端,配置ListenerFilter(envoy.filters.listener.proxy_protocol)解析Proxy Protocol頭部:

/// source/extensions/filters/listener/proxy_protocol/proxy_protocol.cc

ReadOrParseState Filter::onReadWorker() {
  Network::ConnectionSocket& socket = cb_->socket(); /// ConnectionHandlerImpl::ActiveTcpSocket
...
  if (proxy_protocol_header_.has_value() && !proxy_protocol_header_.value().local_command_) {
...
    // Only set the local address if it really changed, and mark it as address being restored.
    if (*proxy_protocol_header_.value().local_address_ !=
        *socket.addressProvider().localAddress()) { /// proxy protocol header: 172.17.0.54:8000
      socket.addressProvider().restoreLocalAddress(proxy_protocol_header_.value().local_address_); /// => 172.17.0.54:8000
    } /// Network::ConnectionSocket
    socket.addressProvider().setRemoteAddress(proxy_protocol_header_.value().remote_address_); /// 修改downstream_remote_address為106.52.131.116
  }

  // Release the file event so that we do not interfere with the connection read events.
  socket.ioHandle().resetFileEvents();
  cb_->continueFilterChain(true); /// ConnectionHandlerImpl::ActiveTcpSocket
  return ReadOrParseState::Done;
}

這裡值得注意的,envoy.filters.listener.proxy_protocol在解析proxy protocol header時,local_address為傳送端的dst_addr(172.17.0.54:8000)remote_address為傳送端的src_addr(106.52.131.116)。順序剛好反過來了。

經過proxy_protocol的處理,連線的downstream_remote_address被修改為client的源地址。

  • envoy.filters.listener.original_src

對於sidecar.istio.io/interceptionMode: TPROXYvirtualInbound listener會增加envoy.filters.listener.original_src:

# istioctl -n foo pc listeners deploy/httpbin --port 15006 -o json
[
    {
        "name": "virtualInbound",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 15006
            }
        },
        "filterChains": [...],
        "listenerFilters": [
            {
                "name": "envoy.filters.listener.original_dst",
                "typedConfig": {
                    "@type": "type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst"
                }
            },
            {
                "name": "envoy.filters.listener.original_src",
                "typedConfig": {
                    "@type": "type.googleapis.com/envoy.extensions.filters.listener.original_src.v3.OriginalSrc",
                    "mark": 1337
                }
            }
        ...
        ]
        "listenerFiltersTimeout": "0s",
        "continueOnListenerFiltersTimeout": true,
        "transparent": true,
        "trafficDirection": "INBOUND",
        "accessLog": [...]
    }
]

envoy.filters.listener.original_src通過tcp option實現修改upstream_local_addressdownstream_remote_address,實現透傳client IP。

/// source/extensions/filters/listener/original_src/original_src.cc

Network::FilterStatus OriginalSrcFilter::onAccept(Network::ListenerFilterCallbacks& cb) {
  auto& socket = cb.socket(); /// ConnectionHandlerImpl::ActiveTcpSocket.socket()
  auto address = socket.addressProvider().remoteAddress();   /// get downstream_remote_address
  ASSERT(address);

  ENVOY_LOG(debug,
            "Got a new connection in the original_src filter for address {}. Marking with {}",
            address->asString(), config_.mark());

...
  auto options_to_add =
      Filters::Common::OriginalSrc::buildOriginalSrcOptions(std::move(address), config_.mark()); 
  socket.addOptions(std::move(options_to_add)); /// Network::Socket::Options
  return Network::FilterStatus::Continue;
}
  • envoy.filters.listener.original_dst

另外,httbin envoy作為 ingressgateway 的接收端,virtualInbound listener還配置了 ListenerFilter(envoy.filters.listener.original_dst),來看看它的作用。

// source/extensions/filters/listener/original_dst/original_dst.cc

Network::FilterStatus OriginalDstFilter::onAccept(Network::ListenerFilterCallbacks& cb) {
  ENVOY_LOG(debug, "original_dst: New connection accepted");
  Network::ConnectionSocket& socket = cb.socket();

  if (socket.addressType() == Network::Address::Type::Ip) { /// socket SO_ORIGINAL_DST option
    Network::Address::InstanceConstSharedPtr original_local_address = getOriginalDst(socket); /// origin dst address

    // A listener that has the use_original_dst flag set to true can still receive
    // connections that are NOT redirected using iptables. If a connection was not redirected,
    // the address returned by getOriginalDst() matches the local address of the new socket.
    // In this case the listener handles the connection directly and does not hand it off.
    if (original_local_address) { /// change local address to origin dst address
      // Restore the local address to the original one.
      socket.addressProvider().restoreLocalAddress(original_local_address);
    }
  }

  return Network::FilterStatus::Continue;
}

對於 istio,由 iptable 截持原有 request,並轉到15006(in request),或者15001(out request)埠,所以,處理 request 的 socket 的local address,並不請求的original dst addressoriginal_dst ListenerFilter負責將 socket 的 local address 改為original dst address

對於virtualOutbound listener,不會直接新增envoy.filters.listener.original_dst,而是將use_original_dst設定為 true,然後 envoy 會自動新增envoy.filters.listener.original_dst。同時,virtualOutbound listener會將請求,轉給請求原目的地址關聯的 listener 進行處理。

對於virtualInbound listener,會直接新增envoy.filters.listener.original_dst。與virtualOutbound listener不同的是,它只是將地址改為original dst address,而不會將請求轉給對應的 listener 處理(對於入請求,並不存在 dst address 的 listener)。實際上,對於入請求是由 FilterChain 完成處理。

參考 istio 生成virtualInbound listener的程式碼:

// istio/istio/pilot/pkg/networking/core/v1alpha3/listener_builder.go

func (lb *ListenerBuilder) aggregateVirtualInboundListener(passthroughInspectors map[int]enabledInspector) *ListenerBuilder {
	// Deprecated by envoyproxy. Replaced
	// 1. filter chains in this listener
	// 2. explicit original_dst listener filter
	// UseOriginalDst: proto.BoolTrue,
	lb.virtualInboundListener.UseOriginalDst = nil
	lb.virtualInboundListener.ListenerFilters = append(lb.virtualInboundListener.ListenerFilters,
		xdsfilters.OriginalDestination, /// 新增envoy.filters.listener.original_dst
	)
	if lb.node.GetInterceptionMode() == model.InterceptionTproxy { /// TPROXY mode
		lb.virtualInboundListener.ListenerFilters =
			append(lb.virtualInboundListener.ListenerFilters, xdsfilters.OriginalSrc)
	}
...

小結

基於 TPROXY 以及 Proxy Protocol,我們可以在 istio 中,實現四層協議的客戶端源 IP 的保持。

參考

關於我們

更多關於雲原生的案例和知識,可關注同名【騰訊雲原生】公眾號~

福利:

①公眾號後臺回覆【手冊】,可獲得《騰訊雲原生路線圖手冊》&《騰訊雲原生最佳實踐》~

②公眾號後臺回覆【系列】,可獲得《15個系列100+篇超實用雲原生原創乾貨合集》,包含Kubernetes 降本增效、K8s 效能優化實踐、最佳實踐等系列。

③公眾號後臺回覆【白皮書】,可獲得《騰訊雲容器安全白皮書》&《降本之源-雲原生成本管理白皮書v1.0》

④公眾號後臺回覆【光速入門】,可獲得騰訊雲專家5萬字精華教程,光速入門Prometheus和Grafana。

相關文章