iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

本文同步發表於個人部落格:上線檢查清單


火線超人本來跑在 DigitalOcean。後來要跟其他醫院介接,對方要求資料不能出境,整台搬回國內。

搬家不是把檔案複製過去就好。主機位址變了,網域要重新指過去,憑證要重簽。而 app 那邊有一份設定也必須跟著改,改的地方還不在自己的程式碼裡。

前面二十七天,你的 app 一直跑在 localhost。今天講把它推出去。最容易壞的那一條跟部署工具無關,而且同樣是你在本機重現不出來的那種。

你要推上去的是什麼

打開第四幕那個目錄,裡面是 index.htmlapp.jsmerge.jsservers.js 四支檔案,加一個 vendor/

沒有 package.json,沒有建置步驟,也沒有伺服器端程式。所以部署就是把這幾個檔案原樣放上去,放到一台能給你 HTTPS 的靜態伺服器。

這件事有兩面。好的一面是不會有建置失敗這種事,你在本機看到的就是使用者看到的。另一面是每個檔案都會被讀到,servers.js 也一樣,第三條講的就是這件事。

也因為沒有伺服器端程式,整排容器、資料庫與資料搬移的部署問題跟你無關。那些是火線超人那種後端服務才要處理的。

上線的順序

這五步不能換順序,換了會卡在中間某一步。

  1. 挑一家靜態託管,先把目錄推上去一次,拿到那個公開網址
  2. 把網址帶去授權伺服器那邊,註冊成 redirect URI
  3. 在瀏覽器打開那個網址,console 敲 window.isSecureContext,要回 true
  4. 跑一次完整授權,從按下登入到畫面出現病人資料
  5. 對照 sandbox 那次的結果,確認畫面上的資料一致

第一步跟第二步不能對調,你要先有網址才註冊得了。而第二步沒生效之前,第四步一定會失敗在授權那一側。

挑託管看兩件事。第一是有沒有自動幫你上 HTTPS。GitHub Pages、Cloudflare Pages、Netlify 都有。自己架的話憑證要自己處理。Caddy 會自動申請,nginx 配 Let's Encrypt。

第二件事只有醫療這一行才會遇到:主機放在哪個國家。開頭那次搬遷就是為了這個。

你只連 sandbox 的時候碰不到它。等你要接第一家真的醫院,它就是先決條件。而且是換機房等級,不是改一行設定。

第四步是整份清單最容易被跳過的一步。首頁打得開不代表 app 能用,因為授權那一段完全沒被碰到。

火線超人搬第二次主機時,驗收是逐項實測記在任務底下的。五個頁面與兩支 API 都回 200,webhook 由平台的真實事件驗證。而且明確區分了哪些錯誤是應用程式本來就有的,不是這次搬遷造成的。

你的驗收沒那麼多項,但形狀一樣。要跑到病人資料出現在畫面上才算過,不是看首頁。

下面五條就是每一步會壞在哪裡。

第一條:redirect URI 要重新註冊,而且要完全一致

你的 app 現在是這樣寫的:

FHIR.oauth2.authorize({
  iss: server.fhirBaseUrl,
  clientId: server.clientId,
  scope: server.scope,
  redirectUri: window.location.pathname,
})

window.location.pathname 在本機是 /。fhirclient 會用目前的 origin 把它補成 http://localhost:5173/。換到公開網域,補出來的就是 https://你的網域/

授權伺服器那邊記著一份你註冊過的網址清單。導回的網址不在清單裡,它就不導,直接給你一個錯誤。

它在各家的設定畫面上不一定叫 redirect URI。OAuth 規範的參數名是 redirect_uri。很多平台的欄位標成 Callback URL,指的是同一個。

麻煩在於那是逐字比對。少一條斜線、協定寫成 http、多一個 www。這三個在你眼裡是同一個網址,在它眼裡是三個不同的字串。

還有一件事更容易漏掉。sandbox 跟正式環境通常是兩份清單,各自註冊,不會互相同步。這兩邊通常是兩個窗口,甚至兩套申請流程。

