iT邦幫忙

2022 iThome 鐵人賽

1
DevOps

那些關於 docker 你知道與不知道的事系列 第 32

Day 32: 換我們的 namespace 解封國境

  • 分享至 

  • xImage
  •  

2023/8/22 更新

鐵人賽文章已集結成書,內容有擴充與更正,可以參考天瓏書局或博客來的網址。

《Docker 實戰 6堂課:56個實驗動手做,掌握 Linux 容器核心技術》

天瓏: https://www.tenlong.com.tw/products/9786263335769
博客來: https://www.books.com.tw/products/0010966356

William 推薦序 透過自己的雙手,掌握那不變的容器技術核心——《Docker 實戰 6 堂課》推薦序


昨天(?) 我們發現如果在我們自己建立出來的 net namespace ns1 裡啟動一個 http server (聽 3000 port),外部是連不上他的,我們用 netstat 指令觀察了一下,發現在 ns1 中是有在 listen 3000 port,但host 沒有在聽 3000。

於是我們去觀察了 Docker 是怎麼做到的,發現再啟動 docker container 時,如果有加上 -p 指令去 publish port,那 Docker 會在 nat table (iptables) 中加上一些規則,於是我們研究了一下這些規則:

ubuntu@ip-xxx:~$ sudo iptables -t nat --list-rules
...略
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
...略
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
  • PREROUTING 這條規則,根據昨天的解釋就是「destination 符合 LOCAL 的,就跳到 DOCKER 這個 chain 去」,至於什麼是 LOCAL,查到一個解釋是 locally hosted IP address(來源),可以用以下指令確認是哪些 IP:
ubuntu@ip-xxx:~$ sudo  ip route show table local type local
local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1
local 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1
local 172.17.0.1 dev docker0 proto kernel scope host src 172.17.0.1
local 172.18.0.1 dev docker1 proto kernel scope host src 172.18.0.1
local 172.31.59.121 dev ens5 proto kernel scope host src 172.31.59.121
  • 那 DOCKER 這條 chain 裡的規則,他的意思是「只要不是從 docker0 進來的,只要是 tcp 的封包且 destination port 是 8080 的,就用 DNAT 這個擴充模組去修改他的目的地位址,修改成 --to--destination 這個參數所設定的 172.17.0.2:80」。
  • 在 OUTPUT 這裡的規則,我們尚未解釋,等下方的實驗完成後,我們再來看看有什麼影響。

今天就讓我們來試試把這些規則加上我們的 ns1 net namespace 看看有沒有一樣的效果!


根據上方對規則的解釋,當 PREROUTING 這條規則符合的時候,會跳到 DOCKER chain 那條規則去,我這邊簡單一點,不要再建立一條 chain,就直接把這兩條規則合併起來:

iptables -t nat -A PREROUTING -m addrtype --dst-type LOCAL ! -i docker1 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

現在就讓我們來試試看:

  1. 先把 ns1 裡的 web server 跑起來:
ubuntu@ip-xxx:~/websrv$ sudo ip netns exec ns1 bash
root@ip-xxx:/home/ubuntu/websrv# node server.js
start to listen 3000

  1. 回到 host 加上我們剛剛整理的那條指令
ubuntu@ip-xxx:~$ sudo iptables -t nat -A PREROUTING -m addrtype --dst-type LOCAL ! -i docker1 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

ubuntu@ip-xxx:~$ sudo iptables -t nat -nvL --line-numbers
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
num   pkts bytes target     prot opt in     out     source               destination
1    21613 1012K DOCKER     all  --  *      *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL
2        1    64 DNAT       tcp  --  !docker1 *       0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL tcp dpt:3000 to:172.18.0.2:3000
...略

加完規則後,用 iptables 指令檢查一下,確認有加成功。

  1. 現在用瀏覽器開啟這台 EC2 的 http://[public IP]:3000,同時我們也錄一下封包,看看跟 Docker 有沒有一樣的效果:

