明明是 CORS,兇手卻是 502
除錯戰記
Ricky-
08 月 21,2026

一切正常,除了打不通

image 已經 push 上 registry,容器也 docker run 起來了, docker ps 看過去綠油油一片。反向代理設好了、憑證也掛上了, https://blog.clelereve.com 打開來版面完整。

只有一件事不對:頁面上一篇文章都沒有。打開 Console,紅字寫著:

Access to fetch at 'https://clelereve-blogapi.clelereve.com/api/categories/articles/latest/'
from origin 'https://blog.clelereve.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

這則訊息非常明確、非常有自信,而且完全是錯的。 接下來一個多小時,我都在修一個根本不存在的問題。

第一個直覺,也是最貴的那個

看到 CORS 就去改 CORS,這是本能。我打開 Django 的 settings.py, 準備把前端網域加進白名單——結果設定早就是最寬鬆的那種了:

CORS_ALLOW_ALL_ORIGINS = True

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',   # 必須在最前面
    'django.middleware.common.CommonMiddleware',
    ...
]

corsheadersINSTALLED_APPS 裡、middleware 排在第一位、 允許所有來源。這是教科書上的正確寫法。

我還是不死心,跑去確認線上跑的那個 image 到底含不含這段設定—— 畢竟有可能是發版時打包到舊的 commit。拉下 v1.0.1 進去翻,設定也在。

到這裡應該要響警鈴了:當你確認了三次「這個設定是對的」, 問題大概就不在這個設定上。但我當時只是覺得奇怪,繼續往 CORS 的方向鑽。

反覆確認同一份設定檔,卻始終沒有懷疑它以外的東西

把瀏覽器拿掉

真正的轉折點是我放棄用瀏覽器測,改用 curl 直接打那個 API:

curl -i https://clelereve-blogapi.clelereve.com/api/categories/articles/latest/

回來的東西讓我愣了一下:

HTTP/1.1 502 Bad Gateway
Server: nginx
Content-Type: text/html

502。從頭到尾就沒有 CORS 的事。後端根本沒有回應這個請求—— 連 Django 都沒摸到,哪來的 CORS header。

為什麼 502 會長得像 CORS

這是整篇文章最值得記住的一段。瀏覽器的 CORS 檢查是對回應做的, 而且不管回應的狀態碼是什麼——200 要檢查,502 也要檢查。

當上游掛掉時,nginx 會自己生一頁 502 錯誤頁回給你。這頁錯誤頁是 nginx 產的, 不是你的應用程式產的,所以它不可能帶著你在 Django 裡設定的 CORS header。 瀏覽器拿到一個跨網域、沒有 Access-Control-Allow-Origin 的回應, 於是照規矩擋下來,並且報告它唯一知道的事實:「這個回應沒有 CORS header」。

它沒有騙你,它只是沒有告訴你更重要的那件事。 而更諷刺的是,資訊一直都在——Network 分頁裡那筆請求,狀態碼從頭到尾誠實地寫著 502。是 Console 的紅字太顯眼,把我的注意力整個吸走了。

所以這條規則值得寫進肌肉記憶:跨域錯誤如果來得莫名其妙, 先確認上游還活著。尤其當你的 CORS 設定「看起來明明就是對的」的時候。

往上游追:Django 有在聽嗎?

確認是 502 之後,方向就清楚了:nginx 找不到、或連不上後端容器。可能性由近而遠有三個—— Django 沒在聽、網路不通、nginx 設定寫錯。

先查最常見的那個。容器裡的服務如果綁在 127.0.0.1, 從容器外面(包括同一台機器上的別的容器)是打不到的,必須綁 0.0.0.0

docker inspect -f '{{.Config.Cmd}}' clelereve_blog_backend
# [python3 manage.py runserver 0.0.0.0:8000]

綁對了。順便看一下 port:

docker ps --format '{{.Names}}\t{{.Ports}}'
# clelereve_blog_backend    8000/tcp

只有 8000/tcp,沒有 0.0.0.0:xxxx->8000/tcp。 這代表這個容器沒有對 host 發布 port——而這是刻意的。 反向代理架構下,nginx 和後端在同一個 Docker 網路裡,用容器名互相找得到就夠了; 再對 host 開一個 port,只是多開一個對外的洞。

但這也表示我不能從 host 用 curl localhost:7999 來測它。得換個方法。

網路通嗎?兩個容器看得到彼此嗎?

這裡踩到一個很煩的小狀況:nginx 容器用的 image 是 jonasal/nginx-certbot:3裡面沒有 curl 也沒有 wget。沒辦法直接從 nginx 內部打後端。

退而求其次,至少可以確認兩件事——它們在不在同一個網路,以及 DNS 解不解得到:

# 兩個容器各自在哪些網路裡
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' clelereve_blog_backend
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' nginx_porting-nginx-1

# 從 nginx 容器裡解容器名(getent 幾乎每個 image 都有)
docker exec -it nginx_porting-nginx-1 getent hosts clelereve_blog_backend
# 172.19.0.9    clelereve_blog_backend

兩個容器都掛在 shared-net 這個共用網路上,DNS 也解得出 172.19.0.9。網路完全正常。

排除到這裡,嫌疑犯只剩一個了。

一層一層往上游排除,範圍愈縮愈小

真兇是一個底線

我去翻 nginx 的錯誤日誌——這件事我早該做的:

docker logs --tail 20 nginx_porting-nginx-1

裡面躺著一行:

[error] ... clelereve-blog-backend could not be resolved (3: Host not found)

clelereve-blog-backend。連字號。

