Nginx—深入理解server和location的匹配算法

前兩天使用K8S的ingress配置,遇到兩個有包含關係的uri需要匹配到兩個不同的容器中的情況,找了一下具體的匹配規則,主要來自這篇文章

根據墨菲定律,可能會發生的事就一定會發生。如果對Nginx的路徑配置一直存疑,遲早會出問題。因此,搞懂很重要。

總體來說,Nginx將配置根據不同的server分成了不同的塊,每當一個請求過來時,Nginx都會根據一定的算法確定哪一個配置塊來處理該請求。其中起關鍵性作用的是server塊和location塊。前者定義了一個虛擬服務器,管理員通常會定義多個server塊,然後根據請求的域名,端口或IP決定匹配到哪一個;後者存在於server塊內,根據URI對虛擬服務器進行更加詳細的區分。二者組合起來能夠實現非常靈活的配置。

server

在server塊內,主要通過listen指令和server_name指令配置

listen

首先,Nginx查看請求的IP地址和端口,將它與每個server塊的listen匹配。

listen指令用於定義server塊需要響應的IP和端口。默認情況下,任何不包含listen指令的server塊都將被分配一個默認值:對於root用戶,將被設置爲0.0.0.0:80;對於普通用戶,將被設置爲0.0.0.0:8080。

listen指令有如下幾種可能性。

  • IP + Port
  • 單獨的IP,此時Port將被設爲默認值80
  • 單獨的Port, 此時將會監聽該端口上的所有接口
  • Unix的socket文件路徑

匹配時,算法如下

  • 首先將不完整的listen指令使用默認值填充完整
    • 沒有設置listen指令的,使用0.0.0.0:80替換
    • 單獨的IP,端口默認設爲80
    • 單獨的端口,IP默認設爲0.0.0.0
  • 將IP-Port和請求的IP-Port對比
  • 如果只有一個server塊匹配成功,則該server爲最終結果。如果有多個server塊匹配成功,則繼續根據server_name進行匹配。

注意喲:server_name只有在需要區分listen匹配的多個結果時纔會被使用。一個典型的例子,如果example.com被解析到192.168.1.10,此時要在80上進行匹配。則下面兩個配置永遠只會匹配到第一個。

server {
    listen 192.168.1.10;
    . . .
}

server {
    listen 80;
    server_name example.com;
    . . .
}

server_name

當listen指令匹配到多個結果時,server_name就發揮作用了。

Nginx會檢查請求的Host頭部,根據如下算法,將其值與server_name指令配置內容進行對比

  • 首先進行精確匹配,如果匹配數量爲1,則使用;如果精確匹配到多個,則使用第一個;如果匹配爲0,則繼續
  • 匹配以*開頭的配置值,如匹配數量爲1,則使用;如匹配到多個,則使用最長那個;如果匹配爲0,則繼續
  • 匹配以*結尾的配置值,如匹配數量爲1,則使用;如匹配到多個,則使用最長那個;如果匹配爲0,則繼續
  • 使用正則表達式匹配,使用匹配到的第一個;如果匹配爲0,則繼續
  • 到這裏還沒有匹配到,則使用該IP和Port對應的默認server塊

注意喲:一個IP和Port對應的默認server塊,就是根據listen匹配結果集的第一個,或包含了default_server選項的server塊。

例一:host1.example.com會匹配第二個server塊。滿足算法第一點。

server {
    listen 80;
    server_name *.example.com;
    . . .
}

server {
    listen 80;
    server_name host1.example.com;
    . . .
}

例二:www.example.org匹配第二個server塊。滿足算法第二點。

server {
    listen 80;
    server_name www.example.*;
    . . .
}

server {
    listen 80;
    server_name *.example.org;
    . . .
}

server {
    listen 80;
    server_name *.org;
    . . .
}

例三:www.example.org匹配第三個server塊。滿足算法第三點。

server {
    listen 80;
    server_name host1.example.com;
    . . .
}

server {
    listen 80;
    server_name example.com;
    . . .
}

server {
    listen 80;
    server_name www.example.*;
    . . .
}

例四:www.example.org匹配第二個server塊。滿足算法第四點。

server {
    listen 80;
    server_name example.com;
    . . .
}

server {
    listen 80;
    server_name ~^(www|host1).*\.example\.com$;
    . . .
}

