Categories
程式開發

用容器來學習Nginx反向代理


本文翻譯自“Using Containers to Learn Nginx Reverse Proxy”,翻譯已獲得原作者Rosemary Wang授權。

作為Nginx及其反向代理功能的初學者,我本來並不知道該從哪裡開始下手,也不知道該如何理解它。為了邁出第一步,我決定自己試著使用反向代理容器來探索它的這部分功能。做測試時其實我並沒有網絡連接,因為我在飛機上,所以只能在本地用Docker做測試。幸運的是,它運轉起來了,實例都是在雲裡運行起來的!
有趣的是,我是在飛往西雅圖的航班上開始寫這篇文章的,那天還是個陰天。所以我猜我的實例真的是在雲裡運行起來的。

反向代理是什麼?

維基百科上是這麼定義的:

在計算機網絡中,反向代理是代理服務器的一種。服務器根據客戶端的請求,從其關聯的一組或多組後端服務器上獲取資源,然後再將這些資源返回給客戶端,客戶端只會得知反向代理的IP地址,而不知道在代理服務器後面的真實服務器集群的存在。

我把反向代理想像成快遞員。快遞員們騎著車,穿梭於大街小巷,收取各種各樣的包裹,再盡快盡量高效地派送出去,就好像發件人自己把它們投送出去一樣。

為什麼要在雲環境中運行反向代理呢?

我所說的雲環境,指的是在公有云或私有云上面運行一組應用程序。我進行了思考,並做了些研究來尋找答案。在這過程中,我發現了一篇2012年發表的好文章,概括了反向代理的主要功能:

  • 負載均衡器
  • 應用層的安全保證(請求並沒有直接發送給應用程序)
  • 單點認證、日誌和審計
  • 靜態內容服務器
  • 緩存
  • 壓縮器
  • URL改寫器

有了上面提到的這些功能,用反向代理就可以很好地滿足我的需求了。在雲上,應用程序的部署都是比較動態的,很難預計應用程序會從哪裡連接過來,以及它們使用的認證方法,等等。使用反向代理可以減輕這些工作量。

反向代理與服務發現有什麼不同?

我有時候也會懷疑自己理解得不對。我認為服務發現解決的問題與反向代理不同。我之前做過有關服務發現的測試,我記得服務發現指的是在雲環境裡,新服務會主動進行註冊,讓各種服務之間可以動態地相互發現。 Nginx更多的是一個服務註冊表,而不是發現和註冊機制,還需要有另一個組件來負責改變反向代理的配置。

怎樣可以把Nginx配置成一個反向代理呢?

Nginx有許多功能,包括HTTP服務器。除了響應請求,你還可以為Nginx創建一個配置文件,指定把請求發往哪裡去處理,這樣就成了一個反向代理。一個簡單的例子就是,一個默認運行在8080端口的測試程序。不管請求具體是被哪里處理的,我希望用戶把所有請求都發往同一個地方。而且,如果某台服務器宕機了,我還希望它可以把請求發往另一台可用的服務器,這就是負載均衡機制。用upstream就可以實現這個功能。

worker_processes 1;

events { worker_connections 1024; }


http {

    log_format compression '$remote_addr - $remote_user [$time_local] '
            '"$request" $status $upstream_addr '
        '"$http_referer" "$http_user_agent" "$gzip_ratio"';

    upstream testapp {
        server test:80;
    }

    server {
        listen 8080;
        access_log /var/log/nginx/access.log compression;

        location /hello/ {
            proxy_pass         http://testapp/;
            proxy_redirect     off;
            proxy_set_header   Host $host;
                proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header   X-Forwarded-Host $server_name;
        }
    }
}

