大家好,我是張晉濤。
上週有小夥伴在群裡問到 Docker 和 Iptables 的關係,這裡來具體聊聊。
Docker 能為我們提供很強大和靈活的網路能力,很大程度上要歸功於與 iptables 的結合。在使用時,你可能沒有太關注到 iptables 的作用,這是因為 Docker 已經幫我們自動完成了相關的配置。
(MoeLove) ➜ ~ dockerd --help |grep iptables
--iptables Enable addition of iptables rules (default true)
docker daemon 有個 --iptables
的引數,便是用來控制是否要自動啟用 iptables 規則的,預設已經設定成了開啟(true)。所以通常我們不會過於關注到它的工作。
本文中,為了避免環境的干擾,我將使用 docker in docker 的環境來進行介紹,可透過如下方式啟動該環境:
(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind
f323aef7b532ba6d575ca6f9444a08f1a55f2447afec2e853954694c034e6ae0
iptables 基礎
iptables
是一個用於配置 Linux 核心防火牆的工具,可用於檢測、修改轉發、重定向以及丟棄 IPv4 資料包。它使用了核心的 ip_tables 的功能,所以需要 Linux 2.4+ 版本的核心。
同時,iptables 為了便於管理,所以按照不同的目的組織了多張 表 ;每張表中又包含了很多預定義的 鏈;每個鏈中包含著順序遍歷的 規則;這些規則中又定義了動作的匹配規則和 目標。
對於使用者而言,我們通常需要互動的就是 鏈和 規則了。
理解 iptables 的主要工作流程有一張比較經典的圖:
圖片來源: https://www.frozentux.net/ipt...
上面的小寫字母是 表,大寫字母則表示 鏈,從任何網路埠 進來的每一個 IP 資料包都要從上到下的穿過這張圖。
- 引用自 ArchWiki
不過這不是本篇的重點,所以就不展開了。如果大家對 iptables 的內容感興趣也歡迎留言,後續可以寫一篇完整的。
Docker 網路與 iptables
接下來我們直接看看 Docker 在開啟和關閉 iptables 時,具體有什麼區別。
關閉 Docker 的 iptables 支援
在本文開頭已經為你介紹過 docker daemon 存在一個 --iptables
的引數,用於控制是否使用 iptables 。我們使用以下命令啟動一個 docker daemon 並關閉 iptables 支援。
(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind dockerd --iptables=false
7135a54c913af5e9ce69a45a0819475503ea9e3c5c673d62d9d38f0f0896179d
進入此容器,並檢視其所有 iptables 規則:
(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # iptables-save
# Generated by iptables-save v1.8.8 on Mon Dec 12 01:46:38 2022
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [2:80]
COMMIT
# Completed on Mon Dec 12 01:46:38 2022
可以看到,當 docker daemon 加了 --iptables=false
的引數時,預設沒有任何規則的輸出。
開啟 Docker 的 iptables 支援
使用以下命令啟動一個 docker daemon,這裡沒有顯式的傳遞 --iptables
選項,因為預設就是 true
。
(MoeLove) ➜ ~ docker run --rm -d --privileged docker:dind
c464c5c08ecdf9129afbf217c6462236089fe0a1d11dfe7700c2985a04d8d216
檢視其 iptables 規則:
(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # iptables-save
# Generated by iptables-save v1.8.8 on Mon Dec 12 14:48:16 2022
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [1:40]
:POSTROUTING ACCEPT [1:40]
:DOCKER - [0:0]
-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 POSTROUTING -s 172.18.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -i docker0 -j RETURN
COMMIT
# Completed on Mon Dec 12 14:48:16 2022
# Generated by iptables-save v1.8.8 on Mon Dec 12 14:48:16 2022
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [2:80]
:DOCKER - [0:0]
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
-A DOCKER-USER -j RETURN
COMMIT
# Completed on Mon Dec 12 14:48:16 2022
可以看到,它比剛才關閉 iptables 支援時多了幾條鏈:
- DOCKER
- DOCKER-ISOLATION-STAGE-1
- DOCKER-ISOLATION-STAGE-2
- DOCKER-USER
以及增加了一些轉發規則,以下將具體介紹。
DOCKER-USER 鏈
在上述新增的幾條鏈中,我們先來看最先生效的 DOCKER-USER 。
*filter
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
...
-A DOCKER-USER -j RETURN
以上規則是在 filter 表中生效的:
- 第一條是
-A FORWARD -j DOCKER-USER
這表示流量進入 FORWARD 鏈後,直接進入到 DOCKER-USER 鏈; - 最後一條
-A DOCKER-USER -j RETURN
這表示流量進入 DOCKER-USER 鏈處理後,(如果無其他處理)可以再 RETURN 回原先的鏈,進行後續規則的匹配。
這其實是 Docker 預留的一個鏈,供使用者來自行配置的一些額外的規則的。
Docker 預設的路由規則是允許所有客戶端訪問的, 如果你的 Docker 執行在公網,或者你希望避免 Docker 中容器被區域網內的其他客戶端訪問,那麼你需要在這裡新增一條規則。
比如, 你僅僅允許 100.84.94.62 訪問,但是要拒絕其他客戶端訪問:
iptables -I DOCKER-USER -i <net interface> ! -s 100.84.94.62 -j DROP
此外,Docker 在重啟之類的操作時候,會進行 iptables 相關規則的清理和重建,但是 DOCKER-USER 鏈中的規則可以持久化,不受影響。
具體的實現均在 docker/libnetwork
下,以下是關於 DOCKER-USER
鏈的相關程式碼:
const userChain = "DOCKER-USER"
func arrangeUserFilterRule() {
if ctrl == nil || !ctrl.iptablesEnabled() {
return
}
iptable := iptables.GetIptable(iptables.IPv4)
_, err := iptable.NewChain(userChain, iptables.Filter, false)
if err != nil {
logrus.Warnf("Failed to create %s chain: %v", userChain, err)
return
}
if err = iptable.AddReturnRule(userChain); err != nil {
logrus.Warnf("Failed to add the RETURN rule for %s: %v", userChain, err)
return
}
err = iptable.EnsureJumpRule("FORWARD", userChain)
if err != nil {
logrus.Warnf("Failed to ensure the jump rule for %s: %v", userChain, err)
}
}
可以看到鏈名稱是固定在程式碼中的,同時會建立/確保鏈和規則存在。
DOCKER-ISOLATION-STAGE-1/2 鏈
DOCKER-ISOLATION-STAGE-1/2 這兩條鏈作用類似,這裡一起進行介紹。
*filter
...
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
:DOCKER-USER - [0:0]
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
...
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN
...
這兩條鏈主要是分兩個階段進行了橋接網路隔離。所謂的橋接網路,通常就是指透過 docker0
這個由 Docker 建立的介面的網路。
/ # ifconfig docker0
docker0 Link encap:Ethernet HWaddr 02:42:11:31:97:0D
inet addr:172.18.0.1 Bcast:172.18.255.255 Mask:255.255.0.0
UP BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)
舉個例子進行說明。
首先建立一個名為 moelove
的 network,並檢視它的 IP 。
➜ ~ docker network create moelove
0d3d76dcf81fcf4b9d76ab5a7dec22737b115dddd593c73b27d27f0114cec1e2
➜ ~ docker run --rm -it --network moelove alpine
/ # hostname -i
172.22.0.2
然後分別使用預設的 network 和使用前面建立的 network 啟動容器,來 ping 上述建立的容器 IP 。
➜ ~ docker run --rm -it alpine ping -c1 -w2 172.22.0.2
PING 172.22.0.2 (172.22.0.2): 56 data bytes
--- 172.22.0.2 ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss
➜ ~ docker run --rm -it --network moelove alpine ping -c1 -w2 172.22.0.2
PING 172.22.0.2 (172.22.0.2): 56 data bytes
64 bytes from 172.22.0.2: seq=0 ttl=64 time=0.092 ms
--- 172.22.0.2 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.092/0.092/0.092 ms
可以看到,如果是相同 network 的容器是可以 ping 成功的,但如果是不同 network 的容器則不能 ping 通。
DOCKER-ISOLATION-STAGE-1 會首先匹配來自橋接網路的網橋,目標是不同的介面,如果匹配到就進入 DOCKER-ISOLATION-STAGE-2,
不匹配就返回父鏈。
DOCKER-ISOLATION-STAGE-2 匹配目標是橋接網路的網橋,如果匹配,意味著資料包是來自於一個橋接網路的網橋,
目的地是另一個橋接網路的網橋,並將其 DROP 丟棄掉。不匹配則返回父鏈。
看到這裡,你可能會問 為什麼要分兩個階段進行隔離?用一條鏈直接隔離行不行?
答案是行,一條鏈也能隔離,Docker 很早的版本就是這樣做的。
但是當時的實在超過 30 個 network 以後,就會導致 Docker 啟動很慢。所以後來做了這個最佳化,
將這部分的複雜度從 O(N^2) 降低到 O(2N) ,Docker 就不再會出現啟動慢的情況了。
DOCKER 鏈
最後我們來看看 DOCKER 鏈,這是 Docker 中使用最為頻繁的一個鏈,也是規則最多的鏈,但它卻很好理解。
通常情況下,如果不小心刪掉了這個鏈的內容,可能會導致容器的網路出現問題,手動修復下,或者重啟 Docker 均可解決。
這裡我們啟動一個容器,並進行埠對映,來看看會有哪些變化。
(MoeLove) ➜ ~ docker exec -it $(docker ps -ql) sh
/ # docker run -p 6379:6379 --rm -d redis:alpine
Unable to find image 'redis:alpine' locally
alpine: Pulling from library/redis
c158987b0551: Pull complete
1a990ecc86f0: Pull complete
f2520a938316: Pull complete
ae8c5b65b255: Pull complete
1f2628236ae0: Pull complete
329dd56817a5: Pull complete
Digest: sha256:518c024ec78b3074917bad2d40863e882e5297d65587e6d7c6e0b7281d9b8270
Status: Downloaded newer image for redis:alpine
6bf21bd3de78ce32617bf64a6a730c0fb50e304509a2ec3ef05ceae648334294
/ # docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6bf21bd3de78 redis:alpine "docker-entrypoint.s…" 9 seconds ago Up 8 seconds 0.0.0.0:6379->6379/tcp friendly_spence
之後再次執行 iptables-save
,對比當前的結果與上次的差別:
*filter
+-A DOCKER -d 172.18.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6379 -j ACCEPT
*nat
+-A POSTROUTING -s 172.18.0.2/32 -d 172.18.0.2/32 -p tcp -m tcp --dport 6379 -j MASQUERADE
+-A DOCKER ! -i docker0 -p tcp -m tcp --dport 6379 -j DNAT --to-destination 172.18.0.2:6379
Docker 分別在 filter
表和 nat
表增加了規則。它的具體含義如下:
filter
表中新增的這條規則表示:在自定義的 DOCKER
鏈中,對於目標地址是 172.18.0.2 且不是從 docker0
進入的但從 docker0
出去的,目標埠是 6379 的 TCP 協議則接收。
簡單點來說就是放行透過 docker0
流出的,目標為 172.18.0.2:6379 的 TCP 協議的流量。
nat
表中這兩條規則的表示:
- 為 172.18.0.2 上目標埠為 6379 的流量執行 MASQUERADE 動作(這裡就簡單的將它理解為 SNAT 也可以);
- 在自定義的
DOCKER
鏈中,如果入口不是docker0
並且目標埠是 6379 則進行 DNAT 動作,將目標地址轉換為 172.18.0.2:6379 。簡單點來說,這條規則就是為我們提供了 Docker 容器埠轉發的能力,將訪問主機本地 6379 埠流量的目標地址轉換為 172.18.0.2:6379 。
當然,要提供完整的訪問能力,也需要和其他前面列出的其他規則共同配合才能完成。
此外,由於 Docker 中還存在多種不同的 network 驅動,在其他模式下還會有一些區別,需要注意。
containerd 與 iptables
隨著 Kubernetes 中將 dockershim 徹底移除,已經有很多人將容器執行時切換到了 containerd,甚至有人希望把所有 Docker 環境都替換成 containerd。
但這裡其實有一些需要注意的點,比如我們上述的示例,在 containerd 中實際上是無法進行埠對映(埠釋出)的。
containerd 中可以透過類似上述 docker 的命令來啟動相同的容器,比如:
$ ctr run docker.io/library/redis:alpine redis-1
但它是沒有 -p
或者 -P
引數的。所以這個埠釋出的能力是 Docker 自己專門提供的。
如果確實想用這樣的功能,怎麼做呢?
一種方式是自己來管理 iptables 規則,但比較繁瑣了。
另一種方式,推薦大家可以直接使用 nerdctl 這是一個專為 containerd 做的,
相容 Docker CLI 的工具。提供了很多遠比預設的 ctr
工具更豐富的能力。
比如可以這樣:
$ nerdctl run -d --name redis-1 -p 6379:6379 redis:alpine
獲取其 IP 是 192.168.40.9, 然後檢查 iptables 的規則:
$ iptables -t nat -L | grep '192.168.40.9'
CNI-66888846605aa0cf860a0834 all -- 192.168.40.9 anywhere
DNAT tcp -- anywhere anywhere tcp dpt:redis to:192.168.40.9:6379
發現有類似的規則,讓它可以正常訪問。
總結
本篇從 Docker 與 iptables 的關係將其,分別剖析了 Docker 啟動後會建立的 iptables 規則及其含義。並透過示例介紹了 Docker 埠對映的實際原理,
以及如何利用 nerdctl 配合使用 containerd 進行埠對映。
容器的網路內容比較多,不過原理都是相通的,在 Kubernetes 中也包含了類似的內容。
好了,以上就是本篇的內容。
歡迎大家在評論區留言討論,也請點贊再看,謝謝。
歡迎訂閱我的文章公眾號【MoeLove】