可以成功開啟了!
https://ithelp.ithome.com.tw/upload/images/20221022/20151857qInHknDIFj.png

  • tcpdump for ens5:
    https://ithelp.ithome.com.tw/upload/images/20221022/201518575zK6ClQIv1.png

  • tcpdump for docker1: 這邊跟 Docker 一樣,到 docker1 時,dst IP 已經從 host 的 172.31.59.121 變成 ns1 裡的 172.18.0.2,除了 IP 之外,可以看到 port 也變了,表示我們剛剛加的 iptables rule 有發生作用!
    https://ithelp.ithome.com.tw/upload/images/20221022/20151857ePNYmwOwFE.png

  • tcpdump for veth0 in ns1 net namespace: 透過 docker1 這個 bridge,封包一路被送進來了。
    https://ithelp.ithome.com.tw/upload/images/20221022/20151857Y8JvWQkiV9.png


到這邊我們就成功地讓外部可以存取我們在 net namespace 裡建立的 web serever 了,這樣應該沒問題了吧?

我們測試的時候會用到瀏覽器去開啟網頁測試,有時候懶得去開,想說就在 host 裡利用 curl 測試看看,進行完上述的設定後,在做一些其他的測試的時候,心血來潮地再用了一次 curl,結果...

ubuntu@ip-xxx:~$ curl 172.31.59.121:3000
curl: (7) Failed to connect to 172.31.59.121 port 3000: Connection refused

咦咦咦,不能連,難道是設定壞掉了嗎?這時候如果去 curl 這台的 host IP,去又是可以的,用瀏覽器開,當然也還是可以的,那到底怎麼了呢?所以我就又來錄一下封包,發現如果是對 ens5 的 IP 172.31.59.121 發出請求,不管是 ens5 還是 docker1 都沒有錄到任何封包。

觀察一下 curl 的錯誤訊息,他的錯誤訊息是 Connection refused,這通常表示這台 host 沒有監聽指定的 port,這也很合理,因為我們是在 ns1 裡 listen 了 3000 port 喔。這邊你可能會發現,我們利用 public ip 去測試的時候,對 ens5 錄封包時,明明有看到這一行:

...略
03:48:44.718674 IP 36.228.205.54.49755 > 172.31.59.121.3000:...略
...略

這樣不就表示封包往 172.31.59.121.3000 這裡送是沒有問題的嗎?想想看這邊最大的差異,一個是封包是從外部送進來的,一個是 host 往自己 host 送,先看一下目前 host 上的網路介面:

ubuntu@ip-xxx:~$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    ...略
2: ens5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
...略

這邊還有一個叫做 lo 的網路介面,他是 loopback,這並不是一個實體的網路介面,是邏輯上的,用來讓我們連到自己。在 IPv4,loopback 介面通常會被指派為 127.0.0.0/8 這個區間裡所有的 IP,也就是從 127.0.0.1 到 127.255.255.254 的所有 IP 都可以,我們通常習慣用 127.0.0.1,你可以試試看 ping 127.123.123.123,也會有一樣的效果。

既然有這個東西,而我們又是在這台機器上對自己發出請求,在 ens5 錄不到,那 lo 會有嗎?就讓我們來試試看對 lo 錄封包,同時在這台 host 上面對自己的 ip 發出請求:
https://ithelp.ithome.com.tw/upload/images/20221022/2015185746Z8deYzpu.png

欸,果然就錄到了!

除了走的介面不一樣之外,另外就是當我們從外部對我們的 web server 發出請求時,網路封包是從外面進來的,所以他會進到 PREROUTING 這邊的規則去,但現在是我們從 host 裡發出請求的,還記得我們昨天最後的討論嗎?在 iptables 裡,從本地發出請求的時候,會走的是 OUTPUT 這邊的規則,OUTPUT 走完後會到 POSTROUTING,根本不會進到 PREROUTING 去,而我們剛剛加的規則是加在 PREROUTING 裡,所以對我們從 host 發出去的請求不會發生作用...

那現在要怎麼辦呢?原來我們一直跳過沒有講的那條在 OUTPUT chain 的規則,就是在處理這件事!觀察一下 OUTPUT 的規則,他最後也是跳到 DOCKER chain 去,一樣我們來把規則合併一下:

iptables -t nat -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL ! -i docker0 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

