作者
尹燁,騰訊專家工程師, 騰訊雲 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 envoy
的upstream_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: TPROXY
,virtualInbound 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_address
為downstream_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 address
。original_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 的保持。
參考
- istio doc: Configuring Gateway Network Topology
- IP Transparency and Direct Server Return with NGINX and NGINX Plus as Transparent Proxy
- Kernel doc: Transparent proxy support
- Haproxy doc: The PROXY protocol
- Envoy doc: IP Transparency
- 【IstioCon 2021】如何在Istio中進行源地址保持?
關於我們
更多關於雲原生的案例和知識,可關注同名【騰訊雲原生】公眾號~
福利:
①公眾號後臺回覆【手冊】,可獲得《騰訊雲原生路線圖手冊》&《騰訊雲原生最佳實踐》~
②公眾號後臺回覆【系列】,可獲得《15個系列100+篇超實用雲原生原創乾貨合集》,包含Kubernetes 降本增效、K8s 效能優化實踐、最佳實踐等系列。
③公眾號後臺回覆【白皮書】,可獲得《騰訊雲容器安全白皮書》&《降本之源-雲原生成本管理白皮書v1.0》
④公眾號後臺回覆【光速入門】,可獲得騰訊雲專家5萬字精華教程,光速入門Prometheus和Grafana。