而實際的容器名是 clelereve_blog_backend,底線。 我在寫 nginx conf 的時候,順手照著網域名稱的習慣打了連字號。 DNS 解不到這個名字 → nginx 連不上上游 → 回 502 → 502 錯誤頁沒有 CORS header → 瀏覽器報 CORS 錯誤 → 我去改 Django 設定。

一個字元,五層轉譯,最後在離現場最遠的地方爆出來。

1 nginx conf 把底線打成連字號 clelereve-blog-backend 真兇 2 Docker DNS 解不到這個名字 could not be resolved (3: Host not found) 3 nginx 連不上上游,回 502 錯誤頁是 nginx 自己產的 4 502 錯誤頁沒有 CORS header Django 從頭到尾沒被呼叫到 5 瀏覽器:被 CORS 政策擋下 你在 Console 唯一看到的東西 你看到的
一個字元的 typo,經過五層轉譯,在離現場最遠的地方變成一則跟它毫無關係的錯誤訊息。 你從第 5 層開始找,但答案在第 1 層。

正確的 conf 長這樣:

server {
    listen 443 ssl;
    server_name clelereve-blogapi.clelereve.com;
    ssl_certificate     /etc/letsencrypt/live/louis/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/louis/privkey.pem;
    client_max_body_size 200m;

    location / {
        resolver 127.0.0.11 valid=30s;
        set $up_blogapi clelereve_blog_backend;   # 底線!typo 過就是 502
        proxy_pass http://$up_blogapi:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }
}

這裡順帶說一下 resolver + set 這個寫法。 如果你直接寫 proxy_pass http://clelereve_blog_backend:8000;, nginx 會在啟動時就把容器名解析成 IP 並記住它—— 之後容器一重啟換了 IP,nginx 還在打舊 IP,你得 reload 才會好。

把上游名稱放進變數,nginx 就會改成執行時才解析, valid=30s 決定快取多久。127.0.0.11 是 Docker 內建的 DNS。 代價是:拼錯名字不會在 nginx -t 時被抓到, 要等到真的有請求進來才炸——也就是我遇到的情況。

下次可以照表操課

這次的教訓整理成一張表。遇到 502(或長得像 CORS 的 502)時, 看 nginx log 加上一次 curl,就能把病因收斂到一個:

nginx log 顯示從 host curl 容器 IP:8000病因修法
upstream 是 :7999 或別的 port有回 JSONnginx port 打錯改成 8000 後 reload
upstream :8000 connection refused也 refusedDjango 沒在聽runserver 0.0.0.0:8000
upstream :8000有回 JSONconf 沒生效/名字拼錯set $up_xxx 的拼字
解不到容器名DNS/網路沒接上docker network connect shared-net <容器>

錯誤訊息只知道它自己看到什麼

這次除錯真正花掉的時間,不在找那個底線,而在相信第一則錯誤訊息

瀏覽器沒有說謊,它只是站得太遠。它看到的是「一個沒有 CORS header 的回應」, 這是它視角裡百分之百準確的描述。但它不知道那個回應是 nginx 生的、 不知道上游從來沒被連上、更不知道兩百公尺外有個底線被寫成了連字號。

所以真正該養成的習慣不是「背下 CORS 錯誤的十種成因」,而是換一個視角再看一次

  • 瀏覽器報錯 → 用 curl 把瀏覽器整個拿掉,看原始狀態碼
  • 前端連不到後端 → 從最靠近後端的那一層開始查,而不是從最靠近你的那一層
  • 設定「看起來明明是對的」→ 那它八成真的是對的,去懷疑別的東西

每換一個視角,訊息就少騙你一點。

下一篇

502 修好,文章終於長出來了。站也上線了、東西也跑起來了, 接下來我想知道的是:這幾個服務各自吃掉多少 CPU 和記憶體?

於是我加了一支 /api/stats,用 Node 的 os 模組讀資源用量。 數字很漂亮,也很好看——直到我發現它量的根本不是我的容器。

Tags:
除錯nginxDockerCORS


相關文章
React整合Hilltop廣告教學
React整合Hilltop廣告教學
ricky - 2025-12-28T09:28:46Z
本篇將介紹如何在 React 專案中整合 Hilltop 廣告,從元件化、動態載入到寬高設定與多單元支援的技術細節。
幫旅遊部落格加上「訂閱電子報」
幫旅遊部落格加上「訂閱電子報」
ricky - 2026-08-22T09:12:14.065632Z
把旅遊部落格的訂閱從只有版型接到能寄信、能退訂、發文自動通知——全貌與 Gmail、單重確認等決策取捨。
從零開始製作一個ip查詢網站-第三章 透過IP取得地理位置
從零開始製作一個ip查詢網站-第三章 透過IP取得地理位置
chris - 2025-11-29T06:05:00Z
從使用者 IP 取得地理資訊不是難事,但要完整呈現城市、國家、緯度/經度就需要好用的 API 與前端實作。這篇文章示範如何使用 ipapi.co 取得位置資料,並在 React 裡動態顯示使用者所在位...
訂閱電子報
隨時掌握最新的創業故事與設計開發過程
內容包含來自麒航團隊的日常分享、專業知識與工具推薦,助你了解更多創業背後的思維與技術。
麒航私房推薦
Clelereve Blog LogoClelereve Blog LogoClelereve Blog Logo
廣告區
Clelereve Blog Logo
聯絡我們
任何合作或問題請洽:
clelereve@gmail.com
Follow us
Copyright © 2025 blog.clelereve.com - All Rights Reserved
Frontend Version: -- | Backend Version: --
創業實驗筆記 - Clelereve Blog | 明明是 CORS,兇手卻是 502 | Clelereve Blog