這條規則的意思,簡單地解釋就是「destination 符合 local address,但不是 127.0.0.0/8 的,且 protocol 是 tcp、destination port 是 3000 的,那就用 DNAT 這個擴充模組將目的地位址修改成 --to--destination 這個參數所設定的 172.18.0.2:3000」。

來試試看:

# 加上指令
ubuntu@ip-xxx:~$ sudo iptables -t nat -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL ! -i docker1 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000
iptables v1.8.4 (legacy): Can't use -i with OUTPUT

# 既然如此,那我們把這個 -i 拿掉?
ubuntu@ip-xxx:~$ sudo iptables -t nat -A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

# 觀察一下是否有加成功:
ubuntu@ip-xxx:~$ sudo iptables -t nat -nvL
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
...略

Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
...略

Chain OUTPUT (policy ACCEPT 4 packets, 333 bytes)
 pkts bytes target     prot opt in     out     source               destination
   17  1020 DOCKER     all  --  *      *       0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL
# 加上去了!
    1    60 DNAT       tcp  --  *      *       0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL tcp dpt:3000 to:172.18.0.2:3000
...略

來測試看看能不能連上(緊張...):

ubuntu@ip-xxx:~$ curl 172.31.59.121:3000
Hello container!

喔耶,成功!可以透過 host 的 ip 在 host 上存取 ns1 裡的 web server 了!這時候如果去對 lo 錄封包,你會發現錄不到了,透過 DNAT 的修改,目前 destination IP address 應該已經被改為 172.18.0.2 了,作業系統上的判斷就不會走到 lo 去了,那現在會走到哪幾個介面去呢?你可以試著用 tcpdump 錄錄看,會發現 ens5 也錄不到東西,但 docker1 就會錄到封包,也就是經過上述的規則後,destination 從 172.31.59.121 被改成 172.18.0.2 了。
https://ithelp.ithome.com.tw/upload/images/20221022/20151857qfoPniATcc.png


到這邊結束了嗎?每次這樣問就是還沒...

讓我們來試試看在 host 上對 localhost:3000 發出請求:

ubuntu@ip-xxx:~$ curl localhost:3000
curl: (7) Failed to connect to localhost port 3000: Connection refused

誒,果然沒讓我們失望...用 netstat 看也真的沒有 listen 這個 port:

ubuntu@ip-xxx:~$ sudo netstat -ano | grep 3000

一樣來偷(?)觀察一下 Docker,昨天我們用 Docker 啟動了一個 nginx 的 container,而且把 container 裡的 80 port 對應到 host 的 8080 port (現在也知道是透過 iptables 做到的),那 host 會聽 port 8080 嗎?還是只是透過 iptables 修改封包而使封包可以從 8080 轉到 container 裡的 80 而已呢?

ubuntu@ip-xxx:~$ sudo netstat -ano | grep 8080
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      off (0.00/0/0)
tcp6       0      0 :::8080                 :::*                    LISTEN      off (0.00/0/0)

有耶,host 有在 listen 8080...這又是怎麼做到的呢?觀察一下 process:

ubuntu@ip-xxx:~$ ps aux | grep 8080
root       49825  0.0  0.1 1148604 1832 ?        Sl   Oct17   0:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
root       49833  0.0  0.1 1148348 1736 ?        Sl   Oct17   0:00 /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 8080 -container-ip 172.17.0.2 -container-port 80
ubuntu     81640  0.0  0.0   6308   720 pts/2    S+   06:50   0:00 grep --color=auto 8080

這邊有兩個 process,都是執行 /usr/bin/docker-proxy 這個程式,觀察一下他的參數:

/usr/bin/docker-proxy 
  -proto tcp 
  -host-ip 0.0.0.0 
  -host-port 8080 
  -container-ip 172.17.0.2 
  -container-port 80

大概可以猜到,他會綁定 host 0.0.0.0 並且 listen port 8080,最後轉到 container 裡去。

但我們沒有 /usr/bin/docker-proxy 可以用怎麼辦?沒關係,我們現在應該會很用 iptables 了(???),讓我們利用 iptables 來試試看。


我們先來錄一下封包,看看如果是對 127.0.0.1 發出請求,會發生什麼事,這邊可以猜到應該是要對 lo 錄封包:

