讓任何 OCI image 都能擁有 VPN Kill Switch 的功能
前一陣子發現一個滿熱門的 BT client 專案 haugene/docker-transmission-openvpn,裡面有各種常見 VPN Providers 的設定,而且他還有用 iptables 寫好 Kill Switch 的功能,覺得還滿懶人的,很好上手。
後來仔細想想如果只是要讓 container 能跑在 VPN 的環境就要弄出一個新的 image 感覺好像沒辦法把服務切的很乾淨,於是就開始著手研究有沒有辦法可以做到這件事但是不需要重新 build image。
動機
主要是因為之前 VPN 都是讓 router (Asus RT-AC66U) 來做,也有做到 policy route 還有 kill switch 的功能,但是因為這台 router 的 CPU 不是說特別好,CPU 跑滿的情況還只能達到 1MB/s 實在是慢到有點受不了。
另外,因為現在住的宿舍每天有限制對外流量,超過流量後就會限速,有時候就必須轉成手機的網路,但是不知道為什麼目前 router 用的韌體 (Merlin) 透過 USB 換到手機的網路的時候有機率會重開機,導致每次做這件事都會有大概 5 分鐘的 down time。
後來想說還是把 VPN 搬到機器上來做效率可能會比較好,而且要切換網路還有路由也比較方便一點,因此就來研究怎麼不修改 image 就能做到這件事。
Sidecar Pattern
透過建立 vpn 的 sidecar container 來附加 vpn 功能到 image 上。
Docker
翻了一下 docker 的文件,發現 network 這個設定有 container
這個選項可以用,說明是這樣的
‘container:<name|id>': reuse another container’s network stack
原理就是讓宣告這個選項的 container 能夠跟指定的 container 共享 linux 上的 network namespace 來共享 container 間的網路資源。使用 docker-compose 時,要使用 network_mode 這個關鍵字,除了 container
這個 keyword,也能使用 service
指定相同 compose 下的服務名稱。
以下是測試的範例,使用了 dperson/openvpn-client 這個 OpenVPN client:
|
|
container 起來後,用 docker-compose exec test /bin/sh
attach 進去,可以看到 test
這個 container 也有 vpn
這個 container 設定的 tun0 interface 以及 default route:
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
2: tun0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UNKNOWN group default qlen 100
link/none
inet 10.8.8.10/24 brd 10.8.8.255 scope global tun0
valid_lft forever preferred_lft forever
102: eth0@if103: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:13:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.19.0.2/16 brd 172.19.255.255 scope global eth0
valid_lft forever preferred_lft forever
/ # ip r
0.0.0.0/1 via 10.8.8.1 dev tun0
default via 172.19.0.1 dev eth0
10.8.8.0/24 dev tun0 proto kernel scope link src 10.8.8.10
128.0.0.0/1 via 10.8.8.1 dev tun0
172.19.0.0/16 dev eth0 proto kernel scope link src 172.19.0.2
212.102.42.84 via 172.19.0.1 dev eth0
缺點
但是這樣做不是完美的,我遇到了幾個比較煩的問題
port binding
如果要在 test
這個 container expose port 的話,會遇到這個問題:
ERROR: for vpn_test_1 Cannot create container for service test: conflicting options: port publishing and the container type network mode
network_mode: service
是無法跟 port publishing 共存的,因此如果要 expose port 的話,必需要在 vpn
這個 container 上做設定,但這樣整份 config 看起來就會很奇怪。
dependency recreation
如果 vpn
這個 container 因為一些原因重新建立了,像是更改環境變數或是升級 image,test
這個 container 不會重新建立,於是就會遇到這個問題:
ERROR: for vpn_test_1 Cannot restart container 9345feb04c10564c0a9443891bd4e6dd67d01a7d822f855b5b901d7a618fba56: No such container: cce530d075a16267da10213949622244cdbbcff40070b38c1a12ff1f9c40325f
因為 vpn
這個 container 重新建立後 container id 變了,原先 test
依賴的 container 消失了所以才會遇到這個問題。雖然可以透過 docker-compose up --always-recreate-deps -d
來強制重新建立所有有 dependency 關係的 container,但這樣實在是不太方便。
bug
不知道為什麼重開的時候有時候會遇到這個錯誤,要再重新下一次指令才會成功:
ERROR: for vpn_test_1 Cannot start service vpn_test_1: Container bb98419511b1aaa72a897bfcb8b61d01ff8508dbd7aa9af9136569b075d3c073 is restarting, wait until the container is running
autorestart
沒有偵錯機制來判斷目前 VPN 的狀況,像是如果 tunnel 因為一些原因掉了,應該要能夠自己 restart container。
Podman
後來發現 podman 的 pod 滿符合我想要達到的目的,pod 對網路資源的定義是:
By definition, all containers in the same Podman pod share the same network namespace. Therefore, the containers will share the IP Address, MAC Addresses and port mappings. You can always communicate between containers in the same pod, using localhost.
透過建立 pod 來讓 container 共享 network namespace,但是 podman 的缺點是雖然有 podman-compose 這樣的專案,但是無法在文件中定義 pod。只能使用像是 podman play kube ...
這個指令來跑 k8s 的 config,而且支援的範圍有限。既然都要用到 k8s 的 config,這樣我就直接使用 kubectl 了,也比較省事一點。
針對上面的提到問題,在使用 pod 後都能夠有辦法解決:
- podman 的 port binding 是針對 pod 的,所以不會有 docker 的問題
- network namespace 是透過 pod 而不是 container,因此
vpn
的 container 重新建立後不會有依賴問題 - 透過
livenessProbe
以及startupProbe
來檢查 vpn 的狀態,如果掉了就 kill container,並透過restartPolicy
決定是否要重啟 container
因為這個使用情景滿簡單的,k3s 提供的功能就已經能做到了,而且安裝也比較輕鬆,所以就拿 k3s 當作例子。
支援 livenessProbe 的 podman 最近有新的 PR 並且已經被 merge,應該過不久就會 release
K3S
定義好 pod 以及 container 的資訊後,透過 kubectl apply -f test.yml
來建立 pod:
|
|
livenessProbe 會在 startupProbe 成功後接手,可以透過 ifconfig tun0 down
來檢查 livenessProbe。
更換 image 的測試,可以把設定中的 openvpn-client:latest
換成 openvpn-client:amd64
,接著再執行一次 kubectl apply -f test.yml
,pod 中的 container 會更換為新的 image。
缺點
缺少 dependency 機制
如果沒有 dependency 機制,就不能確保 vpn
這個 container 在做 tunnel 的設定時其他 container 不會有流量漏出。
雖然說 k8s 有 initContainers
可以用,但是 initContainers
會在最初執行完就結束。檢查 dependency 在 k3s 相較 docker 提供的 depends_on
關鍵字就會麻煩許多。
每開一個新的 pod 就要建立一個新的 VPN connection
由於是透過共享 network namespace 實現的,因此如果有其他 pod 也想使用 VPN 時,必須要再重複完全一樣的 service。
VPN gateway with sidecar pattern
後來覺得比較好的實作方式還是應該要有一個負責做 vpn 的 container,然後讓其他需要有 vpn 功能的 container 把路由導過去。
k8s@home/pod-gateway
找了一下發現已經有人做好我想要的所有功能了,但是搞懂設定怎麼調還是花了不少力氣,總之運作原理大概就像下面這個動畫:
首先會有一個 namespace 是負責做 VPN,在這裡取名為 VPN-GW,裡面有包含一個 OpenVPN (或是 WireGuard) 的 pod,以及一個用來實作 admission webhook 的 pod。在建立這個 namespace 時可以選擇要讓哪些 namespace 的 pod 有 VPN 的功能,這裡為了簡單只有一個叫做 VPN 的 namespace,裡面放著各種要通過 VPN 才能出去的服務的 pod。
在 VPN 這個 namespace 建立 pod 的時候,webhook 會把一個 initContainer client-init
以及一個 container client-sidecar
同時放進這個 pod 中,用來處理介面以及路由。
在最初 VPN-GW 這個 pod 啟動時,會有一個 initContainer gateway-init
負責建立 VXLAN 的 tunnel 通到所有指定的 namespace,同時處理 iptables 的白名單,僅允許通往 VPN provider 的流量,以及設定 NAT。在 gateway-init
執行完初始化後,OpenVPN
會負責建立 VPN tunnel 以及 kill switch 功能,讓流量僅能透過 tun
以及 tap
interface 出去。最後 sidecar container 會負責維護 DHCP 以及 DNS server。
在 service 的 pod 啟動時,client-init
會負責 DHCP 以及設定 gateway 的工作,接著服務及 sidecar 啟動後,會定期檢查 gateway 的連線,如果掉了會再重新執行一次 DHCP。
VPN-GW 這個 pod 可以使用 livenessProbe
來檢查 VPN 的連線,在 namespace 的層級也可以使用 networkpolicies resource 來限制流量只能通往 VPN 服務的 port 或是限制 VPN namespace 僅能與 k8s 內部的 ip 進行溝通。
安裝
首先需要安裝 helm 以及 cert-manager(這個 helm chart 的 dependency)
kubectl apply -f https://github.com/jetstack/cert-manager/releases/latest/download/cert-manager.yaml
建立 vpn-gw 的 namespace:
kubectl create ns vpn-gw
建立 vpn 的 namespace,規定是要有 routed-gateway
這個 label:
|
|
kubectl apply -f vpn-ns.yml
初始設定
首先先檢查一下沒開 VPN 的情況 gateway 能不能正常工作,DNS IP 可能需要根據不同的環境設定:
這邊的其他可以使用的設定參考 k8s@home/library-charts 以及 k8s@home/charts
|
|
接著透過 helm 建立 pod-gateway 在 vpn-gw 這個 namespace:
helm install pod-gateway k8s-at-home/pod-gateway \
--kubeconfig /etc/rancher/k3s/k3s.yaml \
-n vpn-gw \
-f values.yml
接著建立一個測試用的 pod:
|
|
kubectl create -f vpn-test.yml -n vpn
檢查 vpn 的 namespace,可以看到 hook 幫我們建立了除了 service 的兩個 container:
> kubectl describe pod/vpn-test -n vpn
Init Containers:
gateway-init:
...
Containers:
network-multitool:
...
gateway-sidecar:
...
進到 service 的 container 後,可以發現多了 vxlan0 這個 interface 通到 pod-gateway,並且路由也設好了:
> kubectl exec --stdin --tty -n vpn vpn-test -c terminal -- /bin/sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
3: eth0@if145: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue state UP group default
link/ether 8e:d0:ed:fc:02:c5 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 10.42.0.74/24 brd 10.42.0.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::8cd0:edff:fefc:2c5/64 scope link
valid_lft forever preferred_lft forever
4: vxlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1400 qdisc noqueue state UNKNOWN group default qlen 1000
link/ether 1a:ef:44:af:f4:ad brd ff:ff:ff:ff:ff:ff
inet 172.16.0.237/24 brd 172.16.0.255 scope global vxlan0
valid_lft forever preferred_lft forever
inet6 fe80::18ef:44ff:feaf:f4ad/64 scope link
/ # ip r
default via 172.16.0.1 dev vxlan0
10.42.0.0/24 dev eth0 proto kernel scope link src 10.42.0.74
10.42.0.0/16 via 10.42.0.1 dev eth0
10.43.0.10 via 10.42.0.1 dev eth0
172.16.0.0/24 dev vxlan0 proto kernel scope link src 172.16.0.237
Service Namespcae Network Policy
在負責放 service 的 namespace 可以加上這個 network policy,確保 ingress 以及 egress 僅能通往 k8s 內部的 ip:
|
|
kubectl apply -f vpn-networkpolicy.yaml -n vpn
VPN 設定
雷點:因為我之前有用過 dperson/openvpn-client 這個 container 實作的 kill switch 功能,所以不知道什麼時候 vpn 設定目錄下就有 .firewall
還有 .firewall6
的檔案,導致我就算沒有宣告說要用 container 提供的 kill switch 還是會被強制開啟。讓我一開始設定的時候一直搞不懂為什麼 iptables 會整組換新,還用了很 hack 的方法改寫原本 OpenVPN 的 up script。
雷點2:NOT_ROUTED_TO_GATEWAY_CIDRS
必須包含 k3s Service 的 IP range,不然 DNS 會無法使用,pod 之間也會無法溝通。
總之最後是自己去看他的 code 才發現有這些問題,pod-gateway 在開啟 OpenVPN 後的設定如下,要再視自己的環境還有 VPN Provider 做調整:
|
|
最後檢查一下 iptables:
/ # iptables-save
*filter
:INPUT ACCEPT [15:3212]
:FORWARD DROP [0:0]
:OUTPUT DROP [0:0]
-A INPUT -d 10.42.0.193/32 -j ACCEPT
-A FORWARD -o tun0 -j ACCEPT
-A FORWARD -i tun0 -m state --state RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i tun0 -j REJECT --reject-with icmp-port-unreachable
-A OUTPUT -p udp -m udp --dport 1194 -j ACCEPT
-A OUTPUT -p tcp -m tcp --dport 1194 -j ACCEPT
-A OUTPUT -d 10.0.0.0/8 -j ACCEPT
-A OUTPUT -d 192.168.0.0/16 -j ACCEPT
-A OUTPUT -o tun0 -j ACCEPT
-A OUTPUT -o vxlan0 -j ACCEPT
COMMIT
*nat
:PREROUTING ACCEPT [19:2392]
:INPUT ACCEPT [17:2248]
:OUTPUT ACCEPT [13:1713]
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -j MASQUERADE
COMMIT
接著 attach 進去 service 的 container 檢查自己的 ip,可以發現是連上 VPN 後拿到的 ip 了。
使用範例
這裡放一個 aria2 的 service 範例,在使用 pod-gateway 後,基本上已經跟調 VPN 沒有關係了,只需要寫好 Deployment、Service、Ingress 後將服務佈署在 vpn 這個 namespace 就好,剩下的事情 hook 都會幫我們處理好:
|
|
也可以建立一個 proxy daemon 的 pod 在 vpn namespace 中,不過如果直接使用 hostPort 要注意 routing 問題,因為 default route 會走 vxlan0,很有可能流量會送不回去,需要額外用 initContainers 處理,除此之外如果不使用 Ingress resource 當作入口,前面設定的 networkpolicy 中的 ingress ip range 也要做修改:
|
|
結語
原本是想說直接拿 kubectl 當 docker-compose 用就算了,但後來還是覺得單純用 pod 共享 network namespace 的解法不是很完美,所以又花了一點時間研究了 pod-gateway,設定完後覺得真心覺得不錯用,整個架構看起來也滿乾淨的。
最後把全部有在用的服務都順勢搬到 k3s 上了,而且這樣把網速跑滿 (100Mbps) 後 CPU 大概也才佔 20% 而已,相較之前卡在 router 的速度好上不少。
參考資料
https://github.com/dperson/openvpn-client
https://podman.io/getting-started/network
https://artifacthub.io/packages/helm/k8s-at-home/pod-gateway