Nginx 反向代理 - 你試過在 Docker 做嗎

反向代理
tags: DevOps nginx proxy

反向代理

系統設計 - 正向代理跟反向代理

不同實體主機

一開始還是先講講最簡單的基本玩法

使用 nginx proxy-pass

目標

  • 使用者造訪指定網頁路徑 /new_path 或是整個跳轉 /
  • 設定測試機 nginx proxy_pass 使其跳轉到其他主機專案相同路徑下
  • 確認使用者 IP 沒有被變成為代理伺服器的 IP
  • 確認在代理目標的專案可以讀取 Cookie
  • 確認 Post 請求的 payload 也會被轉送過去

設定 nginx config

第一台主機的 nginx 設定

# 把所有前往 domain/new_path 的請求反向代理到另一台主機上
location /new_path {
    # 要代理到哪個網址
    proxy_pass http://laravel55.test.com;

    # 傳送使用者真實 IP 到目標主機
    proxy_set_header X-Real-IP $remote_addr;

    # 設定伺服器為代理前的伺服器,否則 HTTP_HOST 會變成 proxy 那台代理伺服器的位址
    proxy_set_header Host $host;

    # 設定 cookie 轉發
    proxy_set_header Cookie $http_cookie;

    # 用來確認最初的用戶端原始 ip 位置
    proxy_set_header X-Forwarded-Host $host;

    # 記錄從使用者端出來經過的每個代理 X-Forwarded-For: <client>, <proxy1>, <proxy2>
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # 識別網路連線協定
    proxy_set_header X-Forwarded-Proto $scheme;

    # 隱藏伺服器資訊
    proxy_hide_header Server;
    proxy_hide_header X-Powered-By;

    # 因為自行加大 header 所以把空間也加大
    proxy_headers_hash_bucket_size 128;
    proxy_headers_hash_max_size 512;
}

第二台主機的 nginx 設定

server {
    listen 80;
    server_name ray200;
    root /home/toc/www/laravel55/public;

    access_log /var/log/nginx/www/laravel55.access.log;
    error_log /var/log/nginx/www/laravel55.error.log;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~* \.(jpg|jpeg|gif|css|png|js|ico|html|svg|ttf|woff|woff2|eotD)$ {
        access_log off;
    expires max;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        
        # php-fpm 的位置
        fastcgi_pass 127.0.0.1:9000; 
   }
}

注意事項

  • server_name 必須要是 proxy_pass 過來那台的 domain,如果不這樣設定,proxy_pass 過來會進到預設的 server_name 下
  • 在程式中判斷真實 IP 的方法要改成讀讀取 header 中的 x-real-ip 才是真實使用者 ip 位置

如果設定 nginx 有設定 proxy_set_header Cookie $http_cookie;
dump($_COOKIE); 可以取得 cookie,但是 Laravel 內建的 request()->cookie(); 卻沒有取到值

解決方案

Laravel 預設的 web middleware \App\Http\Middleware\EncryptCookies::class, 會要求 cookie 是加密過後的,如果沒有加密當然就讀不到。
解決方案一個是在 EncryptCookies 這個 class 中的 $except 加入要排除的 cookie 名稱,另一個就是直接把 middleware 註解掉。


使用 Docker

然後就是各種辛酸血淚的踩雷了

架構示意圖


參考資料:How to set up NGINX Docker Reverse Proxy?

步驟

1. 手動建立 network

:::warning
反向代理會根據載入的設定檔把請求導向到指定的容器名稱內
故代理的目標容器必須在同一網路內,才能直接使用容器名稱做代理目標
:::

因為不想讓 network 前面帶上 prefix,還有避免依賴,所以手動建立 network

docker network create nginx-network

這個情境屬於來自外部的 nginx-proxy,要多給一個 external network
有需要連接到這個 network 裡面的服務都要設定

networks:
  default:
    external:
      name: nginx-network

如此就可以讓不同 docker-compose.yml 中的服務互相溝通:官方說明

2. 建立測試用的服務

因為懶得建太複雜的服務,所以抓現成的 portainer 服務來做為 proxy 的目標
只是為了要有一個只要 docker-compose up 就可以運行的服務,也可以換成自己比較熟的映像檔

