上一節,我們學習了 NAT 的原理,明白了如何在 Linux 中管理 NAT 規則。先來簡單復習一下。
NAT 技術能夠重寫 IP 數據包的源 IP 或目的 IP,所以普遍用來解決公網 IP 地址短缺的問題。它可以讓網絡中的多台主機,通過共享同一個公網 IP 地址,來訪問外網資源。同時,由於 NAT 屏蔽了內網網絡,也為局域網中機器起到安全隔離的作用。
Linux 中的 NAT ,基於內核的連接跟蹤模塊實現。所以,它維護每個連接狀態的同時,也對網絡性能有一定影響。那么,碰到 NAT 性能問題時,我們又該怎么辦呢?
接下來,我就通過一個案例,帶你學習 NAT 性能問題的分析思路。
案例准備
下面的案例仍然基於 Ubuntu 18.04,同樣適用於其他的 Linux 系統。我使用的案例環境是這樣的:
機器配置:2 CPU,8GB 內存。
預先安裝 docker、tcpdump、curl、ab、SystemTap 等工具,比如
# Ubuntu
apt-get install -y docker.io tcpdump curl apache2-utils
# CentOS
curl -fsSL https://get.docker.com | sh
yum install -y tcpdump curl httpd-tools
大部分工具,你應該都比較熟悉,這里我簡單介紹一下 SystemTap 。
SystemTap
SystemTap 是 Linux 的一種動態追蹤框架,它把用戶提供的腳本,轉換為內核模塊來執行,用來監測和跟蹤內核的行為。關於它的原理,你暫時不用深究,后面的內容還會介紹到。這里你只要知道怎么安裝就可以了:
# Ubuntu
apt-get install -y systemtap-runtime systemtap
# Configure ddebs source
echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \
sudo tee -a /etc/apt/sources.list.d/ddebs.list
# Install dbgsym
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F2EDC64DC5AEE1F6B9C621F0C8CAB6595FDFF622
apt-get update
apt install ubuntu-dbgsym-keyring
stap-prep
apt-get install linux-image-`uname -r`-dbgsym
# CentOS
yum install systemtap kernel-devel yum-utils kernel
stab-prep
本次案例還是我們最常見的 Nginx,並且會用 ab 作為它的客戶端,進行壓力測試。案例中總共用到兩台虛擬機,我畫了一張圖來表示它們的關系。

