新版台大課程網那些事
「課程網站從來都沒修好」- 這是某年台大畢業歌的徵選的歌詞。
台大課程網是每個台大學生的必經之路,還記得我剛進台大的時候就被課程網這傢伙嚇得半死,選課還要加入購物車,而且購物車就算了,這個加入購物車的過程還會跳出新視窗,實在搞不清楚這個流程是幾年前的產物。
當然,台大的系統要嚇人也不是只有課程網,除了「新版台大課程網」以外的網站都是蠻嚇人的(XD)。
台大課程網在過去很長一段時間裡,一直都是被學生嫌棄的系統之一,而且排名還蠻高的。即便有過幾次的修改,但根本問題還是沒有被解決。
話說回來,強者我學長 Po-Hao (James) Chang (又稱 Mr. Po)跟他的幾個小夥伴在某堂課的課堂專案做了一個增強版的選課系統 -「NTU Course Neo」(下稱 Neo 團隊),一推出後就收到不少正向的回覆。後來 Neo 團隊跟教務處取得合作,因此新版台大課程網的計畫就開始動工了。
為了有辦法處理一整個學校 30000 多個學生的選課需求,我們在架構上做了不少的討論跟設計。
前情摘要
因為學校的考量資料安全跟個資保護的關係,因此我們只能自建機房,也就是那些美好的雲端服務都是不可以用了,那些很香的 Template、GKE 上的 Auto Scaling、Load Balancer、Anycast DNS,全部都不能用,全部都要自己從頭到尾架出來,這無非直接加大了實作難度,也增加了不少維護成本。
剛好這個我這次在這個專案中就是負責架構師的工作,對於我這個近年來只用過 Cloud Service 的人來說,實在是非常痛苦,真的好久沒有遇到需要自己架設 Database 的情境了。
還好教務處本來就有自己的機房,因此我們要做的只有購買跟安裝機器就好了。
主架構
我們的架構是在一大堆 Bare Metals 上面安裝 Proxmox Virtual Environment,並在 PVE 上開各種 VM。這個設計主要是為了方便維護,例如某台 VM 去世的時候可以不需要實體的救援,又或是直接操作 Based OS 導致某些異常,而需要通過 IPMI 救援之類的問題。
Application Layer Infrastructure
我們的兩個主服務分別是 API Server 跟 Front-end Server,這兩個服務都是建立在 Kubernetes 上的。因為 Front-end 有部分頁面有使用到 Server-side Rendering,會需要一個可以 Serving 它的地方,也就是不能單單只用 File Server。
Kubernetes
至於要用 Kubernetes 的原因,主要是為了 Horizontal scaling,由於我們上述的服務皆是 Node.js Based 的,而 Node.js 為 Single-threaded,因此 Natively 在一台 Multithreaded 的機器上是沒辦法發揮最大效能,需要藉由一些套件輔助完成,例如 PM2 Clustering。
當然,有人可能會想到 PM2 Clustering / VM-based Horizontal scaling 也很好,沒必要大費周章去弄 Kubernetes。
對於某些公司或是團隊而言,確實是成立的,但剛好有一個前提是,我們幾乎每一個 Team Member 都對 Docker / Kubernetes 有一定程度上的了解(大部分的人上過 NASA,但我是沒有,因為我沒寫 hw0⋯⋯),加上我認為 PM2 Clustering 需要考慮太多 OS Environment 的變因,還有 VM-based scaling 其實會耗費不少資源,又或是 Scaling 速度不夠快之類的問題,所以後來還是決定使用 Kubernetes。
Kubernetes on Bare metal
網路上應該有不少教學指出這件事情怎麼實作,總之大略說起來就是需要一台 Master node(預設是不能 Serving Pods),然後以 Kubeadm
把 Worker Node(也就是 VM)加到 Master node 裡面以建立 Clustering。
Kubernetes 在 Bare metal 的架構上架設還蠻麻煩的,例如上面提到要用 Kubeadm
把 Node 加在一起變成 Cluster,光這一部就經常遇到問題,我們遇過 Flannel 會自己炸開,導致加入 Cluster 之後的 Worker 的連線會莫名的 Connection refused。
而且部署需要 External IP 的服務時,還需要特別指定 NodePort,或是用 Metal LB 做 Network load balancer,例如 Load Balancer (Kong、Istio-Envoy 等等⋯⋯)就會需要。
總之,架設跟維護的體驗真的是挺糟的。
Ingress
雖然 Kubernetes 有內建的 Ingress,但因為我們要加一些特定的需求,例如 RBAC,或是打算在 Gateway 上幹壞事,因此我嘗試了幾個 Ingress,分別是 Kong、Istio-Envoy、Trafik,後來是決定使用 Kong。
就設定與部署而言,最簡單的是 Kong,也是功能最少的,但這僅限於 Kubernetes Ingress Controller,而非典型的 Kong Gateway,這兩者之間有極大的差異,有興趣的人可以自行了解。
原本是已經確定使用 Istio-Envoy,但後面在做壓力測試的時候發現 Istio 會對每個 Pod 做 Envoy Sidecar Injection,而每一個 Injected Envoy Sidecar 會需要使用額外的資源,因此對於資源的消耗是相對有感的增加,在各種測試跟考量下就棄用了 Istio-Envoy,改用 Kong。
我自己是很喜歡 Istio,一方面是 Istio 自己提供了不少 Features,加上原本 Envoy 的 API 也可以使用,因此整個 Gateway 的設定就變得非常彈性。
話說,用過那個多套的心得是-如果你不是真的想折騰,用 NGINX Ingress Controller 就好了,該有的東西幾乎都有,要 Monitoring 也有 Grafana Integration 可以用,實在不需要搞自己。(哪天心血來潮再來寫我們怎麼做各種 Monitoring 好了⋯⋯)
Tips
在 Ingress 上綁上 Request ID 會加快 Debugging 的速度喔!
Database
由於我們每一個 Database Instance 都可以接受不少的 Connections,因此 Connection Pool 的管理就成了一個大問題。
先假設 Count of allowed connections / pods(5)
是個理想的公式,並可以套用在每個 Pod 上面。
假設 Maximum Scaling Pods = 5,Database 可以乘載的 Connections 為 1000,那每個 Pod 的 Connection 理想上應該是 200。
事情總是沒有那麼美好,我們發現某些 Pod 還是會出現 Connection Pool 不夠用,但某些 Pod 卻沒有吃滿的問題,這個時候有一種解法,那就是用 Database load balancing 的工具,例如 PgBouncer。
這也不是不能,但一樣要考慮到 PgBouncer 也要做 HA 的狀況,而 PgBouncer 要做 HA 又是一件非常麻煩的事情,因此在跟老師討論完之後,決定把 Ingress 上的 Load balancing algorithm 改成 Least connections,以解決這個問題。
至於詳細工作模式就不多贅述了,可以參考這篇文章 Round-robin vs Least connection 了解這兩者之間的區別。
Front-end performance tuning
前端主要的 Code 還是從 Neo 來的,由於 Neo 本身開發時間比較短,因此有不少部分不是依循著 Best Practice,更不用說有時間做 Performance tuning 了。
我們的前端是用 Next.js,因此我們主要是著重在 React 本身做優化,理論上很多部分 Webpack 都幫我們做好了,但事實是 Webpack 做的往往不夠,例如 Webpack 雖然可以幫你做 Code splitting,但這也是在有好好做 Component lazy load 的狀況下,剛好我們前端的程式碼「都」沒有做 Lazy load,因此我們花了一些時間把大多 Component 改成 Lazy load。
LRU Cache
在我們做了 LRU Cache (Custom Server)之後發現狀況並沒有改變,但還是可以提一下。
Next.js 預設是沒有 LRU Cache 的選項的,因此每一個頁面都需要 Re-render 才會出現,對於課程網而言,這個 Behavior 是不需要存在的,畢竟課程資訊不會每分每秒都在變,因此我們可以在 Front-end 的 Server 做 Cache(Browser Cache 跟 Server Cache 是不一樣的),確保不需要做不必要的 Re-rendering,省下不必要的運算資源浪費。
我們在做壓力測試的時候有發現跟 API Server 一樣在 Round-robin 狀況下會導致某個 Pod 被撐爆,導致許多 Connection Blocks,又或是直到 Timeout,這一樣是改用 Least connections 解決的。
Disabled Gzip Compression
我們把前端的 Gzip Compression 關掉了,因為我們的某一台 Gateway 會做,因此需要讓負責處理 Compression 的 Gateway 拿到 Uncompressed 的檔案。
煩躁的東西
計中 SSO
這也是所有台大學生的必經之路,當然要作為一個合格的校內系統,那需要支援 SSO 也是再合理不過的事情,但是⋯⋯
作為一個校內系統,支援 SSO 是合理的需求,而台大計中是負責提供這個服務的部門。然而,我們在整合 SSO 時遇到了一些困難。首先,計中並沒有提供具體的 SSO 串接文件,且他們支援多種認證方式,包括加密和非加密方式。這種多樣性和缺乏詳細文件說明讓整個團隊在準備上線時感到壓力重重。
一開始,我們發現計中未提供明確的 SSO 串接文件,且他們支援兩種不同的認證方式,一種是加密的,另一種是非加密的。更複雜的是,必須提供給 IDP(計中)的資料也各有不同,而且我們遇到問題時並沒有收到明確的錯誤訊息。這使得我們在進行調試時只能通過致電計中的負責人來尋求支援,幸運的是,他們非常支持我們,我們對他們的協助深表感謝。
後來我們幾乎把每個 SAML / WS-Fed 的驗證方式都做過一次,才知道到底計中用的是哪種 Protocol,因為計中有表示不能透露具體的資訊,因此大家請自己摸索。XD(註:如果你也是要串台大 SSO 的人,可以透過校內管道找到我,在確定沒問題的狀況下我可以幫你解答。更新:我已經離開課程網團隊,可以直接發 Email 或在 Facebook 找到我。)
除此之外,由於 SSO 2.0 的 Callback Redirection 是使用者在前端做 POST 給我們指定的 Callback URL,也就是計中做了一個網頁,裡面放了一大堆的 Hidden Fields,並透過 Javascript 執行 Form.submit()
,因此我們只能把 Token 放在 URL 回傳給前端,這無非增加了 Token leaking 的風險。
我不確定是不是計中的 Implementation 不正確,但由於我們沒有辦法在流程上做任何改變。在 Same-origin 的狀況下,我們雖然可以直接寫 Cookie,但想到未來可能有跨平台的可能性,所以就沒有往這方面實作。
總之,我們做了一個叫做 Transition Token 的機制,也就是拿 Callback 的 Transition Token 跟我們換正式的 Access Token。
後記
其實整個台大課程網的架構從設計到完成大約也就花了一個月的時間左右,並且大多數時間都在做 Configuration Tuning 還有把環境架起來。
真的要說的話,我覺得最難的其實是跟目前現有的課程網流程串接起來,例如舊有的課程網其實都是 Web-based(Session-based)的架構,完全沒有 Token 或是 API 的概念。
在多次與負責人溝通後,他們提出「你們可以直接寫入我們的資料庫」的建議,但我個人覺得這樣的建議並不太合理。特別是在我們尚未建立良好的權限管理(RBAC)機制的情況下,這樣的做法可能會導致嚴重的問題。
因此我也為了 Integration 這件事情寫了不少 PHP Code 以在舊有的架構上開 API,這確保我們真的以 Best Practice 實作,而不會留下一大堆後續需要通靈的產物。
可能很難相信,新版課程網的 Core Function 幾乎都是在學學生寫出來的,臺大的學生還是非常 Capable 的,非常值得讚賞。以結果來說,整體還是很不錯的。但其實以某些角度來看,我還是必須說整個工作流程是真的蠻累的就是,還好臺大學生學習能力還是蠻快的,通靈的能力也是蠻精確(XD)。
最後,希望新版課程網在我們這些比較有活力的猴子離開之後還可以保有它該有的活力。
這篇文章於 2023 年 2 月初完成,而我在同年 2 月底離開了課程網團隊。雖然我深切地希望能夠實現一些願望,但最終結果可能並不如預期。這是一個令人遺憾的事實,但也許並不出乎意料。