server {
    listen 80;
    server_name ~^(subdomain|set|www|host1).*\.example\.com$;
    . . .
}

location

語法

介紹算法前,先講講語法,標準語法如下

location optional_modifier location_match {
    . . .
}

其中optional_modifier取下面幾種可能的值

  • (none) : 即沒有optional_modifier,按照前綴進行匹配
  • = : 完全匹配
  • ~ : 按照大小寫敏感的正則表達式匹配
  • ~* : 按照大小寫不敏感的正則表達式匹配
  • ^~ : 不按照正則表達式匹配,注意,這裏是顯式地抑制正則表達式的解析

下面舉例

# 匹配/site /site/page/index.html /site/index.html等
location /site {
    . . .
}

# 只能匹配 /site
location = /site {
     . . .
}

# 可匹配/hello.jpg,但是不能匹配/hello.JPG
location ~ \.(jpe?g|png|gif|ico)$ {
    . . .
}
# 上面的大小寫不敏感版本
location ~* \.(jpe?g|png|gif|ico)$ {
    . . .
}

# 能夠匹配/customs/hello.html
location ^~ /customs {
    . . . 
}

匹配算法

location的匹配方式和server塊類似,都是找最優匹配,具體算法如下

  • 找出所有匹配URI前綴的location塊,作爲備選
  • 檢查精確匹配的項(即=修飾的項),如果有結果,則直接使用它最爲最終匹配結果。否則進行下一步
  • 如果沒有精確項匹配,開始匹配不精確項。找出最長前綴匹配的項,按照如下規則檢查
    • 若最長前綴匹配的項被^~修飾,則使用它作爲結果
    • 若最長前綴匹配的項未被^~修飾,則此結果會被Nginx暫存起來
  • 解析正則表達式(包含了大小寫敏感和不敏感),在上面的按照最長前綴匹配的項中有任何包含正則表達式的項,則進行正則表達式匹配。一旦匹配成功,則用它作爲結果
  • 如果沒有正則表達式匹配成功,就使用之前被暫存的匹配項作爲結果

注意喲:這裏所說的基於前綴,意思是location指定的值和請求URI的前綴能夠匹配。比如 URI 爲 /customs/hello/halo時,location /customlocation /custom/hellolocation ~ /.*/hello都是能夠匹配的

注意喲:所謂最長前綴匹配項,即儘可能多地匹配URI。比如location /custom/hello相比location /custom,就是較長的匹配項。

注意喲:默認情況下,相對使用前綴,Nginx會優先使用正則表達式進行匹配。但在這裏,ngin首先檢查所有前綴location,從而允許我們使用=和^~修飾符來覆蓋這個原則。

注意喲:Nginx會匹配最長最具體的location,但當一個location被當做匹配結果時,正則表達式的解析就停止了,因此location之間的相對位置也會有所影響。比如例四。

下面舉例

例一:訪問 /hello/hello,匹配到的是第一個。滿足匹配算法第二點。

location = /hello/hello {
}

location ~* /.*/hello {
}

例二:訪問/hello/hello,匹配到的是第二個。滿足匹配算法第四點。

location /hello/hello {
}

location ~* /.*/hello {
}

例三:訪問/custom/hello,匹配到第一個。滿足匹配算法第五點。

location /custom/hello {
}

location /custom {
}

例四:訪問/custom/hello,匹配到第一個。在前綴匹配長度上,他們一致,在解析正則表達式時,第一個首先被解析,符合要求,這樣儘管第二個也符合要求,但此時正則表達式的解析已經停止了。

location ~ /.*/hello {
}

location ~ /custom/.* {
}

繼續上例,依舊訪問/custom/hello,還是匹配到第一個,原因同上。

location ~ /custom/.* {
}

location ~ /.*/hello {
}

location跳到其它location的情況