兩者對照圖,米色底,標題「授權伺服器只認清單上那一筆」,副標「比對是完全一致,不是差不多。下面三個都只差一個地方,三個都不會導回來」。畫面上方是一塊佔滿寬度的深藍色圓角大色塊,左上角一行淺藍灰小字寫「正式環境那份清單裡註冊過的」,底下一張白色圓角卡片,以深藍色等寬粗體寫 https://你的網域/ ;色塊右側一個青綠色圓形白勾號,右邊接淺青色字「導得回去」。深藍色塊下方由上而下三張白底淺灰外框的圓角長條,每一條的左側是等寬字的網址、中段是灰色小字的原因、最右側是一個淺珊瑚底加珊瑚色外框的標籤,三條的標籤都寫「不在清單裡」。第一條的網址是 https://你的網域 ,結尾多接一個珊瑚色虛線外框的空方格代表缺掉的字元,原因寫「結尾少一條斜線」。第二條的網址是 http://你的網域/ ,其中 http 四個字母是珊瑚色、其餘是深藍色,原因寫「協定寫成 http,不是 https」。第三條的網址是 https://www.你的網域/ ,其中 www. 是珊瑚色,原因寫「多一個 www」。三張長條下方是一個淺褐底、灰藍色外框的圓角區塊,左上角以珊瑚色粗體寫「而且是兩份清單,各自註冊,不會互相同步」,區塊內部由一條垂直虛線分成左右兩半。左半上行灰色小字「sandbox 那一份」,中間深藍色等寬粗體 http://localhost:5173/ ,下行灰色小字「你在這裡註冊過」。右半上行灰色小字「正式環境那一份」,中間深藍色等寬粗體 https://你的網域/ ,下行灰色小字「要另外再註冊一次」。圖片最下方一行灰字註記:程式碼用 window.location.pathname,app 跟著網域走,要改的只有伺服器那兩份清單

所以兩份清單這件事在 sandbox 上測不出來。要等到上線當天,使用者按下登入才會發現。

有一個順手的自保方式:不要在程式碼裡寫死完整網址。window.location.pathname 會跟著網域變,要改的只有伺服器上那兩份清單。

這一條為什麼排第一?因為它是那種程式碼完全正確、部署也成功,使用者按下去卻不會動的問題。錯誤訊息只有一句 Invalid redirect_uri parameter,day09 那次實測就是這句。它不會告訴你差在哪個字元。

第二條:沒有 HTTPS,你的 app 算不出 PKCE

這一條不是最佳實務,是硬性的技術限制。

day08 講過 PKCE。你的 app 送出授權請求之前要先算一個 code_challenge,用的是 SHA-256。

那個雜湊是瀏覽器的 Web Crypto 算的。而 crypto.subtle 只在安全情境下才存在,你會遇到的兩種是 HTTPS 或 localhost

fhirclient 對這件事有明確的錯誤訊息,翻原始碼看得到:

Some of the required subtle crypto functionality is not available unless you run this app in secure context (using HTTPS or running locally).

所以順序是這樣。先有 HTTPS 才有 crypto.subtle,算得出 code_challenge 才送得出授權請求。不是上線之後有空再加 TLS。

兩者對照加時序流程圖,米色底,標題「四種來源,只有兩種算安全情境」,副標「crypto.subtle 只在安全情境下存在,算不出 code_challenge 就沒有授權」。畫面上半部由上而下四張圓角長條,每一條左側是等寬字的來源、中段是灰色小字說明、右側是一個判定標籤。第一條白底,來源 https://你的網域/ ,說明「憑證有效的 HTTPS」,右側淺青底青綠字的勾號標籤寫「安全情境」。第二條白底,來源 http://localhost:5173/ ,說明「規範特別放行,所以本機一直沒事」,右側同樣是青綠色的「安全情境」標籤。第三條改成淺珊瑚底、珊瑚色外框,來源 http://你的網域/ 以暗紅色寫,說明「crypto.subtle 直接是 undefined」,右側淺珊瑚底珊瑚色字的叉號標籤寫「不是安全情境」。第四條同樣是淺珊瑚底,來源 http://192.168.x.x:5173/ ,說明「用區網 IP 給手機測,同樣算不出來」,右側標籤一樣寫「不是安全情境」。四張長條下方是一塊佔滿寬度的深藍色圓角色塊,左上角淺藍灰小字寫「授權能不能送出去,取決於這條鏈的第一環」,右上角珊瑚色小字寫「下面兩種來源,斷在第一環之後」。色塊裡由左至右是四個較淺的藍色方塊串成一條鏈,第一個方塊上行白色粗體「安全情境」、下行淺藍灰小字「HTTPS 或 localhost」,第二個是「crypto.subtle」加「Web Crypto 拿得到」,第三個是「code_challenge」加「SHA-256 算得出來」,第四個是「授權請求」加「送得出去」。第二與第三、第三與第四之間各有一個灰藍色向右箭頭,但第一與第二之間沒有箭頭,取而代之的是一條珊瑚色的垂直粗線貫穿整條鏈的高度,線的中央疊著一個珊瑚色圓形白色叉號。圖片最下方一行灰字註記:本機一直沒事是因為 localhost 被放行,不是因為 HTTP 可以