為了測試從 80 port poxy 到其他 port,所以這邊開在 9000 port
服務啟動之後可以先去 localhost:9000 查看 portainer 服務有沒有啟動

version: '3'

networks:
  default:
    external:
      name: nginx-network

services:
  portainer:
    container_name: nginx_test_portainer
    image: portainer/portainer-ce
    command: -H unix:///var/run/docker.sock
    restart: always
    ports:
      - 8080:9000
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data

volumes:
  portainer_data:

3. Nginx 的 proxy 設定

3.1 設定 docker-compose.yml

version: '3'

networks:
  default:
    external:
      name: nginx-network

services:
  nginx:
    container_name: test_nginx
    image: nginx:1.18
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./conf.d/:/etc/nginx/conf.d/
      - ./ssl/:/ssl/

3.2 設定 mount 進去給反向代理伺服器使用的 nginx config

可以看到底下設定中,我們把所有發往 test.example 網址的請求都轉到
http://nginx_test_portainer:9000
在容器中使用的模式是
:::info
http://{container_name}:{port}
:::
可以看到 container_name 對應了上面建立測試服務時候使用的容器名稱
port 對應到了容器的內部 port!
這邊特別把上面的容器內外使用的 port 區隔開來顯示差別,我們代理的目標是內部的 9000 port

server {
    listen 80;
    listen [::]:80;
    server_name test.example;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name test.example;

    ssl_certificate /ssl/ssl.crt;
    ssl_certificate_key /ssl/ssl.key;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    location / {
        proxy_set_header Host $host;
        proxy_set_header Cookie $http_cookie;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Host $host;
        proxy_pass http://{轉發的容器名稱}:{容器 port};
    }
}

要特別注意的是,這邊 proxy_pass 的目標網址中,http:// 不可以省略
如果轉址過去的網址有自己的 https 證書驗證(例如另一個 nginx 環境),則必須改成 https:// 開頭

參考資料:What value should I use for Nginx proxy_pass running in Docker?

3.3 編輯 etc/hosts

127.0.0.1       test.example

接著啟動 nginx 服務,用瀏覽器開啟剛剛設定的 test.example
正確設定的話,監聽 80 port 的 nginx 服務應該會把所有目標是 test.example 的請求利用 proxy_pass 送到 portainer 容器的指定 port
所以畫面上會顯示 portainer 服務的畫面

:::warning
要在所有服務都啟動之後再啟動反向代理服務,否則會報錯指出被代理的容器目標不存在
:::

注意事項

如果要把全部沒有匹配到的 server_name 請求統一處理
可以在指定的 nginx config 加上 default_server 定義

:::danger
如果沒有定義 default_server,則第一個 server 會被預設成 default server
:::

server {
    listen 8080 ssl default_server;
    listen [::]:8080 ssl default_server;

    ssl_certificate /ssl/ssl.crt;
    ssl_certificate_key /ssl/ssl.key;

    # Virtual Host Domain
    server_name _;

    # where code is
    root /var/一個專案/dev/www/public;
    index index.php index.html index.htm;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        try_files $uri /index.php =404;
        fastcgi_pass {php容器名稱}:{php容器port};
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}

因為要處理沒有被其他 server_name 捕獲的請求
所以 server_name 就不是什麼重點,可以用 _, __, ___ 都可以

這個情境會發生在當前端的 API 設定檔使用的路徑網址是指定後端容器名稱的時候
因為不是使用網址經過反向代理轉發,而是直接將請求發至容器
而這個 nginx 容器中又有多個 server config 設定
所以 nginx 沒有 server_name 可以判斷要使用哪個 config 設定

案例說明

同一個 nginx 容器(dev_nginx) 裡面有多個 server config

  • alex_backend
  • john_backend
  • dev_backend

如果現在有一個在同樣 docker network 的前端容器要打 API 到 dev_backend
他會在設定檔指定網址是 dev_nginx
這時候問題就來了,當 dev_nginx 收到這個請求會不知道是發給誰的
如果沒有設定 default_server,就會把請求派發給第一個 server,也就是 alex_backend
這不是我們要的結果
所以會把 dev_backend 設定為 default_server
如此之後,只要是送進這個容器的請求
若沒有被其他設定檔捕獲,就會被送進 dev_backend 處理

留言