Red Hat OpenShift on IBM Cloud(Classic Infrastructure): Podまでのアクセス経路(DNS -> NLB -> Router -> Pod)を追いかけてみる

# 1. はじめに
Red Hat OpenShift on IBM CloudにはClassic Infrastructure版とVPC版がありますが、この記事ではClassic Infrastructure版で確認しています。[VPC版](https://qiita.com/testnin2/items/27d9a7311be80096924a)はこちらです。

OpenShiftではアプリケーションPodで稼働するサービスを**Route**を使って外部公開することが可能です。でも、実際に外部からのアクセスはどのような経路を辿ってアプリケーションPodにアクセスしているのでしょうか？
Red Hat OpenShift on IBM Cloudでは、[ここ](https://cloud.ibm.com/docs/openshift?topic=openshift-ingress-about-roks4#classic-multi-roks4)に公式の説明がありますが、この記事では実際に処理を追いかけてみることで、実装を深く理解したいと思います。

最初に結論を書いておきますが、

1. DNSによる名前解決を行い、NLB Podが保護するVIPを取得。
2. NLB Pod(`ibm-cloud-provider-ip-<IPアドレス>-xxxxxxxx`)にアクセス。
    - NLB=Network Load Balancer = L4 Load Balancer
    - 所謂、Kubernetesの`type: LoadBalancer`のCloud Provider実装。
    - NLB Podでは、keepalivedを利用してVIP(Public IP)を保護
    - (keepalivedではなく）iptablesを使ってRouter Podにk8s NW経由で割り振りを行う。
3. Router Pod(router-default-xxxxxx)にアクセス
    - Router PodはPrivate IP(172.30.xx.xx)を持つ
    - HAProxyを利用してL7 Load Balancerを提供
    - HAProxyの機能で（HTTPヘッダを元に）Application Podに割り振りを行う。
4. Application Podにアクセス

という流れになります。

<img width="750" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62127/08cdfb2a-5af6-ec82-3248-7a88e8b8fe6a.png">




# 2. 事前準備

この環境では、TOK02/TOK04/TOK05にまたがるマルチゾーンクラスターを利用しています。
また、以下のようにアプリケーションを展開し、Routeを作成します。これによって、外部からRoute経由でこのアプリケーションPodにアクセス可能になります。

```
$ oc new-app --name hello-world https://github.com/IBM/container-service-getting-started-wt --context-dir="Lab 1"

$ oc scale --replicas=5 dc hello-world

$ oc expose service hello-world

$ oc get pods,svc,route
NAME                       READY   STATUS      RESTARTS   AGE
pod/hello-world-1-2r2dr    1/1     Running     0          8m17s
pod/hello-world-1-build    0/1     Completed   0          8m50s
pod/hello-world-1-deploy   0/1     Completed   0          8m19s
pod/hello-world-1-gssg9    1/1     Running     0          25s
pod/hello-world-1-kw7bp    1/1     Running     0          25s
pod/hello-world-1-sk48q    1/1     Running     0          25s
pod/hello-world-1-x27l5    1/1     Running     0          25s

NAME                  TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/hello-world   ClusterIP   172.21.81.250   <none>        8080/TCP   8m53s

NAME                                   HOST/PORT                                                                                                                       PATH   SERVICES      PORT       TERMINATION   WILDCARD
route.route.openshift.io/hello-world   hello-world-syasuda.myrokscluster43-xxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-0000.jp-tok.containers.appdomain.cloud ... 1 more          hello-world   8080-tcp                 None
```

# 3. Routerへのアクセス経路を追いかける

## 3-1. DNS名前解決

Routeで公開されたFQDNを名前解決すると、TOK02/TOK04/TOK05の複数拠点のPublic IPアドレスが返ってきます。

```shell-session:DNS名前解決
$ dig A +noall +answer @1.1.1.1 hello-world-syasuda.myrokscluster43-xxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-0000.jp-tok.containers.appdomain.cloud
```

なお、これらのアドレスは、router-tok02/router-tok04/router-tok05などのLoadBalancer Serviceで構成されているEXTERNAL-IPと同一です。

```
$ oc get services -n openshift-ingress
NAME                      TYPE           CLUSTER-IP       EXTERNAL-IP       PORT(S)                      AGE
router-default            LoadBalancer   172.21.200.228   128.168.xx.xxx    80:31712/TCP,443:32543/TCP   64d
router-internal-default   ClusterIP      172.21.57.171    <none>            80/TCP,443/TCP,1936/TCP      64d
router-tok02              LoadBalancer   172.21.205.181   161.202.xx.xxx    80:30370/TCP,443:32261/TCP   41s
router-tok04              LoadBalancer   172.21.108.161   128.168.xx.xxx    80:31380/TCP,443:30034/TCP   64d
router-tok05              LoadBalancer   172.21.103.123   165.192.xx.xxx    80:32357/TCP,443:30142/TCP   64d
```

ここでは、128.168.xx.xx宛の処理を追いかけてみたいと思います。

## 3-2. NLB Pod
128.168.xx.xx を持つPodを探してみると、`ibm-cloud-provider-ip-<IPアドレス>-xxxxxxxx`というPodが見つかります。これは`NLB Pod`と呼ばれているPodであり、router-tok02/router-tok04/router-tok05などのLoadBalancer Service(=Router Service)の実体にあたります。

```
$ oc get pods --all-namespaces -o wide |grep 128.168.xx.xx
ibm-system                                              ibm-cloud-provider-ip-128-168-xx-xx-c8b78c69b-hpc2k              1/1     Running      0          33d     10.192.109.137   10.192.109.137   <none>           <none>
ibm-system                                              ibm-cloud-provider-ip-128-168-xx-xx-c8b78c69b-xbpdq              1/1     Running      0          33d     10.192.109.197   10.192.109.197   <none>           <none>
```

次に、NLB Podの構成を確認してみます。

```NLB-Podにログイン
$ oc rsh -n ibm-system ibm-cloud-provider-ip-128-168-xx-xx-c8b78c69b-hpc2k

/ # ps -ef
PID   USER     TIME  COMMAND
    1 root      3:32 /usr/local/bin/keepalived
   22 root      0:00 /usr/sbin/keepalived --dont-fork --dump-conf --log-console --log-detail --release-vips --address-monitoring
   23 root     34:12 /usr/sbin/keepalived --dont-fork --dump-conf --log-console --log-detail --release-vips --address-monitoring
   26 root      0:00 /bin/sh
   32 root      0:00 ps -ef
```

このPodの実態はkeepalivedが動いていることがわかります。
keepalived.confの構成を確認してみます。

```txt:keepalived.conf
/ # cat /etc/keepalived/keepalived.conf
global_defs {
    vrrp_mcast_group4 224.0.0.18
}

vrrp_instance vip-128.168.xx.xx {
    state BACKUP
    interface eth1
    virtual_router_id 14
    priority 100
    nopreempt
    virtual_ipaddress {
        128.168.xx.xx
    }
}
```

eth1の構成も確認してみます。

```txt:VRRPで保護されるportable IPがeth1に付与されている。
/ $ ip a show dev eth1
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 1000
    link/ether 06:d2:12:a3:a5:f5 brd ff:ff:ff:ff:ff:ff
    inet xxx.xxx.xx.xx/27 brd xxx.xxx.xx.xx scope global noprefixroute eth1
       valid_lft forever preferred_lft forever
    inet 128.168.xx.xx/32 scope global eth1
       valid_lft forever preferred_lft forever
    inet6 fe80::4d2:12ff:fea3:a5f5/64 scope link
       valid_lft forever preferred_lft forever
```

結果、`128.168.xx.xx`をVIPとしてeth1に付与し、障害時には別Podに引き継げるように構成されていることがわかります。

（2023年8月補足）
この環境では取得し損ねたが、以下は別環境でのtcpdumpの結果。確かにVRRPプロトコルがeth1(Public Interface)上で実行されていることが分かる。(1 node構成なので1箇所からしか出力されていないが）

```txt:NLB Pod内で実行したtcpdumpの結果
/ $ sudo tcpdump -i eth1 vrrp -nn
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), snapshot length 262144 bytes
02:04:07.571433 IP 161.202.xx.xx > 224.0.0.18: VRRPv2, Advertisement, vrid 93, prio 100, authtype none, intvl 1s, length 20
02:04:08.571544 IP 161.202.xx.xx > 224.0.0.18: VRRPv2, Advertisement, vrid 93, prio 100, authtype none, intvl 1s, length 20
02:04:09.571598 IP 161.202.xx.xx > 224.0.0.18: VRRPv2, Advertisement, vrid 93, prio 100, authtype none, intvl 1s, length 20
02:04:10.571682 IP 161.202.xx.xx > 224.0.0.18: VRRPv2, Advertisement, vrid 93, prio 100, authtype none, intvl 1s, length 20
02:04:11.571790 IP 161.202.xx.xx > 224.0.0.18: VRRPv2, Advertisement, vrid 93, prio 100, authtype none, intvl 1s, length 20VRRPv2, Advertisement, vrid 222, prio 100, authtype none, intvl 1s, length 20


/ $ sudo tcpdump -i eth0 vrrp -nn
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
（出力なし）
```

（補足終わり）


ただし、keepalived.confには転送先の情報が出力されていません。つまり、keepalivedはVIPの管理だけを担当しており、パケットの割り振りは担当していません。代わりに、NLB Podにおける割り振り処理はiptablesで実施されます(iptablesはkube-proxyによって制御されます）。iptablesで`KUBE-SERVICES` Chainを確認してみます。

```iptables-KUBE-SERVICES
/ # iptables -L "KUBE-SERVICES" -v -n -t nat |grep "loadbalancer"
    0     0 KUBE-FW-DUBAWALOAOQGHLZQ  tcp  --  *      *       0.0.0.0/0            128.168.xx.xx       /* openshift-ingress/router-tok04:http loadbalancer IP */ tcp dpt:80
    0     0 KUBE-FW-LIGRWE2RGSK5GETQ  tcp  --  *      *       0.0.0.0/0            165.192.xx.xx       /* openshift-ingress/router-tok05:https loadbalancer IP */ tcp dpt:443
    0     0 KUBE-FW-VEWSEELRREPOPKKP  tcp  --  *      *       0.0.0.0/0            161.202.xx.xx      /* openshift-ingress/router-tok02:http loadbalancer IP */ tcp dpt:80
    0     0 KUBE-FW-MBAZS3WDHL45BPIZ  tcp  --  *      *       0.0.0.0/0            128.168.xx.xx       /* openshift-ingress/router-default:https loadbalancer IP */ tcp dpt:443
    0     0 KUBE-FW-QDH42CU33EM2QUE5  tcp  --  *      *       0.0.0.0/0            128.168.xx.xx       /* openshift-ingress/router-tok04:https loadbalancer IP */ tcp dpt:443
    0     0 KUBE-FW-DXUYFP57AWJVW53Q  tcp  --  *      *       0.0.0.0/0            161.202.xx.xx      /* openshift-ingress/router-tok02:https loadbalancer IP */ tcp dpt:443
    0     0 KUBE-FW-HEVFQXAKPPGAL4BV  tcp  --  *      *       0.0.0.0/0            128.168.xx.xx       /* openshift-ingress/router-default:http loadbalancer IP */ tcp dpt:80
    0     0 KUBE-FW-OMKAGPVWEYNWKEWW  tcp  --  *      *       0.0.0.0/0            165.192.xx.xx       /* openshift-ingress/router-tok05:http loadbalancer IP */ tcp dpt:80
```

（もう忘れてしまったかもしれませんが128.168.xx.xx宛の処理を追いかけていたので）ここでは、80番ポートである一番上の"KUBE-FW-DUBAWALOAOQGHLZQ"を追いかけてみます。

```
/ # iptables -L "KUBE-SVC-DUBAWALOAOQGHLZQ" -v -n -t nat
Chain KUBE-SVC-DUBAWALOAOQGHLZQ (3 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-SEP-HGNA5KMN5EENFF6N  all  --  *      *       0.0.0.0/0            0.0.0.0/0            statistic mode random probability 0.50000000000
    0     0 KUBE-SEP-H7OGC53763Z5IA6P  all  --  *      *       0.0.0.0/0            0.0.0.0/0
```

以上より、`KUBE-SEP-HGNA5KMN5EENFF6N`および`KUBE-SEP-H7OGC53763Z5IA6P`に等確率で割り振りを行なっていることがわかります(SEPはService Endpointの意味でしょう）。


```
/ # iptables -L "KUBE-SEP-HGNA5KMN5EENFF6N" -v -n -t nat
Chain KUBE-SEP-HGNA5KMN5EENFF6N (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-MARK-MASQ  all  --  *      *       172.30.34.96         0.0.0.0/0
    0     0 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp to:172.30.34.96:80

/ # iptables -L "KUBE-SEP-H7OGC53763Z5IA6P" -v -n -t nat
Chain KUBE-SEP-H7OGC53763Z5IA6P (1 references)
 pkts bytes target     prot opt in     out     source               destination
    0     0 KUBE-MARK-MASQ  all  --  *      *       172.30.97.241        0.0.0.0/0
    0     0 DNAT       tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp to:172.30.97.241:80
```

結果、NLB Podでは`128.168.xx.xx`宛のリクエストは`172.30.34.96:80`および`172.30.97.241:80`に等確率でDNATされていることがわかります。

ちなみに、NLB Podからどこに割り振られているかは、本来は以下から確認することができます。

```
$ oc describe service/router-default -n openshift-ingress|grep -i endpoints
Endpoints:                172.30.34.96:80,172.30.97.241:80
Endpoints:                172.30.34.96:443,172.30.97.241:443
$ oc describe service/router-tok02 -n openshift-ingress|grep -i endpoints
Endpoints:                172.30.34.96:80,172.30.97.241:80
Endpoints:                172.30.34.96:443,172.30.97.241:443
$ oc describe service/router-tok04 -n openshift-ingress|grep -i endpoints
Endpoints:                172.30.34.96:80,172.30.97.241:80
Endpoints:                172.30.34.96:443,172.30.97.241:443
$ oc describe service/router-tok05 -n openshift-ingress|grep -i endpoints
Endpoints:                172.30.34.96:80,172.30.97.241:80
Endpoints:                172.30.34.96:443,172.30.97.241:443
```

## 3-3. Router Pod

NLB Podが割り振りしている`172.30.34.96:80`および`172.30.97.241:80`は何でしょうか？

```
# oc get pods --all-namespaces -o wide|grep -e 172.30.34.96 -e 172.30.97.241
openshift-ingress                                       router-default-5d4497844b-fqhd6                                   1/1     Running      0          12d     172.30.34.96     10.132.94.75     <none>           <none>
openshift-ingress                                       router-default-5d4497844b-w4jpg                                   1/1     Running      0          12d     172.30.97.241    10.193.75.36     <none>           <none>
```

これにより、`router-default`というPodに割り振られていることが分かります。これは`Router Pod`です。`Router Pod`は１つのクラスターにデフォルトで2つ作成されています。この`Router Pod`にログインして構成を確認してみます。

```
# oc rsh -n openshift-ingress router-default-5d4497844b-fqhd6

sh-4.2$ ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
1000280+      1      0  1 Jun17 ?        05:18:04 /usr/bin/openshift-router
1000280+   4713      1  1 02:38 ?        00:00:02 /usr/sbin/haproxy -f /var/lib/haproxy/conf/haproxy.config -p /var/lib/haproxy/run/haproxy.pid -x /var/lib/haproxy/run/haproxy.sock -sf 4703 4693
1000280+   4720      0  0 02:42 pts/0    00:00:00 /bin/sh
1000280+   4726   4720  0 02:42 pts/0    00:00:00 ps -ef
```

`Router Pod`内ではHAProxyが稼働していることが分かります。HAProxyの構成を確認してみます。

```txt:/var/lib/haproxy/conf/haproxy.config
sh-4.2$ cat /var/lib/haproxy/conf/haproxy.config
（途中略）
frontend public

  bind :80
  mode http
  tcp-request inspect-delay 5s
  tcp-request content accept if HTTP
  monitor-uri /_______internal_router_healthz

  # Strip off Proxy headers to prevent HTTpoxy (https://httpoxy.org/)
  http-request del-header Proxy

  # DNS labels are case insensitive (RFC 4343), we need to convert the hostname into lowercase
  # before matching, or any requests containing uppercase characters will never match.
  http-request set-header Host %[req.hdr(Host),lower]

  # check if we need to redirect/force using https.
  acl secure_redirect base,map_reg(/var/lib/haproxy/conf/os_route_http_redirect.map) -m found
  redirect scheme https if secure_redirect

  use_backend %[base,map_reg(/var/lib/haproxy/conf/os_http_be.map)]

  default_backend openshift_default

# public ssl accepts all connections and isn't checking certificates yet certificates to use will be
# determined by the next backend in the chain which may be an app backend (passthrough termination) or a backend
# that terminates encryption in this router (edge)
frontend public_ssl

  bind :443
  tcp-request  inspect-delay 5s
  tcp-request content accept if { req_ssl_hello_type 1 }

  # if the connection is SNI and the route is a passthrough don't use the termination backend, just use the tcp backend
  # for the SNI case, we also need to compare it in case-insensitive mode (by converting it to lowercase) as RFC 4343 says
  acl sni req.ssl_sni -m found
  acl sni_passthrough req.ssl_sni,lower,map_reg(/var/lib/haproxy/conf/os_sni_passthrough.map) -m found
  use_backend %[req.ssl_sni,lower,map_reg(/var/lib/haproxy/conf/os_tcp_be.map)] if sni sni_passthrough

  # if the route is SNI and NOT passthrough enter the termination flow
  use_backend be_sni if sni

  # non SNI requests should enter a default termination backend rather than the custom cert SNI backend since it
  # will not be able to match a cert to an SNI host
  default_backend be_no_sni
（途中略）
# Plain http backend or backend with TLS terminated at the edge or a
# secure backend with re-encryption.
backend be_http:syasuda:hello-world
  mode http
  option redispatch
  option forwardfor
  balance leastconn

  timeout check 5000ms
  http-request set-header X-Forwarded-Host %[req.hdr(host)]
  http-request set-header X-Forwarded-Port %[dst_port]
  http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
  http-request set-header X-Forwarded-Proto https if { ssl_fc }
  http-request set-header X-Forwarded-Proto-Version h2 if { ssl_fc_alpn -i h2 }
  # Forwarded header: quote IPv6 addresses and values that may be empty as per https://tools.ietf.org/html/rfc7239
  http-request add-header Forwarded for=%[src];host=%[req.hdr(host)];proto=%[req.hdr(X-Forwarded-Proto)];proto-version=\"%[req.hdr(X-Forwarded-Proto-Version)]\"
  cookie 50180e4b662b224b6f27aade3ab06d5c insert indirect nocache httponly
  server pod:hello-world-1-2r2dr:hello-world:172.30.150.205:8080 172.30.150.205:8080 cookie 7dc27513af58658ee891b9a99d171e72 weight 256 check inter 5000ms
  server pod:hello-world-1-kw7bp:hello-world:172.30.237.72:8080 172.30.237.72:8080 cookie 16b5b8f6fa87b14eceaee9bb30ccab4b weight 256 check inter 5000ms
  server pod:hello-world-1-gssg9:hello-world:172.30.34.127:8080 172.30.34.127:8080 cookie ae3543b5497fe908a7ecb8d3aac1b121 weight 256 check inter 5000ms
  server pod:hello-world-1-sk48q:hello-world:172.30.71.240:8080 172.30.71.240:8080 cookie 01ab9b2e14d412bb6c02c327b59a0c6d weight 256 check inter 5000ms
  server pod:hello-world-1-x27l5:hello-world:172.30.97.254:8080 172.30.97.254:8080 cookie e42a33a091d7baffdda4d61065997f3c weight 256 check inter 5000ms
```

以上より、`server`のセクションからわかるとおり、HAProxyがアプリケーションPod(hello-world)に直接割り振りを行なっていることがわかります。
また、`backend be_http:syasuda:hello-world`にて、router-pod内でのリクエストの処理が分類されています。実際、以下で`hello-world-syasuda.myrokscluster43-xxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-0000.jp-tok.containers.appdomain.cloud`というHost ヘッダを含む場合は、`be_http:syasuda:hello-world`で定義される設定を利用する旨の記述があります。

```
sh-4.2$ cat /var/lib/haproxy/conf/os_http_be.map|grep hello
^hello-world-syasuda\.myrokscluster43-xxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-0000\.jp-tok\.containers\.appdomain\.cloud(:[0-9]+)?(/.*)?$ be_http:syasuda:hello-world
```

（参考）
もし、`$ oc expose service hello-world --hostname www.yasuda.com`のように独自ドメインを使用した場合（というかこっちの方が一般的だが）は、以下のようになっている。

```
sh-4.2$ cat /var/lib/haproxy/conf/haproxy.config|grep backend|grep hello-world
backend be_http:syasuda:hello-world
sh-4.2$ cat /var/lib/haproxy/conf/os_http_be.map|grep hello
^www\.yasuda\.com(:[0-9]+)?(/.*)?$ be_http:syasuda:hello-world
```