day08 舉過一個很容易踩到的例子:為了用手機測而改開 http://192.168.x.x:5173。區網 IP 不是安全情境,crypto.subtle 就沒了。

這一條沒做的後果特別難查。使用者按下登入就停在原地,而你在本機永遠重現不出來。

第三條:secrets 不進版控

你的 app 沒有 secret 可以外流。

瀏覽器裡的檔案使用者全都打得開,密碼寫進去等於公開。所以 SMART 不發密碼給這種 app。day07 講過,它叫 public client。

授權伺服器改用 day08 那組 PKCE 的亂數。它證明來換 token 的就是發起授權的那個程式。那組亂數每次授權重新產生,換完就作廢。

打開 servers.js 看一眼。每台伺服器只記 FHIR base URL、clientIdscope,加一個顯示用的 labelclientId 本來就是公開的識別碼。

但你只要往前一步做後端,情況就變了。火線超人那邊有 client secret,它只存在加密的資料庫欄位裡。值從環境變數帶進去,解密金鑰不在版控裡。

驗收方式很具體:在版控歷史裡搜那個 secret 的值,必須零命中。

明文憑證進版控,一旦發生就收不乾淨。你刪得掉那一行,但舊的 commit 裡還有那一筆。而且補救不是改一行程式碼,是換掉那把憑證,還要改每一個用它連線的地方。

第四條:沒有監控,等於不知道自己掛了

火線超人那份部署文件建議了六項設定,從 CPU 告警到每日資料庫備份都有。

我去翻設定檔想確認哪幾項真的裝了。找得到痕跡的只有兩樣,而且兩樣都不在那份建議清單上。

兩者對照圖,米色底,標題「文件建議一整排,設定檔裡找得到兩樣」,副標「而且設定檔裡那兩樣,一項都不在建議清單上」。畫面分左右兩欄。左欄上方灰色小標「部署文件建議的(節錄)」,底下由上而下六個灰色虛線外框、內部完全不填色的圓角長條,每個長條左側有一個灰色虛線的空心小圓,右邊是灰字項目,由上而下分別是 CPU 與磁碟告警、可用性監控、集中式日誌、錯誤回報服務、防火牆規則、每日資料庫備份。右欄上方深藍色小標「設定檔裡真的有的」,底下是兩張白底、有淡陰影的圓角卡片,比左欄的長條大得多。第一張卡片標頭是一個青綠色圓形白勾號加深藍粗體「日誌層級設成」,後面接一個淺灰底的等寬字標籤 info,卡片內文兩行灰字寫「另外定義了兩個查日誌的別名。一個看全部,一個只 grep 錯誤。」第二張卡片標頭同樣是青綠色勾號加深藍粗體「記憶體上限寫死」,內文三行灰字寫「被實測逼出來的。容器級的上限讓超量時只殺掉那個容器並重啟,不觸發全主機的連鎖反應。」兩欄下方是一張佔滿寬度的深藍色橫幅,左側白色粗體寫「建議的那幾項,找不到任何一項做過的痕跡」,下一行淺藍灰字寫「兩邊沒有一項重疊,所以不是做了一部分,是各做各的」;橫幅右側沒有數字,是兩行靠右對齊的純文字,上行以淺藍灰小字寫「建議的那些」,後面接珊瑚色粗體「找不到做過的痕跡」,下行以淺藍灰小字寫「設定檔裡那兩樣」,後面接白色粗體「真的做了」。圖片最下方一行灰字註記:相依清單裡沒有任何錯誤回報或效能監控的套件,只有兩個安全掃描工具

一樣是日誌層級,另一樣是被實測逼出來的記憶體上限。這一段我照實寫,因為漂亮的版本對你沒有用。

那你那個靜態 app 呢?你沒有伺服器程序會掛掉,所以監控對你來說是另一回事。

你的 app 跑在使用者的瀏覽器裡,錯誤不會回到你這邊。第一條那個 redirect URI 註冊錯了,使用者按下登入就卡住。而你這邊什麼都收不到,唯一的管道是有人跑來跟你講。

這就是靜態 app 版本的監控缺口,而下一條講的是同一種安靜。

第五條:快取要有失效路徑

你的 app 裡有兩層快取。一層是你自己寫的,另一層是瀏覽器自己做的。

你寫的那一層

day22 把每台伺服器的 discovery 結果存了下來,存活時間 24 小時。唯一的失效方式是有人手動去清。