詳細講解一下:

  • worker_processes告訴你將會運行多少個Nginx實例。為了能處理更大的負載,建議設置成auto(每核一個)。
  • worker_connections定義worker可以同時處理多少個連接。這裡有篇文章,詳細討論了worker_connections
  • log_format為日誌增加了指定的字段。帶上更多指令的話,它可以填充指定的字段,方便調試。我則希望能打印出我的upstream服務器地址。
  • upstream是一組服務器,可以用包括proxy_pass在內的特定指令訪問。在upstream下面還有一個server指令,可以啟動一個Nginx網站服務器,並告訴它監聽哪個端口。在我的例子裡是8080端口。
  • 還有location指令,包含著該怎樣代理請求的信息。也就是說,proxy_pass定義了協議和地址,指示著代理該往哪裡轉發。

接下來看個例子

為了方便使用,我用上面的Nginx反向代理配置創建了一個Docker鏡像,並把鏡像命名為reverseproxy

FROM nginx:latest

COPY nginx.conf /etc/nginx/nginx.conf

我還創建了Docker compose,用於啟動reverseproxy和我的應用程序test

version: '2'

services:
    reverseproxy:
        image: reverseproxy:latest
        ports:
            - 8080:8080
        restart: always

    test:
        image: joatmon08/testapp:latest
        restart: always

我的測試程序打印的輸出示例如下:

# curl test:80
Hello World!
# curl test:80/another?user=joatmon08
joatmon08 says Hello!

在這一組Docker裡,我的reverseproxy程序只會通過8080端口為外部提供服務。如果從localhost:8080用路徑/hello/來訪問反向代理,我的測試程序會返回“Hello World!”。如果通過路徑/hello/another來訪問我的API,就會將用戶名作為參數,返回一條消息。

$ curl localhost:8080/hello/
    Hello World!
    $ curl localhost:8080/hello/another?user=joatmon08
joatmon08 says Hello!

Nginx會把我的請求轉發給我的程序。兩種配置會返回相同的輸出。我也可以再增加一個程序,映射到另一個Nginx位置,比如/goodbye。

再次查看我的Nginx反向代理日誌,也就是Docker日誌,可以看到我通過curl對API的訪問都被記錄下來了。在一台普通的Nginx服務器上,你也可以在access.log中找到這些信息。