接下來,我們打開兩個終端,分別 SSH 登錄到兩台機器上(以下步驟,假設終端編號與圖示 VM 編號一致),並安裝上面提到的這些工具。注意,curl 和 ab 只需要在客戶端 VM(即 VM2)中安裝。
同以前的案例一樣,下面的所有命令都默認以 root 用戶運行。如果你是用普通用戶身份登陸系統,請運行 sudo su root 命令,切換到 root 用戶。
如果安裝過程中有什么問題,同樣鼓勵你先自己搜索解決,解決不了的,可以在留言區向我提問。如果你以前已經安裝過了,就可以忽略這一點了。
接下來,我們就進入到案例環節。
案例分析
為了對比 NAT 帶來的性能問題,我們首先運行一個不用 NAT 的 Nginx 服務,並用 ab 測試它的性能。
在終端一中,執行下面的命令,啟動 Nginx,注意選項 --network=host ,表示容器使用 Host 網絡模式,即不使用 NAT:
docker run --name nginx-hostnet --privileged --network=host -itd feisky/nginx:80
然后到終端二中,執行 curl 命令,確認 Nginx 正常啟動:
curl http://192.168.0.30/
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
繼續在終端二中,執行 ab 命令,對 Nginx 進行壓力測試。不過在測試前要注意,Linux 默認允許打開的文件描述數比較小,比如在我的機器中,這個值只有 1024:
# open files
ulimit -n
1024
所以,執行 ab 前,先要把這個選項調大,比如調成 65536:
# 臨時增大當前會話的最大文件描述符數
ulimit -n 65536
接下來,再去執行 ab 命令,進行壓力測試:
# -c 表示並發請求數為 5000,-n 表示總的請求數為 10 萬
# -r 表示套接字接收錯誤時仍然繼續執行,-s 表示設置每個請求的超時時間為 2s
ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30/
...
Requests per second: 6576.21 [#/sec] (mean)
Time per request: 760.317 [ms] (mean)
Time per request: 0.152 [ms] (mean, across all concurrent requests)
Transfer rate: 5390.19 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 177 714.3 9 7338
Processing: 0 27 39.8 19 961
Waiting: 0 23 39.5 16 951
Total: 1 204 716.3 28 7349
...
關於 ab 輸出界面的含義,我已經在 怎么評估系統的網絡性能 文章中介紹過,忘了的話自己先去復習。從這次的界面,你可以看出:
- 每秒請求數(Requests per second)為 6576;
- 每個請求的平均延遲(Time per request)為 760ms;
- 建立連接的平均延遲(Connect)為 177ms。
記住這幾個數值,這將是接下來案例的基准指標。
注意,你的機器中,運行結果跟我的可能不一樣,不過沒關系,並不影響接下來的案例分析思路。
接着,回到終端一,停止這個未使用 NAT 的 Nginx 應用:
docker rm -f nginx-hostnet
再執行下面的命令,啟動今天的案例應用。案例應用監聽在 8080 端口,並且使用了 DNAT ,來實現 Host 的 8080 端口,到容器的 8080 端口的映射關系:
docker run --name nginx --privileged -p 8080:8080 -itd feisky/nginx:nat
Nginx 啟動后,你可以執行 iptables 命令,確認 DNAT 規則已經創建:
iptables -nL -t nat
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
...
Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:8080
你可以看到,在 PREROUTING 鏈中,目的為本地的請求,會轉到 DOCKER 鏈;而在 DOCKER 鏈中,目的端口為 8080 的 tcp 請求,會被 DNAT 到 172.17.0.2 的 8080 端口。其中,172.17.0.2 就是 Nginx 容器的 IP 地址。
接下來,我們切換到終端二中,執行 curl 命令,確認 Nginx 已經正常啟動:
curl http://192.168.0.30:8080/
...
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
然后,再次執行上述的 ab 命令,不過這次注意,要把請求的端口號換成 8080:
# -c 表示並發請求數為 5000,-n 表示總的請求數為 10 萬
# -r 表示套接字接收錯誤時仍然繼續執行,-s 表示設置每個請求的超時時間為 2s
ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30:8080/
...
apr_pollset_poll: The timeout specified has expired (70007)
Total of 5602 requests completed
果然,剛才正常運行的 ab ,現在失敗了,還報了連接超時的錯誤。運行 ab 時的 -s 參數,設置了每個請求的超時時間為 2s,而從輸出可以看到,這次只完成了 5602 個請求。
既然是為了得到 ab 的測試結果,我們不妨把超時時間延長一下試試,比如延長到 30s。延遲增大意味着要等更長時間,為了快點得到結果,我們可以同時把總測試次數,也減少到 10000:
ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
...
Requests per second: 76.47 [#/sec] (mean)
Time per request: 65380.868 [ms] (mean)
Time per request: 13.076 [ms] (mean, across all concurrent requests)
Transfer rate: 44.79 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1300 5578.0 1 65184
Processing: 0 37916 59283.2 1 130682
Waiting: 0 2 8.7 1 414
Total: 1 39216 58711.6 1021 130682
...
再重新看看 ab 的輸出,這次的結果顯示:
- 每秒請求數(Requests per second)為 76;
- 每個請求的延遲(Time per request)為 65s;
- 建立連接的延遲(Connect)為 1300ms。
顯然,每個指標都比前面差了很多。
那么,碰到這種問題時,你會怎么辦呢?你可以根據前面的講解,先自己分析一下,再繼續學習下面的內容。
在上一節,我們使用 tcpdump 抓包的方法,找出了延遲增大的根源。那么今天的案例,我們仍然可以用類似的方法尋找線索。不過,現在換個思路,因為今天我們已經事先知道了問題的根源——那就是 NAT。
回憶一下 Netfilter 中,網絡包的流向以及 NAT 的原理,你會發現,要保證 NAT 正常工作,就至少需要兩個步驟:
- 第一,利用 Netfilter 中的鈎子函數(Hook),修改源地址或者目的地址。
- 第二,利用連接跟蹤模塊 conntrack ,關聯同一個連接的請求和響應。
是不是這兩個地方出現了問題呢?我們用前面提到的動態追蹤工具 SystemTap 來試試。
由於今天案例是在壓測場景下,並發請求數大大降低,並且我們清楚知道 NAT 是罪魁禍首。所以,我們有理由懷疑,內核中發生了丟包現象。
我們可以回到終端一中,創建一個 dropwatch.stp 的腳本文件,並寫入下面的內容:
#! /usr/bin/env stap
############################################################
# Dropwatch.stp
# Author: Neil Horman <nhorman@redhat.com>
# An example script to mimic the behavior of the dropwatch utility
# http://fedorahosted.org/dropwatch
############################################################
# Array to hold the list of drop points we find
global locations
# Note when we turn the monitor on and off
probe begin { printf("Monitoring for dropped packets\n") }
probe end { printf("Stopping dropped packet monitor\n") }
# increment a drop counter for every location we drop at
probe kernel.trace("kfree_skb") { locations[$location] <<< 1 }
# Every 5 seconds report our drop locations
probe timer.sec(5)
{
printf("\n")
foreach (l in locations-) {
printf("%d packets dropped at %s\n",
@count(locations[l]), symname(l))
}
delete locations
}
這個腳本,跟蹤內核函數 kfree_skb() 的調用,並統計丟包的位置。文件保存好后,執行下面的 stap 命令,就可以運行丟包跟蹤腳本。這里的 stap,是 SystemTap 的命令行工具:
stap --all-modules dropwatch.stp
Monitoring for dropped packets
當你看到 probe begin 輸出的 “Monitoring for dropped packets” 時,表明 SystemTap 已經將腳本編譯為內核模塊,並啟動運行了。
接着,我們切換到終端二中,再次執行 ab 命令:
ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
然后,再次回到終端一中,觀察 stap 命令的輸出:
10031 packets dropped at nf_hook_slow
676 packets dropped at tcp_v4_rcv
7284 packets dropped at nf_hook_slow
268 packets dropped at tcp_v4_rcv
你會發現,大量丟包都發生在 nf_hook_slow 位置。看到這個名字,你應該能想到,這是在 Netfilter Hook 的鈎子函數中,出現丟包問題了。但是不是 NAT,還不能確定。接下來,我們還得再跟蹤 nf_hook_slow 的執行過程,這一步可以通過 perf 來完成。
我們切換到終端二中,再次執行 ab 命令:
ab -c 5000 -n 10000 -r -s 30 http://192.168.0.30:8080/
然后,再次切換回終端一,執行 perf record 和 perf report 命令
# 記錄一會(比如 30s)后按 Ctrl+C 結束
perf record -a -g -- sleep 30
# 輸出報告
perf report -g graph,0
在 perf report 界面中,輸入查找命令 / 然后,在彈出的對話框中,輸入 nf_hook_slow;最后再展開調用棧,就可以得到下面這個調用圖:

從這個圖我們可以看到,nf_hook_slow 調用最多的有三個地方,分別是 ipv4_conntrack_in、br_nf_pre_routing 以及 iptable_nat_ipv4_in。換言之,nf_hook_slow 主要在執行三個動作。
- 第一,接收網絡包時,在連接跟蹤表中查找連接,並為新的連接分配跟蹤對象(Bucket)。
- 第二,在 Linux 網橋中轉發包。這是因為案例 Nginx 是一個 Docker 容器,而容器的網絡通過網橋來實現;
- 第三,接收網絡包時,執行 DNAT,即把 8080 端口收到的包轉發給容器。
到這里,我們其實就找到了性能下降的三個來源。這三個來源,都是 Linux 的內核機制,所以接下來的優化,自然也是要從內核入手。
根據以前各個資源模塊的內容,我們知道,Linux 內核為用戶提供了大量的可配置選項,這些選項可以通過 proc 文件系統,或者 sys 文件系統,來查看和修改。除此之外,你還可以用 sysctl 這個命令行工具,來查看和修改內核配置。
比如,我們今天的主題是 DNAT,而 DNAT 的基礎是 conntrack,所以我們可以先看看,內核提供了哪些 conntrack 的配置選項。
我們在終端一中,繼續執行下面的命令:
sysctl -a | grep conntrack
net.netfilter.nf_conntrack_count = 180
net.netfilter.nf_conntrack_max = 1000
net.netfilter.nf_conntrack_buckets = 65536
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 60
net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 120
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
...
你可以看到,這里最重要的三個指標:
- net.netfilter.nf_conntrack_count,表示當前連接跟蹤數;
- net.netfilter.nf_conntrack_max,表示最大連接跟蹤數;
- net.netfilter.nf_conntrack_buckets,表示連接跟蹤表的大小。
所以,這個輸出告訴我們,當前連接跟蹤數是 180,最大連接跟蹤數是 1000,連接跟蹤表的大小,則是 65536。
回想一下前面的 ab 命令,並發請求數是 5000,而請求數是 100000。顯然,跟蹤表設置成,只記錄 1000 個連接,是遠遠不夠的。
實際上,內核在工作異常時,會把異常信息記錄到日志中。比如前面的 ab 測試,內核已經在日志中報出了 “nf_conntrack: table full” 的錯誤。執行 dmesg 命令,你就可以看到:
dmesg | tail
[104235.156774] nf_conntrack: nf_conntrack: table full, dropping packet
[104243.800401] net_ratelimit: 3939 callbacks suppressed
[104243.800401] nf_conntrack: nf_conntrack: table full, dropping packet
[104262.962157] nf_conntrack: nf_conntrack: table full, dropping packet
其中,net_ratelimit 表示有大量的日志被壓縮掉了,這是內核預防日志攻擊的一種措施。而當你看到 “nf_conntrack: table full” 的錯誤時,就表明 nf_conntrack_max 太小了。
那是不是,直接把連接跟蹤表調大就可以了呢?調節前,你先得明白,連接跟蹤表,實際上是內存中的一個哈希表。如果連接跟蹤數過大,也會耗費大量內存。
其實,我們上面看到的 nf_conntrack_buckets,就是哈希表的大小。哈希表中的每一項,都是一個鏈表(稱為 Bucket),而鏈表長度,就等於 nf_conntrack_max 除以 nf_conntrack_buckets。
比如,我們可以估算一下,上述配置的連接跟蹤表占用的內存大小:
# 連接跟蹤對象大小為 376,鏈表項大小為 16
nf_conntrack_max* 連接跟蹤對象大小 +nf_conntrack_buckets* 鏈表項大小
= 1000*376+65536*16 B
= 1.4 MB
接下來,我們將 nf_conntrack_max 改大一些,比如改成 131072(即 nf_conntrack_buckets 的 2 倍):
sysctl -w net.netfilter.nf_conntrack_max=131072
sysctl -w net.netfilter.nf_conntrack_buckets=65536
然后再切換到終端二中,重新執行 ab 命令。注意,這次我們把超時時間也改回原來的 2s:
ab -c 5000 -n 100000 -r -s 2 http://192.168.0.30:8080/
...
Requests per second: 6315.99 [#/sec] (mean)
Time per request: 791.641 [ms] (mean)
Time per request: 0.158 [ms] (mean, across all concurrent requests)
Transfer rate: 4985.15 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 355 793.7 29 7352
Processing: 8 311 855.9 51 14481
Waiting: 0 292 851.5 36 14481
Total: 15 666 1216.3 148 14645
果然,現在你可以看到:
- 每秒請求數(Requests per second)為 6315(不用 NAT 時為 6576);
- 每個請求的延遲(Time per request)為 791ms(不用 NAT 時為 760ms);
- 建立連接的延遲(Connect)為 355ms(不用 NAT 時為 177ms)。
這個結果,已經比剛才的測試好了很多,也很接近最初不用 NAT 時的基准結果了。
不過,你可能還是很好奇,連接跟蹤表里,到底都包含了哪些東西?這里的東西,又是怎么刷新的呢?
實際上,你可以用 conntrack 命令行工具,來查看連接跟蹤表的內容。比如:
# -L 表示列表,-o 表示以擴展格式顯示
conntrack -L -o extended | head
ipv4 2 tcp 6 7 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51744 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51744 [ASSURED] mark=0 use=1
ipv4 2 tcp 6 6 TIME_WAIT src=192.168.0.2 dst=192.168.0.96 sport=51524 dport=8080 src=172.17.0.2 dst=192.168.0.2 sport=8080 dport=51524 [ASSURED] mark=0 use=1
從這里你可以發現,連接跟蹤表里的對象,包括了協議、連接狀態、源 IP、源端口、目的 IP、目的端口、跟蹤狀態等。由於這個格式是固定的,所以我們可以用 awk、sort 等工具,對其進行統計分析。
比如,我們還是以 ab 為例。在終端二啟動 ab 命令后,再回到終端一中,執行下面的命令:
# 統計總的連接跟蹤數
conntrack -L -o extended | wc -l
14289
# 統計 TCP 協議各個狀態的連接跟蹤數
conntrack -L -o extended | awk '/^.*tcp.*$/ {sum[$6]++} END {for(i in sum) print i, sum[i]}'
SYN_RECV 4
CLOSE_WAIT 9
ESTABLISHED 2877
FIN_WAIT 3
SYN_SENT 2113
TIME_WAIT 9283
# 統計各個源 IP 的連接跟蹤數
conntrack -L -o extended | awk '{print $7}' | cut -d "=" -f 2 | sort | uniq -c | sort -nr | head -n 10
14116 192.168.0.2
172 192.168.0.96
這里統計了總連接跟蹤數,TCP 協議各個狀態的連接跟蹤數,以及各個源 IP 的連接跟蹤數。你可以看到,大部分 TCP 的連接跟蹤,都處於 TIME_WAIT 狀態,並且它們大都來自於 192.168.0.2 這個 IP 地址(也就是運行 ab 命令的 VM2)。
這些處於 TIME_WAIT 的連接跟蹤記錄,會在超時后清理,而默認的超時時間是 120s,你可以執行下面的命令來查看:
sysctl net.netfilter.nf_conntrack_tcp_timeout_time_wait
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
所以,如果你的連接數非常大,確實也應該考慮,適當減小超時時間。
除了上面這些常見配置,conntrack 還包含了其他很多配置選項,你可以根據實際需要,參考 nf_conntrack 的文檔來配置。
小結
今天,我帶你一起學習了,如何排查和優化 NAT 帶來的性能問題。
由於 NAT 基於 Linux 內核的連接跟蹤機制來實現。所以,在分析 NAT 性能問題時,我們可以先從 conntrack 角度來分析,比如用 systemtap、perf 等,分析內核中 conntrack 的行文;然后,通過調整 netfilter 內核選項的參數,來進行優化。
其實,Linux 這種通過連接跟蹤機制實現的 NAT,也常被稱為有狀態的 NAT,而維護狀態,也帶來了很高的性能成本。
所以,除了調整內核行為外,在不需要狀態跟蹤的場景下(比如只需要按預定的 IP 和端口進行映射,而不需要動態映射),我們也可以使用無狀態的 NAT (比如用 tc 或基於 DPDK 開發),來進一步提升性能。
思考
最后,給你留一個思考題。你有沒有碰到過 NAT 帶來的性能問題?你是怎么定位和分析它的根源的?最后,又是通過什么方法來優化解決的?你可以結合今天的案例,總結自己的思路。