這個範例本身踩不到那個坑,day22 說過了。FHIR.oauth2.authorize() 自己會去抓一次 discovery,授權用的不是你快取的端點。

真正會踩到的是拿快取的端點去打 token 端點做 refresh。端點換掉之後,請求會打到舊位址而失敗。而且快取沒過期之前,你每次重試都會打到同一個舊位址。

所以這一條的檢查項不是有沒有裝監控,是有沒有路徑讓你知道快取過時了。

瀏覽器自己那一層

你寫的那一層在哪一行、存多久、怎麼清掉都查得到。瀏覽器那一層不一樣,它不在你的程式碼裡,你也沒設定過它。

我是在 day18 那個功能上撞到它的。把血壓寫回伺服器之後,我讓畫面重查一次再重畫趨勢圖。想法是查得回來才算真的存進去。結果存完那一筆,圖上就是不出現,要重整才會有。

第一時間我以為是寫入失敗。用 curl 直接讀那個 id,回 200,資源好好的躺在那裡。

第二個猜測是伺服器的搜尋索引晚幾秒才更新。實測下去也不對,寫完立刻查就查得到。

我還順手看了回應的標頭。沒有 Cache-Control,沒有 ETag,也沒有 Last-Modified。當下的結論是跟快取無關。

這個結論是反的,而且我是拿 curl 得到的。

真正的答案要在瀏覽器裡才看得到。同一筆 POST 之後,用兩種寫法各查一次:

client.request(q, …)                          19 筆,看不到剛寫的那一筆
client.request({ url: q, cache: 'no-store' })  20 筆,看得到

沒有那三個標頭,瀏覽器可以自己判斷這份答案還新不新鮮。我這次的瀏覽器就是這樣做的,直接把上一次的結果拿出來用。少了快取標頭不等於不快取。正好相反,沒有標頭就沒有任何指示叫瀏覽器回頭問。

修法是查生命徵象的時候明講不吃快取。client.request() 第一個參數改成物件才放得下 fetch 的選項:

client.request(
  { url: `Observation?patient=${client.patient.id}&code=${code}`, cache: 'no-store' },
  { pageLimit: 0, flat: true }
)

只有剛寫完要立刻重讀的查詢需要這樣。病況跟用藥不會在寫入之後馬上重查,讓它們繼續吃快取沒關係。

這一條值得加進你自己的清單。寫回去之後立刻重讀的那條路徑,中間有沒有一層瀏覽器的快取。

還有一個更通用的教訓。curl 看得到標頭,看不到瀏覽器拿那些標頭做了什麼決定。協定層的證據不能拿來回答執行環境的問題,這兩件事我那天混在一起了。

跟著做:拿這張表掃你自己的 app

打開你第四幕做出來的專案,逐條回答。每一條都在你自己的檔案裡查得到。

檢查項 怎麼查 你大概會查到
redirect URI 寫死了嗎 redirectUri,看它是不是 window.location.pathname 是,所以換網域時它會自己跟著變,但授權伺服器那邊要重註冊
你的 app 有 client secret 嗎 打開 servers.js 看有沒有 clientSecret 沒有,因為它是 public client
哪個欄位不該進版控 servers.js 那四個欄位,哪一個算敏感 四個都不算,clientId 本來就是公開的
沒有 HTTPS 會壞在哪 在瀏覽器 console 打 window.isSecureContext 本機回 true,因為 localhost 被放行
快取過時你會知道嗎 TTL_MS,看 24 小時到期前有沒有其他失效路徑 只有那顆「清掉全部授權」的按鈕會順手清掉它

最後一列要補的話有兩條路。一是授權失敗時自動清掉那台的快取再重試一次,二是把存活時間縮短。兩條都會多送幾次請求,你是拿速度換早一點發現端點變了。

小結

五條,後半句是不做會怎樣:

  1. redirect URI 要重註冊而且完全一致,不然使用者會卡在授權那一側,只看到一句 redirect_uri 不對
  2. 沒有 HTTPS 你的 app 就算不出 PKCE,授權連第一步都送不出去
  3. secrets 不進版控,寫進去一次舊的 commit 就留著,而且憑證要整把換掉
  4. 沒有監控就沒有系統主動通知你,只能等使用者跑來講
  5. 快取要有失效路徑,包括瀏覽器那一層你沒寫的

明天回頭看整個專案。哪些決策後來被自己推翻了,哪些九個月沒動過。


上一篇
Day27 - 醫療 app 的安全思維
下一篇
Day29 - SMART 那一層改過四次
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言