一般來說,匹配到一個location後,之後的工作都會在該location下完成。但有幾個特殊的場景將會重新觸發location匹配。比如如下幾個指令。

  • index

    index如果用來處理請求,則始終會導致重定向。如果我們將一個精確匹配的location配置爲一個目錄,則可能將其重定向到其它位置。比如下面的配置。

    # 當方位 /exact,會被重定向到/index.html,從而重定向到第二個location
    index index.html;
    
    location = /exact {
        . . .
    }
    
    location / {
        . . .
    }
    
    # 解決方案是關閉index並開啓autuindex
    location = /exact {
        index nothing_will_match;
        autoindex on;
    }
    
  • try_files

    該指令告訴Nginx檢查是否存在一組命名的文件或目錄。 最後一個參數可以是Nginx將對其進行內部重定向的URI。考慮下面的例子。

    root /var/www/main;
    location / {
        try_files $uri $uri.html $uri/ /fallback/index.html;
    }
    
    location /fallback {
        root /var/www/another;
    }
    

    這裏,如果來的請求是/balabala,則Nginx會嘗試在/var/www/main下依次嘗試尋找balabala、balabala.html、balabala/等文件或文件夾,如果都沒有則內部重定向到/fallback/index.html。此時會匹配到第二個location塊。

  • rewrite

    rewrite指定將匹配的uri重寫成新的uri,並重新匹配新的location。

    # 如果uri爲/rewriteme/fallback,則重寫後的uri變成/fallback,會匹配到下面那個location
    root /var/www/main;
    location / {
        rewrite ^/rewriteme/(.*)$ /$1 last;
        try_files $uri $uri.html $uri/ /fallback/index.html;
    }
    
    location /fallback {
        root /var/www/another;
    }
    

    當然在使用return指令發送301、302重定向時,也會發生類似效果,但那是標準重定向,可以看做新的請求,所以他們還是不同的。

  • error_page

    error_page類似try_files形成的效果,執行的錯誤頁面路徑可能在另一個location中。

    root /var/www/main;
    
    location / {
        error_page 404 /another/whoops.html;
    }
    
    location /another {
        root /var/www;
    }
    

kubernetes ingress

ingress是K8S中的概念,用於將請求路由到指定的服務,本質上是對nginx的包裝。所有配置的ingress都將被轉換成nginx的location塊。

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: test-ingress
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"
spec:
  rules:
  - host: test.com
    http:
      paths:
      - path: /foo/.*
        backend:
          serviceName: test
          servicePort: 80

上述配置將被翻譯成如下配置

location ~* "^/foo/.*" {
  ...
}

本文比較關心的是ingress中配置的路徑優先級。

在Nginx中,正則表達式遵循最先匹配原則,因此爲了更加準確地進行匹配,在寫入nginx配置前,ingress首先會根據路徑的長度倒序排序,然後才寫入nginx配置。

注意喲,注意下面這種情況此時test.com/foo/bar/bar將會匹配第一個location,而不是第二個location,因爲整個ingress開啓了正則表達式。當然,如果需要匹配第二個,可以將正則表達式關閉,從而設置 location = /foo/bar/bar

apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: test-ingress-3
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"  # 注意這裏
spec:
  rules:
  - host: test.com
    http:
      paths:
      - path: /foo/bar/bar
        backend:
          serviceName: test
          servicePort: 80
      - path: /foo/bar/[A-Z0-9]{3}
        backend:
          serviceName: test
          servicePort: 80

將會被翻譯成

location ~* "^/foo/bar/[A-Z0-9]{3}" {
  ...
}

location ~* "^/foo/bar/bar" {
  ...
}

總結

詳細瞭解nignx這些特性,在做網站或接口配置時非常有用。比如有如下需求。

在我開發的項目中,大多數接口均以/admin打頭,但只有一個接口以/swagger開頭,現在出於需要,我要將項目配置到 www.example.com/abc/admin下。

  • 在詳細瞭解Nginx配置之前,我的解決方案如下

    將接口的admin前綴去除,然後將location爲/abc/admin/下的請求轉發給我的服務。

    location /abc/admin/ {
        proxy_pass http://127.0.0.1:19898/;
    }
    
  • 熟悉後,我可以在不改動原來前綴的基礎上進行修改。這樣看來,上面的操作相當於把Nginx的開發轉移到了業務代碼中,非常的不好。

    location /abc/admin/ {
        proxy_pass http://127.0.0.1:19898/admin/;
    }
    
    location /abc/admin/swagger/ {
        proxy_pass http://127.0.0.1:19898/swagger/;
    }
    

參考資料

  1. Understanding Nginx Server and Location Block Selection Algorithms
  2. Kubernetes Ingress Path Matching
  3. Nginx官方手冊
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章