# 🔀 代理應用程式

反向代理會在一個或多個應用程式執行個體前面放上一個公開位址，而且不必修改應用程式。本頁從單一上游開始，逐步建立一個具備健康檢查、逾時與備援的上游池，最後說明應用程式那一端看到的是什麼。

📌 本頁描述 **v0.2.2**。

## 🧾 開始之前

- 已安裝並執行 Pingclair（[安裝](/zh-TW/start/install/)），實驗期間先停掉服務：`sudo pc service stop`。
- 一個在本機連接埠上監聽的應用程式。這裡的範例使用 `127.0.0.1:3000`。
- 代理本身要用的連接埠：範例中是 `:8080`。

## 🔀 單一上游

```caddyfile
{
    admin 127.0.0.1:2019
}

http://:8080 {
    reverse_proxy 127.0.0.1:3000
}
```

```bash
sudo cp Pingclairfile /etc/Pingclair/Pingclairfile
sudo pingclair validate /etc/Pingclair/Pingclairfile
sudo kill -USR1 "$(systemctl show -p MainPID --value pingclair)"
curl -i http://localhost:8080/
```

回應來自應用程式，帶著它自己的標頭。`admin` 選項讓 `pingclair reload` 能連到執行中的伺服器；上面的 `SIGUSR1` 重載則不需要它（[重載意味著什麼](/zh-TW/start/service/#-重載意味著什麼)）。

## ⚖️ 多個上游

用 `to` 列出各個執行個體，再選擇流量如何分配：

```caddyfile
http://:8080 {
    reverse_proxy {
        to 127.0.0.1:3000
        to 127.0.0.1:3001
        lb_policy round_robin
    }
}
```

若應用程式會回報是哪個連接埠回應的，六個請求會在兩個執行個體之間輪流：

```text
3000 3001 3000 3001 3000 3001
```

| `lb_policy` | 行為 |
| --- | --- |
| `round_robin` | 依序輪流選擇上游。 |
| `random` | 隨機選擇上游，預設策略。 |
| `least_conn` | 進行中連線最少的上游。 |
| `ip_hash` | 同一個用戶端位址永遠連到同一個上游。 |
| `first` | 第一個可用的上游。 |
| `header <name>`、`cookie <name>`、`query <name>` | 依該欄位雜湊，讓同一個工作階段固定在一個執行個體上。 |
| `weighted_round_robin <w> …` | 每個上游一個權重，寫在同一行。 |

權重 `0` 會排空該上游；大於 `100` 的權重及所有主要上游權重皆為 `0` 的集區會被拒絕。`lb_try_duration` 只限制新重試開始的時間，不截斷已開始的回應。上游可能已收到請求後，自動重試只重複冪等方法。

權重也可以設定在每個上游上，當每個執行個體各有理由時，這樣寫比較好讀：

```caddyfile
http://:8080 {
    reverse_proxy {
        to 127.0.0.1:3000 {
            weight 3
        }
        to 127.0.0.1:3001
    }
}
```

⚠️ `lb_policy weighted_round_robin 3 1` 會把權重對應到寫在它上方的上游，所以 `to` 這幾行必須寫在它 **之前**。順序反過來的話，`validate` 會以 `2 weights were given for 0 upstreams` 拒絕這個檔案。

標記為 `backup` 的上游，只有在其他所有上游都無法使用時才會被用到：

```caddyfile
http://:8080 {
    reverse_proxy {
        to 127.0.0.1:3000
        to 127.0.0.1:3001 {
            backup
        }
    }
}
```

兩者都正常時，每個請求都會送到 `3000`。停掉那個行程，下一個請求就由 `3001` 回應。

## 🩺 健康檢查

沒有健康檢查時，上游要等到有請求失敗後才會被移出輪替。健康檢查會在背景探測每個上游，在使用者的請求抵達之前就把失敗的上游移除：

```caddyfile
http://:8080 {
    reverse_proxy {
        to 127.0.0.1:3000
        to 127.0.0.1:3001
        health_check {
            path /health
            interval 2s
            timeout 1s
            status 200
            consecutive_failure 2
            consecutive_success 1
        }
    }
}
```

應用程式需要一個回應成本低的端點，這裡是 `/health`。每次狀態改變都會被記錄下來，要知道某個執行個體何時離開輪替，就看這裡：

```text
INFO pingclair_proxy::health_check: 🩺 Active upstream health changed backend=Inet(127.0.0.1:3001) healthy=false
INFO pingclair_proxy::health_check: 🩺 Active upstream health changed backend=Inet(127.0.0.1:3001) healthy=true
```

以這份設定實測：停掉第二個執行個體後，所有流量都送往第一個；它恢復後，經過 `consecutive_success` 次成功的探測就重新加入。Caddy 的扁平寫法（`health_uri`、`health_interval`、`health_timeout`、`health_status`、`health_fails`、`health_passes`）設定的是同一套檢查。

## ⏱️ 逾時

逾時設定寫在 `reverse_proxy` 裡面的 `transport http` 區塊中，而不是直接寫在 `reverse_proxy` 底下：

```caddyfile
http://:8080 {
    reverse_proxy {
        to 127.0.0.1:3099
        to 127.0.0.1:3000
        transport http {
            connect_timeout 1s
            first_byte_timeout 1s
            read_timeout 30s
            write_timeout 30s
        }
    }
}
```

實測：`127.0.0.1:3099` 不接受任何連線時，`connect_timeout 1s` 會花掉一秒，接著請求改送到第二個上游重試，得到 `200`。若應用程式接受了連線，卻等 3 秒才送出本文，則改由 `first_byte_timeout 1s` 生效，用戶端會收到 `504`。

`dial_timeout` 不是 `reverse_proxy` 的選項；寫在那裡的話，`validate` 會以 `Unknown directive 'reverse_proxy: dial_timeout'` 拒絕這個檔案。`transport http` 接受 `dial_timeout` 與 `connect_timeout`。

## 🔁 以主機名稱指定上游

上游可以是主機名稱，而不是位址。容器重啟後換了 IP 位址時，需要的就是這個：

```caddyfile
{
    dns_refresh 5s
}

http://:8080 {
    reverse_proxy {
        to api.internal:3000
    }
}
```

名稱會依該間隔重新解析，每次重新解析都會記錄下來：

```text
INFO pingclair_proxy::dns: 🔄 Upstream DNS scheduler enabled interval_secs=5 pools=1
INFO pingclair_proxy::dns: 🔄 Upstream DNS refresh changed=1 adopted=0 kept_stale=0 unresolved=0
```

以 `/etc/hosts` 作為唯一依據實測：`api.internal` 指向 `127.0.0.1` 時由第一個執行個體回應；把檔案改成 `127.0.0.2` 後，在間隔時間內就改由第二個回應，不需重啟，也沒有任何請求失敗。查詢失敗時，會保留先前的位址繼續輪替。

## 📨 上游看到的是什麼

應用程式會收到原本的 `Host`，用戶端位址則放在常見的標頭裡：

```text
{
  "host": "127.0.0.1:8080",
  "x_forwarded_for": "127.0.0.1",
  "x_forwarded_proto": "http",
  "x_real_ip": "127.0.0.1"
}
```

如果前面還有另一個代理，除非它列在 `trusted_proxies` 中，否則這些標頭裡的位址會是那個代理的位址；[Cloudflare Tunnel 指南](/zh-TW/guides/cloudflare-tunnel/)說明了這種情況。

## ⚠️ 無法運作時

- **代理回傳 `502`。** 沒有任何上游回應。請確認應用程式正在監聽（`sudo ss -ltnp | grep :3000`），而且位址相符。由 Pingclair 產生的 `502` 或 `504` 會帶有 `Proxy-Status: pingclair; error=…`；自訂 `handle_errors` 回應也不帶此欄位，不能只以缺少欄位判定來自應用程式。
- **停頓一陣子後出現 `504`。** 有逾時觸發了：後端太慢是 `first_byte_timeout`，本文太慢是 `read_timeout`，主機始終不接受連線則是 `connect_timeout`。
- **`Unknown directive 'reverse_proxy: …'`。** 這個選項屬於某個巢狀區塊——逾時放在 `transport http` 底下，檢查放在 `health_check` 底下——`validate` 會指出它拒絕的確切寫法。
- **設定變更沒有生效。** 重載無法新增或搬移監聽器。新檔案有這類變更時，unit 的狀態列會列出變動的位址，執行 `sudo pc service restart` 即可套用。請見[以服務方式執行](/zh-TW/start/service/#-重載意味著什麼)。
- **每個請求都落在同一個執行個體上。** 它是唯一健康的那一個。健康檢查的日誌會說明其他執行個體何時、為何離開輪替（`ConnectRefused`、`failure_statuses` 等）。

## 🧭 下一步

- [提供靜態網站](/zh-TW/guides/static-site/)：壓縮、快取，以及單頁應用程式的後備路由。
- [`reverse_proxy`](/zh-TW/reference/directives/#reverse_proxy)：指令參考。
- [以服務方式執行](/zh-TW/start/service/)：重載、重啟與日誌。

📌 連線建立後才失敗的後端——截斷的本文、回應結束前重設、格式錯誤的回應——會計入被動健康狀態，並依 Caddy 的 `max_fails` 與 `fail_duration` 決定留在輪替外的時間。 [CHANGELOG](https://github.com/dorianverlaine/pingclair/blob/main/CHANGELOG.md).
