反向代理與負載平衡
正向代理 vs 反向代理
💡 比喻:秘書代接電話
正向代理(Forward Proxy):你請秘書幫你打電話給別人
- 對方不知道是你打的,只知道是秘書打的
- 例如:VPN、公司內部代理伺服器
反向代理(Reverse Proxy):公司請秘書幫所有員工接電話
- 外面的人打電話進來,都先經過秘書
- 秘書決定轉給哪位員工處理
- 例如:Nginx、HAProxy
關鍵差別:正向代理代替客戶端,反向代理代替伺服器。
圖解差異
// 正向代理(幫客戶端出去)
客戶端 A ─┐
客戶端 B ─┼─→ [正向代理] ─→ 網際網路 ─→ 伺服器 // 代理幫客戶端存取外部
客戶端 C ─┘ // 伺服器看不到真正的客戶端
// 反向代理(幫伺服器進來)
客戶端 ─→ 網際網路 ─→ [反向代理] ─┬→ 伺服器 A // 代理幫伺服器接收請求
├→ 伺服器 B // 客戶端看不到真正的伺服器
└→ 伺服器 C // 代理決定分配給哪台
Nginx 設定反向代理到 Kestrel
安裝 Nginx
# Ubuntu/Debian 安裝 Nginx
sudo apt update # 更新套件清單
sudo apt install nginx -y # 安裝 Nginx,-y 自動確認
# 啟動並設為開機自動啟動
sudo systemctl start nginx # 立即啟動 Nginx
sudo systemctl enable nginx # 設定開機自動啟動
# 檢查 Nginx 狀態
sudo systemctl status nginx # 查看是否正常運行
# 應該看到 active (running)
# 測試設定檔語法是否正確
sudo nginx -t # 檢查設定檔有沒有語法錯誤
基本反向代理設定
# /etc/nginx/sites-available/myapp
# 這個設定檔告訴 Nginx 如何轉發請求給 Kestrel
server { # 定義一個虛擬主機
listen 80; # 監聽 HTTP 80 Port
server_name myapp.com www.myapp.com; # 回應這些網域名稱的請求
location / { # 所有路徑的請求
proxy_pass http://localhost:5000; # 轉發到 Kestrel(本機 5000 Port)
proxy_http_version 1.1; # 使用 HTTP 1.1 協定
proxy_set_header Upgrade $http_upgrade; # 支援 WebSocket 升級
proxy_set_header Connection keep-alive; # 保持連線不斷開
proxy_set_header Host $host; # 傳遞原始的主機名稱
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 傳遞客戶端真實 IP
proxy_set_header X-Forwarded-Proto $scheme; # 傳遞原始協定(http/https)
proxy_cache_bypass $http_upgrade; # WebSocket 請求不走快取
}
location /static/ { # 靜態檔案路徑
alias /var/www/myapp/wwwroot/; # 直接由 Nginx 提供靜態檔案
expires 30d; # 快取 30 天
add_header Cache-Control "public"; # 設定為公開快取
}
}
# 啟用站台設定
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ # 建立符號連結啟用站台
sudo nginx -t # 測試設定檔語法
sudo systemctl reload nginx # 重新載入設定(不中斷服務)
ASP.NET Core 配合反向代理
// Program.cs 中設定轉發標頭
var builder = WebApplication.CreateBuilder(args); // 建立建構器
// 設定轉發標頭中介軟體(重要!否則拿不到真實 IP)
builder.Services.Configure<ForwardedHeadersOptions>(options => // 設定轉發標頭選項
{
options.ForwardedHeaders = // 指定要處理的標頭
ForwardedHeaders.XForwardedFor | // 處理客戶端 IP
ForwardedHeaders.XForwardedProto; // 處理原始協定
});
var app = builder.Build(); // 建構應用程式
app.UseForwardedHeaders(); // 啟用轉發標頭(必須放在其他中介軟體之前)
app.UseHttpsRedirection(); // HTTP 重導向到 HTTPS
app.UseStaticFiles(); // 提供靜態檔案
app.UseAuthorization(); // 授權中介軟體
app.MapControllers(); // 對應 Controller 路由
app.Run(); // 啟動應用程式
負載平衡演算法
💡 比喻:餐廳帶位 想像你是餐廳經理,要決定把客人帶到哪一桌(伺服器):
- Round Robin:輪流帶位,1號桌 → 2號桌 → 3號桌 → 回到1號桌
- Least Connections:看哪桌客人最少就帶到那桌
- IP Hash:看客人的臉(IP),同一張臉永遠去同一桌
Nginx 負載平衡設定
# /etc/nginx/nginx.conf
# 負載平衡設定範例
# 方法一:Round Robin(預設)—— 輪流分配
upstream myapp_servers { # 定義後端伺服器群組
server 192.168.1.10:5000; # 後端伺服器 1
server 192.168.1.11:5000; # 後端伺服器 2
server 192.168.1.12:5000; # 後端伺服器 3
# 請求會依序分配:1 → 2 → 3 → 1 → 2 → 3... # 公平輪流
}
# 方法二:Least Connections —— 最少連線優先
upstream myapp_least { # 另一個伺服器群組
least_conn; # 啟用最少連線演算法
server 192.168.1.10:5000; # 連線少的優先接收新請求
server 192.168.1.11:5000; # 適合請求處理時間不均的情況
server 192.168.1.12:5000; # 動態平衡負載
}
# 方法三:IP Hash —— 同 IP 固定到同一台
upstream myapp_sticky { # 黏性 Session 群組
ip_hash; # 根據客戶端 IP 做 Hash
server 192.168.1.10:5000; # 同一個 IP 永遠去同一台
server 192.168.1.11:5000; # 適合需要 Session 的應用
server 192.168.1.12:5000; # 缺點:負載可能不均勻
}
# 方法四:加權 Round Robin —— 效能好的多分一點
upstream myapp_weighted { # 加權群組
server 192.168.1.10:5000 weight=3; # 這台效能好,分配 3 份
server 192.168.1.11:5000 weight=2; # 這台普通,分配 2 份
server 192.168.1.12:5000 weight=1; # 這台較弱,分配 1 份
}
server { # 虛擬主機設定
listen 80; # 監聽 80 Port
server_name myapp.com; # 網域名稱
location / { # 所有請求
proxy_pass http://myapp_servers; # 轉發到伺服器群組
proxy_set_header Host $host; # 傳遞主機名稱
proxy_set_header X-Real-IP $remote_addr; # 傳遞真實 IP
}
}
SSL/TLS 終止(SSL Termination)
💡 比喻:門口保安檢查 SSL 終止就像大樓門口的保安:
- 訪客進來時要出示證件(加密連線)
- 保安驗證完後,讓訪客進入大樓(解密)
- 大樓內部就不需要再出示證件了(內部走明文)
- 這樣每間辦公室(後端伺服器)就不用各自安排保安
# Nginx 處理 SSL/TLS 終止
server { # HTTPS 虛擬主機
listen 443 ssl; # 監聽 443 Port 並啟用 SSL
server_name myapp.com; # 網域名稱
ssl_certificate /etc/ssl/certs/myapp.crt; # SSL 憑證檔案路徑
ssl_certificate_key /etc/ssl/private/myapp.key; # SSL 私鑰檔案路徑
ssl_protocols TLSv1.2 TLSv1.3; # 只允許安全的 TLS 版本
ssl_ciphers HIGH:!aNULL:!MD5; # 使用高強度加密套件
ssl_prefer_server_ciphers on; # 優先使用伺服器的加密套件
# Nginx 負責解密,轉發給 Kestrel 時用 HTTP(明文)
location / { # 所有請求
proxy_pass http://localhost:5000; # 內部用 HTTP 就好(已在同一台機器)
proxy_set_header X-Forwarded-Proto https; # 告訴應用程式原始是 HTTPS
}
}
# HTTP 自動重導向到 HTTPS
server { # HTTP 虛擬主機
listen 80; # 監聽 80 Port
server_name myapp.com; # 網域名稱
return 301 https://$host$request_uri; # 永久重導向到 HTTPS
}
# 使用 Let's Encrypt 免費取得 SSL 憑證
sudo apt install certbot python3-certbot-nginx -y # 安裝 Certbot 和 Nginx 外掛
sudo certbot --nginx -d myapp.com -d www.myapp.com # 自動設定 SSL 憑證
# Certbot 會自動修改 Nginx 設定檔 # 並設定自動續約
sudo certbot renew --dry-run # 測試自動續約是否正常
Health Check
Nginx 被動健康檢查
# Nginx 開源版的被動健康檢查
upstream myapp_backend { # 後端伺服器群組
server 192.168.1.10:5000 max_fails=3 fail_timeout=30s; # 失敗 3 次後暫停 30 秒
server 192.168.1.11:5000 max_fails=3 fail_timeout=30s; # 同樣的失敗門檻設定
server 192.168.1.12:5000 backup; # 備援伺服器,平時不使用
}
# max_fails=3:連續失敗 3 次就標記為不健康 // 容錯次數
# fail_timeout=30s:30 秒後再試試看是否恢復 // 恢復等待時間
# backup:只有其他伺服器都掛了才啟用 // 最後防線
ASP.NET Core Health Check 端點
// Program.cs 中設定 Health Check
var builder = WebApplication.CreateBuilder(args); // 建構器
// 加入 Health Check 服務
builder.Services.AddHealthChecks() // 註冊健康檢查
.AddSqlServer( // 檢查 SQL Server 連線
builder.Configuration.GetConnectionString("DefaultConnection"), // 連線字串
name: "database", // 檢查項目名稱
timeout: TimeSpan.FromSeconds(3)) // 逾時時間 3 秒
.AddRedis( // 檢查 Redis 連線
"localhost:6379", // Redis 連線位址
name: "redis", // 檢查項目名稱
timeout: TimeSpan.FromSeconds(3)) // 逾時時間 3 秒
.AddUrlGroup( // 檢查外部 API
new Uri("https://api.example.com/health"), // 外部 API 的健康端點
name: "external-api", // 檢查項目名稱
timeout: TimeSpan.FromSeconds(5)); // 逾時時間 5 秒
var app = builder.Build(); // 建構應用程式
app.MapHealthChecks("/health"); // 對應到 /health 路徑
app.Run(); // 啟動
nginx.conf 完整範例
# /etc/nginx/nginx.conf 完整設定範例
user www-data; # Nginx 執行的使用者身分
worker_processes auto; # 工作程序數量(auto 會自動偵測 CPU 核心數)
pid /run/nginx.pid; # PID 檔案位置
events {
worker_connections 1024; # 每個工作程序的最大連線數
multi_accept on; # 允許一次接受多個新連線
}
http { # HTTP 區塊設定
# 基本設定
sendfile on; # 啟用高效檔案傳輸
tcp_nopush on; # 最佳化封包傳送
tcp_nodelay on; # 減少延遲
keepalive_timeout 65; # Keep-Alive 逾時時間 65 秒
types_hash_max_size 2048; # MIME 類型 Hash 表大小
server_tokens off; # 隱藏 Nginx 版本號(安全考量)
# MIME 類型
include /etc/nginx/mime.types; # 載入 MIME 類型對應表
default_type application/octet-stream; # 預設 MIME 類型
# 日誌設定
access_log /var/log/nginx/access.log; # 存取日誌路徑
error_log /var/log/nginx/error.log; # 錯誤日誌路徑
# Gzip 壓縮
gzip on; # 啟用 Gzip 壓縮
gzip_vary on; # 加入 Vary 標頭
gzip_proxied any; # 對所有代理請求壓縮
gzip_comp_level 6; # 壓縮等級(1-9,6 是好的平衡點)
gzip_types text/plain text/css application/json application/javascript; # 壓縮的類型
# 速率限制
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; # API 限流:每秒 10 個請求
# 後端伺服器群組
upstream dotnet_app { # 定義後端應用群組
least_conn; # 使用最少連線演算法
server 127.0.0.1:5000; # 本機的 ASP.NET Core 應用
server 127.0.0.1:5001 backup; # 備援實例
}
server { # 虛擬主機設定
listen 443 ssl http2; # 監聽 HTTPS 並啟用 HTTP/2
server_name myapp.com; # 網域名稱
ssl_certificate /etc/ssl/certs/myapp.crt; # SSL 憑證
ssl_certificate_key /etc/ssl/private/myapp.key; # SSL 私鑰
ssl_protocols TLSv1.2 TLSv1.3; # 安全的 TLS 版本
ssl_ciphers HIGH:!aNULL:!MD5; # 高強度加密
# 靜態檔案
location /static/ { # 靜態檔案路徑
alias /var/www/myapp/wwwroot/; # 對應到實際目錄
expires 7d; # 快取 7 天
access_log off; # 靜態檔案不記錄存取日誌
}
# API 路由(有速率限制)
location /api/ { # API 路徑
limit_req zone=api burst=20 nodelay; # 套用速率限制,允許突發 20 個
proxy_pass http://dotnet_app; # 轉發到後端群組
proxy_http_version 1.1; # HTTP 1.1
proxy_set_header Upgrade $http_upgrade; # WebSocket 支援
proxy_set_header Connection keep-alive; # 保持連線
proxy_set_header Host $host; # 主機名稱
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 真實 IP
proxy_set_header X-Forwarded-Proto $scheme; # 原始協定
}
# 其他所有請求
location / { # 預設路徑
proxy_pass http://dotnet_app; # 轉發到後端
proxy_http_version 1.1; # HTTP 1.1
proxy_set_header Host $host; # 主機名稱
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 真實 IP
proxy_set_header X-Forwarded-Proto $scheme; # 原始協定
}
}
# HTTP 重導向到 HTTPS
server { # HTTP 虛擬主機
listen 80; # 監聽 80 Port
server_name myapp.com; # 網域名稱
return 301 https://$host$request_uri; # 301 永久重導向
}
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤一:反向代理後拿不到客戶端真實 IP
// 錯誤:以為 HttpContext.Connection.RemoteIpAddress 是客戶端的 IP
var ip = HttpContext.Connection.RemoteIpAddress; // 拿到的是 127.0.0.1(Nginx 的 IP)!
// 原因:經過反向代理後,Kestrel 看到的是 Nginx 的 IP
// 真正的客戶端 IP 被放在 X-Forwarded-For 標頭中
// ✅ 正確做法:啟用 ForwardedHeaders 中介軟體
// 在 Program.cs 中加入:
app.UseForwardedHeaders(); // 必須放在 UseAuthorization 之前
// 這樣 RemoteIpAddress 就會自動讀取 X-Forwarded-For
❌ 錯誤二:負載平衡時 Session 遺失
// 錯誤:使用 In-Memory Session,負載平衡時 Session 會遺失
builder.Services.AddDistributedMemoryCache(); // 記憶體快取(只存在單一伺服器)
builder.Services.AddSession(); // 啟用 Session
// 問題:使用者第一次請求到 Server A 登入
// 第二次請求被分到 Server B,Session 不在 B 上面,被登出!
// ✅ 正確做法:使用分散式 Session(Redis)
builder.Services.AddStackExchangeRedisCache(options => // 改用 Redis
{
options.Configuration = "localhost:6379"; // Redis 連線位址
});
builder.Services.AddSession(); // Session 資料存在 Redis,所有伺服器都能讀取
❌ 錯誤三:SSL 設定不安全
# 錯誤:允許舊版不安全的 SSL/TLS 協定
ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # SSLv3 和 TLSv1 有已知漏洞!
# 問題:SSLv3 有 POODLE 攻擊漏洞
# TLSv1.0 和 TLSv1.1 已被主流瀏覽器棄用
# ✅ 正確做法:只允許 TLSv1.2 和 TLSv1.3
ssl_protocols TLSv1.2 TLSv1.3; # 只用安全的版本
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; # 強加密套件
ssl_prefer_server_ciphers on; # 優先使用伺服器的加密設定