$ docker logs nginxtest_reverseproxy_1
172.19.0.1 - - [22/Jul/2017:00:14:22 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.3:80 "-" "curl/7.43.0" "-"
172.19.0.1 - - [22/Jul/2017:00:14:54 +0000] "GET /hello/another?user=joatmon08 HTTP/1.1" 200 172.19.0.3:80 "-" "curl/7.43.0" "-"

它還記錄下了我的upstream服務器172.19.0.3:80。 test會被解析成這個IP地址和端口。這個IP地址實際上是我的應用程序容器,要了解更多細節,請參考我以前關於容器網絡的文章。

如果把多個實例鏈接到了upstream服務器的URL上,會怎樣?

我又部署了一個test應用的實例,現在有兩個實例了。這意味著當我試圖訪問http://test時,我的請求可能會被轉發到這兩個不同IP地址上的任意一個容器中。

$ docker-compose up -d --scale test=2
nginxtest_reverseproxy_1 is up-to-date
Starting nginxtest_test_1 ... done
Creating nginxtest_test_2 ...
Creating nginxtest_test_2 ... done

為了確認URL http://test會被轉發到兩個不同實例上,我在同一個網絡內的另一個容器裡運行dig命令。

$ dig test
; <> DiG 9.9.5-3ubuntu0.2-Ubuntu <> test
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 64355
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;test.    IN A
;; ANSWER SECTION:
test.   600 IN A 172.19.0.3
test.   600 IN A 172.19.0.4
;; Query time: 0 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Fri Jul 21 02:43:21 UTC 2017
;; MSG SIZE  rcvd: 62

在answer塊裡有兩條記錄。當我再次通過反向代理去訪問test應用時,我會查看upstream服務器會不會被解析成這兩個IP地址之一。

$ docker logs nginxtest_reverseproxy_1
172.19.0.1 - - [22/Jul/2017:00:16:49 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.3:80 "-" "curl/7.43.0" "-

答案是肯定的,它被解析成了與之前相同的一個,即172.19.0.3。

如果兩個應用服務器掛了一個,會怎樣?

我想知道如果我把172.19.0.3刪了會怎樣。 Nginx應該會轉發到172.19.0.4去,因為test應該把請求轉發到另一個仍然活著的服務器上。於是我刪了172.19.0.3,即nginxtest_test_1。為了確認,我再次運行dig命令,看我的test應用的DNS記錄是不是會指向172.19.0.4。

$ dig test
; <> DiG 9.9.5-3ubuntu0.2-Ubuntu <> test
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 35920
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;test.    IN A
;; ANSWER SECTION:
test.   600 IN A 172.19.0.4
;; Query time: 0 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Sat Jul 22 00:47:28 UTC 2017
;; MSG SIZE  rcvd: 42

接下來再次測試,通過localhost:8080訪問我的反向代理。

$ curl localhost:8080/hello/

502 Bad Gateway

502 Bad Gateway


nginx/1.13.1

什麼?怎麼會這樣?我竟然收到了502 Bad Gateway的響應!

172.19.0.4工作正常,為什麼Nginx訪問不到172.19.0.4呢?也有另一種可能是Nginx壓根就不會訪問172.19.0.4。也許我該試試重啟反向代理容器,Nginx就能獲取到剩下的最後一個IP地址了。

$ docker restart d2
    d2
    $ curl localhost:8080/hello/
Hello World!

現在它被指向172.19.0.4了,即最後一個容器的IP地址。

$ docker logs nginxtest_reverseproxy_1
172.19.0.1 - - [22/Jul/2017:00:18:22 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.4:80 "-" "curl/7.43.0" "-"

結論是,Nginx會緩存它第一次通過upstream解析到的IP地址,而且不會刷新緩存,至少對於開源版本是這樣。

如果反向代理不能再次解析到一個新的IP地址上,會發生什麼?

老實說,從設計初衷上來說,我並不知道upsteam到底可不可以用於動態DNS解析。在官方的Nginx示例中,他們用upstream指令對一個IP地址集合做負載均衡。 upstream通常用於:

  • 在多組服務器之間按權重做負載均衡。
  • 如果有一條連接出錯,它就會換用下一個。如果全部出錯了,連接會被斷開。

我用下面的Nginx配置來更清晰地聲明我的容器IP地址。

worker_processes 1;

events { worker_connections 1024; }


http {

    log_format compression '$remote_addr - $remote_user [$time_local] '
            '"$request" $status $upstream_addr '
        '"$http_referer" "$http_user_agent" "$gzip_ratio"';

    upstream testapp {
        server 172.19.0.3:80;
        server 172.19.0.4:80;
    }

    server {
        listen 8080;
        access_log /var/log/nginx/access.log compression;

        location /hello/ {
            proxy_pass         http://testapp/;
            proxy_redirect     off;
            proxy_set_header   Host $host;
                proxy_set_header   X-Real-IP $remote_addr;
            proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header   X-Forwarded-Host $server_name;
        }
    }
}

用上面的配置做測試,Nginx可以幫我做負載均衡。當我再次刪除172.19.0.3的容器時,Nginx會在172.19.0.4上重試。

$ docker logs nginxtest_reverseproxy_1
172.19.0.1 - - [22/Jul/2017:00:21:49 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.3:80 "-" "curl/7.43.0" "-"
2017/07/22 00:21:53 [error] 7#7: *8 connect() failed (113: No route to host) while connecting to upstream, client: 172.19.0.1, server: , request: "GET /hello/ HTTP/1.1", upstream: "http://172.19.0.3:80/", host: "localhost:8080"
172.19.0.1 - - [22/Jul/2017:00:21:53 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.4:80, 172.19.0.4:80 "-" "curl/7.43.0" "-"

如果用URL做為upstream服務器,那麼你應該已經在它的前面部署了負載均衡。如果選擇用URL或經過負載均衡的DNS記錄來配置upstream,那麼當負載均衡的IP地址發生變化時,你就會有Nginx反向代理無法重新解析IP地址的風險。通常,在下面這些場景可能會碰到上面提到的問題:

  • 公有云負載均衡
  • 嵌入Docker的DNS服務器
  • 任意其它類型的動態負載均衡

使用動態負載均衡時,該怎樣做,才能讓Nginx重解析IP地址呢?

幸運的是,有許多博客已經就開源版Nginx的這個問題進行了詳細討論。下面這個簡單配置就是根據他們的建議總結的:

worker_processes 1;

events { worker_connections 1024; }


http {

    log_format compression '$remote_addr - $remote_user [$time_local] '
            '"$request" $status $upstream_addr '
        '"$http_referer" "$http_user_agent" "$gzip_ratio"';

    server {
        listen 8080;
        access_log /var/log/nginx/access.log compression;

        location /hello {
            resolver 127.0.0.11 valid=5s;
            set $upstream_endpoint http://test:80;
                rewrite ^/hello(/.*) $1 break;
            proxy_pass $upstream_endpoint;
                proxy_redirect     off;
                proxy_set_header   Host $host;
            proxy_set_header   X-Real-IP $remote_addr;
                proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header   X-Forwarded-Host $server_name;
        }
    }
}

簡單來說就是別用upstream,換成resolver,請注意它解析出的DNS服務器是Docker內嵌的DNS!其實就是應該把upstream端點設置成一個動態變量,當每隔5秒鐘執行解析器時,都會重新生成它的值。

非常重要的一點就是:應該增加rewrite指令來傳入正確的URI。沒有它,我的URI沒能被正確傳入,所以返回了一個“404 Not Found”錯誤。

$ curl localhost:8080/hello/

404 Not Found

Not Found

The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.

Nginx的proxy_pass指令需要結尾的“/”來對URI進行修整(trim)。然而當你把proxy_pass設置成一個動態變量時,Nginx就會忽略它。如果你想讓URI被轉發到正確的地方,在這種情況下千萬別忘了引入rewrite指令。

新的Nginx反向代理配置能正確工作嗎?

我想測試在相同情況下它是否仍然能正確工作:

  1. 創建一個反向代理容器(reverseproxy)。
  2. 創建我的應用容器(test)。
  3. 向reverseproxy發起一次調用,轉發到我的應用去處理。
  4. 把我的應用容器擴展到兩個。
  5. 刪掉我的第一個應用容器(nginxtest_test_1)。

測試最後的結果讓人很滿意。在刪除了172.19.0.3上面的第一個應用容器之後,我再向應用程序的端點發起一次調用:

$ curl localhost:8080/hello/
    Hello World!
    $ curl localhost:8080/hello/another?user=joatmon08
joatmon08 says Hello!

和上次不同,我沒收到“502 Bad Gateway”的錯誤。為了再次確認Nginx反向代理解析結果的正確性,我查看了Nginx的日誌:

$ docker logs nginxtest_reverseproxy_1
172.19.0.1 - - [22/Jul/2017:00:34:37 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.3:80 "-" "curl/7.43.0" "-"
172.19.0.1 - - [22/Jul/2017:00:35:02 +0000] "GET /hello/ HTTP/1.1" 200 172.19.0.4:80 "-" "curl/7.43.0" "-"

請注意,並不需要重啟Nginx容器,Nginx就把應用程序的IP地址重新指向了172.19.0.4。

小結

為了深入了解Nginx解析器的特定行為,我對它的行為和各種指令的含義進行了研究。而且,用容器來模擬這些行為並獲得最終收穫的過程讓人尤其印象深刻,我發現容器實在是一個優秀的學習工具,可以幫我們探索和進行測試。它可以幫我把一個問題拆解成可理解可測試的部分,把功能與技術和基礎設施解耦開來。

參考資料:

原文鏈接:

https://medium.com/@joatmon08/using-containers-to-learn-nginx-reverse-proxy-6be8ac75a757