1. 程式人生 > 實用技巧 >42 | 案例篇:如何優化 NAT 效能?(下)

42 | 案例篇:如何優化 NAT 效能?(下)

上一節,我們學習了 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 <[email protected]>
# 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 帶來的效能問題?你是怎麼定位和分析它的根源的?最後,又是通過什麼方法來優化解決的?你可以結合今天的案例,總結自己的思路。