https://ithelp.ithome.com.tw/upload/images/20221024/20151857TV5Zdob0r1.png

這邊可以看到,是從 127.0.0.1:36292 送到 127.0.0.1:3000,是不是有點似曾相似的感覺,在前文、我們處理送請求到 host IP (172.31.59.121:3000) 有過很類似的情境,我們當時是對 nat table 的 OUTPUT chain 加上一條修改 destination 的規則,那我們來仿效看看:

iptables -t nat -A OUTPUT -d 127.0.0.1 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

這邊規則大致上跟之前的一樣(protocol 是 tcp, destination port 是 3000),差別是我們針對 destination 是要到 127.0.0.1,然後一樣利用 DNAT 這個擴充模組把目的地修改成 172.18.0.2:3000

加上這條規則後,試試看對 127.0.0.1:3000 發出請求看看:

ubuntu@ip-xxx:~$ sudo iptables -t nat -A OUTPUT -d 127.0.0.1 -p tcp -m tcp --dport 3000 -j DNAT --to-destination 172.18.0.2:3000

ubuntu@ip-xxx:~$ curl 127.0.0.1:3000
# 不再是 Connection refused 這個錯誤了,但沒有任何回應

因為不再是 Connection refused 這個錯誤,而是沒有回應,所以我猜測可能是有送出請求,但沒有收到 response,那我們來對 docker1 錄封包確認看看:
https://ithelp.ithome.com.tw/upload/images/20221025/20151857SL149xjUd1.png

docker1 已經有收到對 172.18.0.2:3000 發出的請求了。我們接著進入 ns1 裡去錄錄看封包:
https://ithelp.ithome.com.tw/upload/images/20221025/20151857SxJjyjWnal.png

ns1 裡的 veth0 也是有收到請求,但卻沒有 response,為什麼呢?我注意到 request 請求的 src 是 127.0.0.1,但是我們現在是在 ns1 這個 net namespace 裡耶,如果在這個 net namespace 中要對 127.0.0.1 送出回覆,那不就是這個 net namespace 自己嗎?

有想到我們在 Day 29 針對修改 source 修改的規則嗎?

iptables -t nat -I POSTROUTING -s 172.18.0.0/24 -o ens5 -j MASQUERADE

我們模仿著做做看,不過這邊 source 要改成是 127.0.0.1,且 output 是 docker1,所以規則就會是:

iptables -t nat -A POSTROUTING -s 127.0.0.0/8 -o docker1 -j MASQUERADE

來試試看吧!

ubuntu@ip-xxx:~$ sudo iptables -t nat -A POSTROUTING -s 127.0.0.0/8 -o docker1 -j MASQUERADE
ubuntu@ip-xxx:~$ curl 127.0.0.1:3000
Hello container!

嘿嘿,成功了!


上面的實驗是不是很有趣呢?不過阿,雖然最後成功了,但其實還有個小小的問題,那就是 3000 是在 ns1 這個 net namespace 裡頭聽的,host 至始至終都沒有聽這個 port,我們是依賴了 iptables 去轉送封包,所以,如果,你這時候在 host 啟動一個監聽 3000 port 是可以成功的喔...但 Docker 呢?當你在啟動 container 時,如果有 publish port 到 host 來,host 是真的有在監聽這個 port 的,他就是我們前文提過的 docker-proxy 來做到的,Docker 是不是幫我們做了不少事呢?

以上就是針對 Docker 的 bridge 模式做了一點研究,關於 bridge 的其他實驗,歡迎參考我兩年前的筆記: Docker Container 基礎入門篇 2 ,這份筆記是只針對 Docker 的操作,不像本系列,根本是在討論 Linux,我自己對 iptables 也沒有到很熟,很多都是邊寫邊查邊研究,有不對的地方歡迎再一起留言討論,之後也想整理一篇關於 iptables 的筆記,再請多多指教啦!

(網路的部分其實還沒有寫完,但可能要下週才能有後續了,這兩週工作忙爆,下週見! --> 還有人嗎?)


上一篇
Day 31: Docker 是怎麼解封國境的呢?
系列文
那些關於 docker 你知道與不知道的事32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言