# DividendTracker ## 概述 本專案旨在開發一款網頁應用程式,方便使用者記錄歷年股利領取情況,並根據需求擴展更多功能。 > GitHub repo: [DividendTracker ](https://github.com/Potassium-chromate/DividendTracker/tree/main) ## 專案目標 本次開發重點為: - 前端:提供使用者友善的介面,方便輸入與管理股利資料。 - 後端:使用 Flask 架設 API,並搭配 Microsoft SQL Server 進行資料儲存與管理,以加強對 MySQL 和 Flask 架構的實作經驗。 ## 前端架構介紹 ### 登入介面 - 登入機制:使用者需先登入才能使用應用程式。在點擊 **Log in** 按鈕時,系統會查詢 SQL 資料庫以驗證帳戶是否存在,確保使用者身份的正確性。 - 若使用者尚未註冊,可透過 Sign up 按鈕創建新帳號。系統將在 SQL 資料庫中建立對應的 Account 與 Password,以供後續登入使用。 ![image](https://hackmd.io/_uploads/H1xfYLiYkl.png) ### 寫入資料介面 寫入畫面如 圖一 所示,當使用者點擊 "+" 按鈕時,系統將彈出輸入視窗(見 **圖二**)。在該視窗中,使用者可以輸入以下資訊: - **股票 ID(stock_id)** - **持有總量(amount)** - **選擇交易日期** 此外,股票名稱(**stock_name**) 將透過後端查詢 **stock_id** 對應的名稱,並自動填入欄位,無需手動輸入。 ### 資料提交流程 1. 使用者填寫相關資訊後,點擊 **"Submit"** 按鈕,系統會將該筆資料寫入前端暫存的 **table**。 2. 若需正式儲存至後端,使用者需再點擊 **右上角的 "Save"** 按鈕,系統才會將資料寫回後端資料庫。 #### 圖一 ![image](https://hackmd.io/_uploads/ryugLwiKJe.png) #### 圖二 ![image](https://hackmd.io/_uploads/HkTkvDjtye.png) ### 嵌入式Power Bi圖表 詳細可以參考 [PowerBi.jsx](https://github.com/Potassium-chromate/DividendTracker/blob/main/DividendTracker/src/PowerBi.jsx),其中的 `src` 則是填入Power Bi提供的內嵌連結,具體連結可以在Power Bi的網頁版找到(如 圖二)。並且,如果在Power Bi桌面端連結SQL資料庫時,選擇direct query,則Power Bi就會及時與 SQL 連棟更新。 #### 圖一 ![image](https://hackmd.io/_uploads/H1XFJOsKkg.png) #### 圖二 ![image](https://hackmd.io/_uploads/HJ5bJOsYJg.png) ## 後端架構架構 ### Flask架構 Flask檔案的配置如下 ``` ----- models/ # 定義 SQL 資料庫的資料結構 | | | ------ __init__.py # 建立資料庫 Instance | ------ dividends_model.py # 定義 "dividends" 表的資料結構 | ------ stocks_model.py # 定義 "stocks" 表的資料結構 | ------ user_model.py # 定義 "users" 表的資料結構 | ---- routes/ # API 路由處理 | | | ------ div_routes.py # 處理股利資料表的API | ------ user_routes.py # 處理用戶資料的API | ---- main.py # Flask 應用程式的入口點 | ---- config.py # SQL 資料庫連接設定 ``` ### Flask Flask 透過 `Flask(__name__)` 建立應用實例,以下是基本設定: ```python from flask import Flask from flask_cors import CORS app = Flask(__name__) # 建立 Flask 應用 # 設定 CORS,允許特定來源的請求 CORS(app, origins=["http://localhost:5001", "http://localhost:5174"], methods=["GET", "POST", "OPTIONS"]) ``` - `Flask(__name__)`:建立 Flask 應用程式實例,`__name__` 讓 Flask 可正確定位應用所在的模組。 - `CORS(app, origins=[...], methods=[...])`: - 允許 http://localhost:5001 和 http://localhost:5174 這兩個來源發送請求(解決跨域問題)。 - 限制允許的 HTTP 方法為 GET、POST、OPTIONS,確保安全性。 #### `config.py` - 資料庫設定 `config.py` 用於設定 Flask 應用的 SQL Server 連線資訊,主要包含: - 資料庫帳號 (`Username`) 和密碼 (`Password`) - 伺服器 (`Server`) 和資料庫名稱 (`Data_Base`) - ODBC 驅動程式 (`Driver`) **改進版程式碼(使用環境變數提高安全性)** ```python import os class Config: def __init__(self): self.Username = os.getenv("DB_USERNAME", "sa") # 從環境變數讀取,預設為 "sa" self.Password = os.getenv("DB_PASSWORD", "") # 密碼應存於環境變數,避免硬編碼 self.Server = os.getenv("DB_SERVER", "localhost\\SQLEXPRESS") # 伺服器位址 self.Data_Base = os.getenv("DB_NAME", "Data_Base") # 資料庫名稱 self.Driver = "ODBC Driver 17 for SQL Server" # ODBC 驅動程式 def config(self): return f"mssql+pyodbc://{self.Username}:{self.Password}@{self.Server}/{self.Data_Base}?driver={self.Driver}" ``` **改進點** 1. 避免硬編碼憑證 - ❌ 原本版本:將密碼 (`Password`) 直接寫入程式碼中,可能導致安全性問題。 - ✅ 改進版本:使用 `os.getenv()` 讀取環境變數,避免密碼外洩。 2. 提高可維護性 - 透過環境變數,開發者可以根據不同環境(開發/正式環境)設定不同的 DB 連線資訊,而不需要修改程式碼。 **設定環境變數的方法** 在 Linux / macOS 終端機: ```bash export DB_USERNAME="your_username" export DB_PASSWORD="your_password" export DB_SERVER="your_server" export DB_NAME="your_database" ``` 在 Windows 命令提示字元 (cmd): ```bash set DB_USERNAME=your_username set DB_PASSWORD=your_password set DB_SERVER=your_server set DB_NAME=your_database ``` ### models - 資料庫設定 `models` 資料夾負責管理 **SQLAlchemy** 資料庫實例,以及定義對應的 **Table 模型**。 **建立 SQLAlchemy 資料庫實例** 在 `models/__init__.py` 中,我們先建立一個 **全域的 SQLAlchemy 實例**,但此時還 未初始化,因為 `main.py` 會在應用啟動時進行初始化並連接到 SQL 資料庫。 ```python from flask_sqlalchemy import SQLAlchemy # 建立全域 SQLAlchemy 實例(尚未綁定資料庫) db = SQLAlchemy() ``` **定義資料庫表格 (Models)** 接下來,我們透過繼承 `db.Model` 來定義不同的資料表結構,之後呼叫由對應類別創建的實例後,即可操作在資料庫中對應的資料表。 :::info 在創建資料庫實例時,我們會用到 `db = SQLAlchemy()`,這個 `db` 物件負責管理與資料庫的互動,包括表的定義、查詢與事務處理。但是此時,這些 ORM Model 仍然只是 Python 內部的類別,並不會自動轉換成 SQL Table,而是需要執行 `db.create_all()` 才會正式建立表。 當我們定義一個 ORM Model (`Dividends(db.Model)`) 時,它確實是繼承 `db.Model`,但這樣的繼承並不是為了覆寫父類別方法,而是讓 `db.Model` 來管理這個 Model,並將它的結構註冊到 SQLAlchemy 的 metadata 中。 以下方程式碼為例: - 當我們 `Dividends()` 建立一個實例時,它代表 `dividends` 表中的一行記錄。 - 想操作整張表則可以使用 `db.session.query(Dividends).all()`(也可以縮寫成`Dividends.query.all()`)。 ::: **`Dividends` - 記錄股利發放資料** ```python from models import db from sqlalchemy import Float class Dividends(db.Model): __tablename__ = 'Dividends' # 指定 SQL Table 名稱 ID = db.Column(db.Integer, primary_key=True, autoincrement=True) # 唯一識別 ID,自動遞增 Stock_ID = db.Column(db.String(50), nullable=False) # 股票代號(外鍵 Stocks.Stock_ID) Stock_name = db.Column(db.String(50), nullable=False) # 股票名稱 Amount = db.Column(Float, nullable=False) # 領取股利金額 Date = db.Column(db.String(50), nullable=False) # 領取日期 ``` **`Stocks` - 股票基本資料表** ```python from models import db class Stocks(db.Model): __tablename__ = 'Stocks' # 指定 SQL Table 名稱 Stock_ID = db.Column(db.String(50), primary_key=True) # 股票代號(主鍵) Stock_name = db.Column(db.String(50), nullable=False) # 股票名稱 ``` **如何在 `main.py` 進行資料庫初始化?** 在 `main.py` 中,當 Flask 應用啟動時,才會 正式綁定資料庫 並進行初始化: ```python db.init_app(app) # 綁定 Flask 應用與資料庫 ``` ### Flask API 創建 (Blueprint) 在 Flask 中,當 API 變得 複雜且包含許多路由 時,使用 `Blueprint` 可以幫助我們更好地管理 API,讓程式碼更加模組化且易於維護。 :::info **什麼是 Flask Blueprint?** `Blueprint` 是 Flask 提供的模組化路由機制,允許我們將 API 拆分為不同的部分,而不需要將所有路由都寫在 `main.py` 中。 **Flask Blueprint 的運作方式** - Blueprint 本質上是 Flask 應用程式的延伸,它可以擁有自己的: - 路由 (`@route`) - 靜態檔案 - 模板 (`templates`) - 但 Blueprint 不是獨立的應用程式,它 需要在 Flask app 中註冊後才能運行。 ::: **創建 API Blueprint** 我們在 `routes/dividends.py` 中 定義 `Blueprint` 並建立 API 路由。 ```python from flask import Blueprint, request, jsonify from models.dividends_model import Dividends from models import db # 建立 Blueprint (名稱為 "dividends") div_bp = Blueprint("dividends", __name__) # 取得所有股利發放紀錄 (GET) @div_bp.route("/dividends", methods=["GET"]) def get_dividends(): dividends = Dividends.query.all() return jsonify([ { "ID": d.ID, "stock_id": d.Stock_ID, "stock_name": d.Stock_name, "amount": d.Amount, "date": d.Date } for d in dividends ]) ``` **如何在 `main.py` 註冊 Blueprint?** Blueprint 必須在 主應用程式 (`main.py`) 中註冊 才能生效。 在 `main.py` 中,我們需要 匯入 `div_bp`,並使用 `app.register_blueprint()` 來將其添加到 Flask 應用中。 ```python app.register_blueprint(div_bp, url_prefix="/api") # 指定 API 前綴 (可選) ``` **最終 API 路徑** 現在,我們的 API 會被掛載到 `/api/dividends`: ```bash GET http://localhost:5000/api/dividends ``` **如何在前端呼叫API** 在 React 中,我們可以使用 `axios` 向 Flask 後端發送 HTTP 請求,以獲取股利 (`dividends`) 資料。 ```jsx useEffect(() => { axios.get("http://127.0.0.1:5001/api/div/dividends") .then(response => setDividends(response.data)) .catch(error => console.error("Error fetching data:", error)); }, []); ``` ## SQL設定 具體設定方式可以參考: [SQL Server 安裝與設定](https://ithelp.ithome.com.tw/m/articles/10235779) ![image](https://hackmd.io/_uploads/HJAzns3tyl.png) ## 導入docker ### 概述 此專案中的 Docker 設定為開發和生產提供了一個隔離的環境。透過容器化應用程序,我們確保: - 可重現的環境(相同的 Python/Node/其他依賴項版本), - 更容易部署到伺服器或雲端服務, ### 撰寫`docker-compose.yml` 檔案位置: [docker-compose.yml](https://github.com/Potassium-chromate/DividendTracker/blob/main/DividendTracker/docker-compose.yml) 首先先確定docker中的架構為: ``` . ├── backend/ │ └── Dockerfile # Docker instructions for the Flask app ├── frontend/ │ └── Dockerfile # Docker instructions for the React app └── docker-compose.yml ``` 接下來是`docker-compose.yml`個個參數的意義 1. 版本:「3.8」 指定 Compose 檔案格式版本。 「3.8」是常用且穩定的版本。 2. 服務 - 後端: - build:./backend 告訴 Docker 使用資料夾`Dockerfile`內部`backend`來建立映像。 - container_name:flask_container 為容器指派一個名稱,以便於引用(而非隨機名稱)。 - restart: always 確保容器停止或 Docker 守護程序重新啟動時始終重新啟動。 - ports: ["5001:5001"] 將本機連接埠 5001 對應到容器的連接埠 5001(Flask 正在運作的位置)。 - 環境:... Flask 應用程式用於連接資料庫的 環境變數( )。`DATABASE_URL` - 網路:[app_network] 將容器放在上面,`app_network`以便它可以與此 Compose 檔案中的其他服務(如前端)對話。 - 前端: - build:./ frontend 指向React 應用程式的資料Dockerfile夾。frontend - container_name:react_container React 應用程式的容器名稱。 - depending_on:[backend] 確保後端容器首先啟動 - 如果 React 應用程式需要啟動並運行 API,則很有用。 - 網路:[app_network] 也加入同一個網絡,以便在需要時可以與後端通訊(例如,`http://backend:5001`從容器內部呼叫)。 3. 網路 - app_network:{} 定義一個`app_network`以您的所有服務命名的自訂網路。使用自訂網路而不是預設網路有助於保持容器隔離但在必要時能夠相互通訊。 ### 儲存庫佈局(相關 Docker 檔案) ``` DividendTracker/ ├── backend/ # Main backend code │ │ │ ├── main.py # Entry point or main script │ └── requirements.txt # Python dependencies (example) │ └── Dockerfile │ ├── frontend/ │ │ │ └── Dockerfile │ ├── docker-compose.yml # Defines how services relate to each other ├── Dockerfile # Instructions to build the app container └── README.md ``` ### 基本指令 - `docker-compose build`:建立由 Docker Compose 管理的 Docker image。 - `docker-compose up -d`:建立並啟動由 Docker Compose 管理的 container,加上 -d 代表的是讓這些 container 運行在背景 - `docker-compose down`:停止並刪除由 Docker Compose 管理的 container ### 遇到的問題 目前遇到的最大的問題就是在docker中無法正常使用blueprint。因此要把blueprint拔掉,之後即可正常運行。 ### 多階段構建 (Multi-stage Build) 原本的印象檔編譯後大約有 1.5 GB,對於在本機內保存多版本的印象檔來說非常大。因此我們採用多階段建構的方式來降低檔案大小。 簡單來說,舊版像是連同廚房、鍋具、果皮和甚至是廚餘一起打包給客人;新版則是只把煮好的菜端上桌。 我們可以把它拆解成三個主要差異: 1. 更換輕量化系統 (`Debian` vs `Alpine`) - 舊版 (`FROM node:18`):使用的是基於 Debian 的完整 Linux 系統,包含許多開發工具(git, curl, gcc 等),基礎大小就將近 1GB。 - 新版 (`FROM node:18-alpine`):在第二階段(Stage 2),我們改用 **Alpine Linux**。這是一個專為容器設計的超輕量系統,只保留最核心的執行環境,大幅縮減了底層 OS 的體積。 2. 拋棄龐大的開發依賴 (`node_modules`) - 編譯期:在 builder 階段,我們依然執行 `npm install` 安裝所有依賴(包含 Vite, TypeScript, Webpack 等),因為編譯過程需要它們。 - 執行期:到了最終階段(Stage 2),我們沒有把肥大的 `node_modules` 複製過來。我們只安裝了一個極小的 Web Server 套件 (`serve`)。可以再省下了數百 MB 的空間。 3. 只保留成品,不保留原始碼 (`COPY --from=builder`) - 舊版:`COPY . .` 會把整個專案的原始碼 (`src/`)、設定檔、Git 紀錄全部打包進去。 - 新版:透過 `COPY --from=builder /app/dist ./dist` 指令,我們只從第一階段「拿取」編譯好的靜態檔案 (`dist` 資料夾)。 - 優點:最終的 Image 裡**完全沒有原始碼**,不僅體積變小,安全性也更高(避免原始碼外洩)。 ```diff - FROM node:18 + FROM node:18 AS builder WORKDIR /app - COPY . . + COPY package*.json ./ RUN npm install + COPY . . RUN npm run build + FROM node:18-alpine + WORKDIR /app RUN npm install -g serve - EXPOSE 3000 +COPY --from=builder /app/dist ./dist - CMD ["serve", "-s", "dist"] +EXPOSE 3000 +CMD ["serve", "-s", "dist", "-l", "3000"] ``` ## K8S部屬: ### 基本概念 >參考: [k8s總結:架構整合、圖表整理](https://ithelp.ithome.com.tw/m/articles/10227983) Kubernetes 是一個由 Google 開發、現在由 CNCF 維護的開源容器編排平台(Container Orchestration Platform)。 **主要功能:** Kubernetes 用來自動化管理容器化應用的: - 部署(Deployment) - 擴展(Scaling) - 負載均衡(Load Balancing) - 滾動更新(Rolling Update) - 自我修復(Self-Healing) **基本概念:** | 元件 | 說明 | | ------------------------- | --------------------------------------------------------------- | | **Pod** | K8s 最小的運行單位,一個 Pod 內可以有一個或多個容器(通常是一個)。 | | **Node** | 實際運行 Pod 的主機(可以是實體機或虛擬機)。 | | **Cluster** | 一組 Node 構成一個叢集,K8s 負責調度與管理它們。 | | **Master(Control Plane)** | 負責控制整個 Cluster 的核心組件,包括 API Server、Scheduler、Controller、etcd 等。 | | **Service** | 為 Pod 提供穩定的網路訪問入口(因為 Pod 是動態生成的)。 | | **ConfigMap / Secret** | 提供應用程式設定值與敏感資訊的注入機制。 | ### Cluster、Node、Pod 的層級關係 ``` Cluster(叢集) ├─ Master Node(控制平面) │ ├─ API Server │ ├─ Scheduler │ ├─ Controller Manager │ └─ etcd(儲存叢集狀態) │ └─ Worker Nodes(工作節點) ├─ kubelet(與 Master 溝通) ├─ kube-proxy(負責網路) └─ Pods(實際執行應用的容器) ``` 在實際情況中,一台主機(實體/虛擬)通常==只會運行一個 Node。== - 每個 Node 代表 Kubernetes 的一個工作節點(worker node),K8s 會在上面部署 Pod。 - Node 通常對應到一個獨立的作業系統實例(例如一台 VM、一個 Docker 容器或一台物理伺服器)。 - 而一個 Cluster 可以包含多台主機(多個 Node)。 ### Control Plane (控制平面 / Master Node) #### API Server 可以將其想成是整個 K8s 叢集的「大門」與「櫃台」。 運作方式: - 當你打指令 `kubectl get pods`,其實你是發送 HTTP Request 給 API Server。 - Worker Node 上的 `Kubelet` 也要回報狀況給 API Server。 - 重點: 它是唯一一個能直接讀寫 `etcd` ==(資料庫)== 的元件。其他元件要存取資料,都必須透過它。 #### etcd 可以將其視為 ==叢集的記憶與靈魂== ,他是一個分散式的 Key-Value Store (鍵值資料庫)。K8s ==所有的資料==(有哪些 Pod、狀態是什麼、Config 是什麼)通通存在這裡。etcd 如果資料遺失,你的叢集就等於毀了。 :::info Production 環境的 etcd 要怎麼部署? 必須是 高可用性 (HA) 架構,通常是 3 台或 5 台 (奇數台)。為什麼是奇數?因為要投票選 Leader (Raft 演算法),偶數台容易造成 Split-brain (腦裂,僵持不下)。 ::: #### Scheduler 負責決定新來的 Pod 要去哪一台 Worker Node 執行。 它會看哪台機器 CPU/RAM 剩餘空間夠,或者有沒有特殊限制 (例如:這台機器有 GPU,或者這台機器專門給 DB 用)。 :::info Taints & Tolerations (污點與容忍度):為了讓 DB 跑得穩,我們可能會把某些 Node 標上「污點 (Taint)」,只允許特定的 DB Pod (有容忍度 Toleration) 跑在上面,避免被閒雜人等 (其他 App) 搶資源。 ::: #### Controller Manager 負責維持叢集的「期望狀態 (Desired State)」。 例子:你設定要有 3 個 Pod,如果現在只有 2 個 (因為有一台掛了),Controller Manager 發現「現狀不符期望」,就會通知 API Server 去補一個新的 ### Worker Node (工作節點) #### Kubelet(現場工頭) 每一台 Worker Node 上都有一個 Agent。 它聽 API Server 的命令根據 Pod 規格書 (PodSpec),在該節點上把Pod跑起來!它同時負責跟 Docker (或 Container Runtime) 溝通,叫 Docker 啟動容器。 它還會定時回報:「我這台機器還活著 (Heartbeat)」。 :::info 故障排除 (Troubleshooting): 如果發現某個 Node 狀態變成 NotReady,通常第一件事就是去那台機器檢查 systemctl status kubelet 看看它是不是掛了。 ::: #### Kube-Proxy 責維護節點上的網路規則 (iptables 或 IPVS)。它讓 K8s 的 Service 概念得以實現。 當有人要把流量送到 Service IP 時,Kube-Proxy 確保這些流量能轉發到正確的 Pod 身上。另外,Kube-Proxy 還負責處理 Service 的負載平衡與流量轉發 :::info 在正規的架構中,會額外設計Ingress Controller (例如 Nginx)。 Ingress Controller 本身其實也是一個 Pod(通常會搭配一個 LoadBalancer Service 暴露在公網 IP)。 當外部封包進來時,流程如下: 1. User -> LoadBalancer (公網 IP) -> Ingress Controller Pod (Nginx)。 2. Nginx 拆開 HTTP 封包,看裡面的網址(URL Path): - 是 / 嗎? -> 決定往 Frontend Service 送。 - 是 /api 嗎? -> 決定往 Backend Service 送。 3. 關鍵點: Nginx 決定要送給 Backend Service 後,這時候 Kube-proxy 才出場! 4. Nginx 發出請求給 backend-service (ClusterIP)。 5. Kube-proxy (iptables/IPVS) 攔截這個請求,把它轉發給真正活著的 Backend Pod。 ==如此一來,就不用在前端網頁以ip的方式連後端,而是以domain的方式== ::: #### Container Runtime(實際的執行者) 真正執行 Container 的軟體。以前大家只用 Docker,現在 K8s 支援 containerd 或 CRI-O。==K8s 其實不直接管容器==,它是透過 CRI (Container Runtime Interface) 介面來指揮這些 Runtime。 ### 建立 Pod流程 這是一個經典流程題,會用到剛剛提到的組件: 1. kubectl 發送請求給 API Server。 2. API Server 把資料寫入 etcd (紀錄:使用者想建一個 Pod)。 3. Scheduler 發現有個新 Pod 還沒地方住,計算後決定把它派到 Node-A,並告訴 API Server。 4. API Server 更新 etcd。 5. Node-A 上的 Kubelet 監聽到這個任務。 6. Kubelet 命令 Container Runtime (Docker) 啟動容器。 7. Kubelet 回報 API Server:「跑起來了!」 ### 大規模部屬 假設想要在每個 Node 上都部署相同的後端 Pod,可以在 yaml 中調整參數 `replicas`。以下方程式設定檔為例子 `replicas=3`, Kubernetes 會自動把這三個 Pod 分配到同一個 Cluster 所屬的不同的 Node 上。 ```yaml spec: replicas: 3 selector: matchLabels: app: backend template: metadata: labels: app: backend spec: containers: - name: backend image: eason20011215/dividendtracker-backend:v1.0 ``` 外部使用者在發送請求時並不是連接到某個 pod,而是連到 Service。 例如: ```yaml apiVersion: v1 kind: Service metadata: name: backend-service spec: selector: app: backend ports: - port: 80 targetPort: 5000 type: LoadBalancer ``` | 節點類型 | 主要組件 | 功能說明 | | ------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | **Control Plane Node(Master)** | - kube-apiserver<br>- kube-controller-manager<br>- kube-scheduler<br>- etcd | 負責整個叢集的「大腦」:接收 kubectl 指令、排程 Pod、維持狀態、記錄 etcd 資料。<br>👉 不直接運行業務 Pod (除非是小型或單節點測試環境)。 | | **Worker Node(工作節點)** | - kubelet<br>- kube-proxy<br>- container runtime(Docker 或 containerd) | 實際執行 Pod 與 Service,負責運行你的應用程式容器。<br>👉 每個 Node 上可以運行多個 Pod。 | 當使用者連線到 backend-service: - Kubernetes 的 Service(kube-proxy) 會負責負載平衡。 - 它會把請求分配給目前可用的 Pod(不管在哪個 Node 上)。 - Master Node 不會親自轉發流量,而是「決定誰該接收流量」,由 kube-proxy 在各 Node 執行負載平衡。 - 所以最少會需要==兩個 node==,一個為 Master Node,其餘為 Slave Node。 最後以層級來說: - 同一個 Service → 代表同一個應用邏輯(例如 backend)。 - 同一個 Cluster → 共享同一個 Kubernetes 控制平面。 ### DB 部屬 #### Deployment vs StatefulSet 一般來說,部署應用服務時,通常會使用deployment來進行部署,但在部署資料庫時,因為資料庫會將所有的table , user等資料都存放在volume內,所以會搭配一個持久性儲存,並且為了要讓Pod可以跟據負載擴展,同時還要能保持資料的一致性,以下針對二種資源類型的比較: - **Deployment:** 每次Pod在刪除或重建之後名稱都會改變,不適合拿來做Pod識別使用。 - **StatefulSet:** 只要Pod取得名稱之後,不管刪除或重建都不會被更改名稱,適合用來做識別Pod使用。並且因為具備順序的特性,也能確保volume可以掛回原本的volume,大部分的StatefulSet類型的應用都是這種方式。 為了讓 DB 叢集(例如 Master-Slave 架構)能運作,StatefulSet 保證了以下三點: - 穩定的網路識別 (Stable Network ID): - Pod 的名字是固定且有順序的:mysql-0, mysql-1, mysql-2。 - 就算 mysql-0 掛了,重建出來的 Pod 依然叫 mysql-0。這對 DB 很重要,因為 Slave 要認得 Master 是誰。 - 有序部署與擴展 (Ordered Deployment): - 啟動時:嚴格按照 0 -> 1 -> 2 順序啟動(Master 先起來,Slave 才能連)。 - 關閉時:嚴格按照 2 -> 1 -> 0 順序關閉。 - Deployment 是平行(Parallel)啟動的,這會導致 DB 叢集同步失敗。 - 穩定的儲存 (Stable Storage): - mysql-0 永遠綁定 pvc-0 (硬碟 0 號)。 - 就算 mysql-0 被排程搬到另一台 Node,它依然會掛載原本那顆 pvc-0 硬碟,確保資料不丟失。 #### Storage 鐵三角:PV, PVC, StorageClass > 參考資料: https://ithelp.ithome.com.tw/articles/10347335 1. StorageClass (SC) —— 菜單 - 定義: 定義後端儲存設備的種類(這是 Infra 工程師要預先建好的)。 - 例子: 「SSD-快」、「HDD-慢」、「AWS-EBS」。 - 作用: 讓 K8s 可以動態配置 (Dynamic Provisioning) 硬碟,不用人工介入。 2. PersistentVolumeClaim (PVC) - 定義: 開發者 (User) 寫的 YAML。 - 內容: 「我要 100GB 的空間,要讀寫模式 (RWO),請給我 SSD。」 - 重點: PV 是跟 PVC 綁定。 3. PersistentVolume (PV) —— 實體硬碟 - 定義: 叢集裡實際切出來的儲存空間(可能是 NFS Server 的一個資料夾,或是雲端的一顆 EBS 硬碟)。 - 生命週期: PV 的生命週期獨立於 Pod。Pod 死了,PV 裡的資料還在。 #### PV 與 PVC 的生命週期 1. Provisioning - 此階段為「PV 的產生」,有兩種產生方式: - Static:管理員自行定義並建置 PV,例如剛剛看到的 PV yaml。 - Dynamic: 管理員配置「StorageClass」,後續由 storageClass 來自動建置 PV。 2. Binding - PVC 被建立後,系統會搜尋可用的 PV 來綁定(binding),情況可分為以下幾種: - PVC 如果有設定 storageClassName,建立後系統會找相對應的 storageClass 來產生 PV。 - PVC 若**沒有**設定 storageClassName,系統會找 default storageClass 來產生 PV。 3. Using - 一旦 PVC 與 PV 綁定,Pod 同樣在 yaml 中透過 volume 的寫法掛載 PVC,可以開始使用 PV 的儲存資源。 #### Reclaiming 當 PVC 被刪除後,PV 的狀態則取決於 reclaimPolicy: | ReclaimPolicy | PV 的狀態 | 資料狀態 | | -------- | -------- | -------- | | Retain | 繼續留存,但已不可被使用 | 繼續留存 | | Delete | 直接刪除 | 直接刪除 | :::info Q: 當我部署一個帶有 PVC 的 Pod 時,發生了什麼事? A: 1. StatefulSet 根據 volumeClaimTemplates 產生一個 PVC。 2. K8s 的 PV Controller 看到這個 PVC 請求,根據指定的 StorageClass (例如 Ceph RBD)。 3. StorageClass 會去呼叫後端儲存系統,切出一塊硬碟空間,並自動建立一個對應的 PV。 4. K8s 把這個 PVC 與 PV 綁定 (Bind) 在一起。 5. Pod 啟動,Mount 這個 PV 到容器內的指定路徑 (如 /var/lib/mysql) ::: #### Headless Service:DB 專用的通訊錄 特性如下: - 與其他 Service 不同,==沒有 Cluster Service IP==。前面提過如果使用 StatefulSet 建立的 Pod 它的名字並不會因為重啟而改變,這時利用這個 DNS record 就能正確地與特定 Pod 連接,不用擔心名字改變、IP 改變 - 不需要負載平衡 前面提過 Service 有 ClusterIP 做負載平衡。但 DB 叢集有個問題: - 寫入只能寫給 Master (mysql-0)。 - 讀取可以讀 Slave (mysql-1, mysql-2)。 如果用一般的 Service,流量會隨機亂跑,寫入請求可能會跑到 Slave 導致錯誤。 **解決方案:Headless Service** - 設定: 在 Service YAML 裡設定 clusterIP: None。 - 效果: 它不會給一個單一 IP。 - DNS 解析: 當你去查這個 Service 的 DNS 時,它會直接回傳 「所有後端 Pod 的 IP 列表」。 - 用途: 讓應用程式或 DB 自己決定要連哪一台(Service Discovery)。配合 StatefulSet,你可以直接用 DNS 域名 mysql-0.db-service 精準找到 Master。 :::info Q: 一般我們用 NFS 或 Ceph 當儲存後端,是透過網路傳輸。但 DB 對 IOPS (效能) 要求極高,網路傳輸太慢怎麼辦? A: 我們會使用 ==Local PV==。 概念: 直接拿 Worker Node 本機插著的 NVMe SSD 當作 PV 給 DB 用。 代價: 喪失了飄移能力。因為資料在 Node A 的硬碟裡,所以這個 Pod 之後 「必須」 永遠被 Scheduler 綁死在 Node A 上。需要知道這會透過 ==NodeAffinity== 實現。 Q: 你在維護 K8s Node 要重開機,DB Pod 正在寫入資料,怎麼辦? A: 要確保 Pod 有設定 preStop hook。 做法: 在 Pod 被殺掉前,先執行一個腳本(例如 mysqladmin shutdown),讓 DB 把緩衝區資料寫回硬碟,確保資料一致性後再斷氣。 ::: ### DB 介紹 針對「DB 效能有上限,遇到大規模訪問怎麼辦?」這個問題,通常會祭出兩種方法:讀寫分離 (Read/Write Splitting) 與 分庫分表 (Sharding)。 #### 第一把刀:讀寫分離 (Master-Slave Replication) —— 解決「讀取」瓶頸 絕大多數的應用程式(如電商、FB、IG),特性都是 「讀多寫少」(你看 100 則貼文,才發 1 則限動)。 1. **Master (主庫):** 只有一台。負責**所有「寫入、更新、刪除」**的動作。它是權威數據源。 2. **Slave / Replica (從庫):** 可以有無限多台。它們會不斷從 Master 抄筆記(同步資料)。負責**所有「查詢 (Select)」** 的動作。 ![image](https://hackmd.io/_uploads/HJPGGzEE-x.png) - 當流量變大,前端 Pod 變多,查詢量爆衝。 - 只要一直加開 Slave Pod (透過 StatefulSet 擴展),就能撐住讀取流量。 - 瓶頸: ==Master 還是只有一台,所以「寫入」效能依然有上限。== #### 方法二:分庫分表 (Sharding) —— 解決「寫入」與「容量」瓶頸 如果你的業務大到像台積電的產線數據,或者像雙 11 的淘寶,連「寫入」都爆量,單一台 Master 寫不進去了,硬碟也滿了,怎麼辦? 這時就不能只靠複製了,要靠 「切分」。 **架構邏輯:** - 把資料依照某種規則(例如 User ID)切開。 - Shard A (分片 A): 負責存 User ID 1~10000 的資料。 - Shard B (分片 B): 負責存 User ID 10001~20000 的資料。 - 這兩台資料庫互不相關,各自為政。 **這解決了什麼?** - 寫入流量被分散了。 - 資料總量突破了單機硬碟的限制。 - 代價: 架構變得超級複雜。假如你要搜尋「某個名字叫 Amy 的人」,你必須去掃描所有的 Shard,效率反而變差。 #### middleware 但是如果你讓 App 開發者自己在程式碼裡面寫 `if (id < 10000) connect_db_A else connect_db_B`,他們會恨死你。 所以,需要在 App 和 DB 之間架一層 Proxy (資料庫代理)。 - Proxy 的角色: 它是 DB 的「大門神」。 - App 只需要連到 Proxy (單一入口)。 - Proxy 負責解析 SQL 語法: - 看到 INSERT/UPDATE -> 轉發給 Master。 - 看到 SELECT -> 輪詢 (Load Balance) 轉發給 Slave 1, Slave 2... - 常見工具: - MySQL 生態系:ProxySQL, MaxScale. - PostgreSQL 生態系:Pgpool-II, Pgbouncer. #### 觀念釐清 1. 在實務上硬碟 (PV) 跟 DB Pod 可能不在同一個地方,並且通常是透過網路傳輸? - **Network Attached Storage (NAS/SAN):** 在大多數企業環境(或雲端 AWS/GCP),PV 背後其實是 NFS Server、Ceph、或是雲端的 EBS/Persistent Disk。 - **情境:** 你的 DB Pod 跑在 Node A (機房的第一排機櫃),但你的資料其實存在 Node B (機房最後一排的儲存設備) 裡面。 - **機制:** 兩者透過網路線 (Network) 進行 I/O 讀寫。 - **影響:** 這就是為什麼 Latency (延遲) 這麼重要。如果網路慢,DB 就會慢。 - 這也是為什麼有 "Local PV": 為了追求極致速度,有些架構會強迫 DB Pod 只能跑在特定的 Node 上,直接讀寫那台 Node 插在主機板上的 NVMe SSD。這就是犧牲「移動性」換取「高效能」。 2. PV 的 ==accessModes(RWO, ROX...)== 並不是權限管控 (Security),而是「物理連接能力」的限制。 - 這些縮寫代表的是 Volume 的掛載能力: - RWO (ReadWriteOnce): 只能被「一個 Node」掛載 為讀寫模式。 - 比喻: 就像 USB 隨身碟,一次只能插在一台電腦上。一般的 DB (MySQL, Postgres) 的硬碟檔都是用這種,因為如果兩個人同時寫入同一個檔案,資料會壞掉 (Corruption)。 - RWX (ReadWriteMany): 可以被「多個 Node」同時掛載 為讀寫模式。 - 比喻: 就像 Google Drive 共用資料夾 (或是 NFS)。大家都可以丟檔案進去。這通常是用來放 Web Server 的靜態圖片、Log 檔,絕對不會用來放 DB 的核心資料庫檔案 (因為 DB 軟體通常不支援這種鎖定機制)。 - ROX (ReadOnlyMany): 多個 Node 可以同時讀。 - 結論: ==這不是用來管「使用者 User A 不能寫」==,而是用來管「硬體架構上,這顆硬碟能不能同時被兩台電腦插著」。 3. DB 的讀寫權限管控在於資料庫軟體 (Database Level) 裡面做。 解決方案:SQL Grant (帳號授權) - DB 管理員進入 MySQL 建立兩個帳號: - 帳號 `dev_user`: 給予 `GRANT ALL PRIVILEGES` (可讀寫)。 - 帳號 `app_user`: 給予 `GRANT SELECT` (只能讀)。 - 設定 App Pod: - Backend Pod A 的環境變數設定 `DB_USER = dev_user`。 - Backend Pod B 的環境變數設定 `DB_USER = app_user`。 ### minikube介紹 🖥️ 二、Minikube **介紹:** Minikube 是一個輕量級的 Kubernetes 實作版本,讓你可以在本地(例如 Ubuntu、macOS、Windows)啟動一個單節點的 K8s Cluster。 --- **用途:** 主要用於 開發與測試。 - 在本地模擬真實的 Kubernetes 架構。 - 可用於部署 Pods、Services、Ingress、ConfigMap 等資源。 - 測試部署流程與 YAML 設定,而不需使用雲端資源。 - 教學或開發新功能前的實驗環境。 --- **工作原理:** Minikube 會在你的本地系統中: 1. 啟動一個虛擬機或容器(Driver 可以是 Docker、Podman、VirtualBox...)。 2. 在該虛擬環境內安裝一個 ==單節點 Kubernetes Cluster==。 3. 自動建立: - Control Plane(Master 節點) - Worker Node(工作節點)→ 通常合併在同一個節點中運行。 4. 設定 kubectl 讓你能直接操作這個 Cluster。 --- **整體架構:** ``` 本機 └── Minikube ├── Kubernetes Cluster (名稱: minikube) │ └── Node (名稱: minikube) │ ├── kubelet │ ├── kube-proxy │ └── Pods (運行你的應用) └── 控制平面組件 ├── API Server ├── Controller Manager ├── Scheduler └── etcd ``` - 在 Minikube 環境中,這些組件都跑在同一台「虛擬機」中。 - 所以它模擬的是「單一節點 Cluster」,既是 Master,又是 Worker。 --- **總結:** | 概念 | Minikube 的對應 | | ----------------- | ------------------------------ | | **Cluster** | 自動建立的單一 Cluster(名稱:minikube) | | **Node** | 只有一個 Node(名稱:minikube) | | **Master Node** | 內嵌於同一個 Node 中 | | **Worker Node** | 同一個節點上執行 Pod | | **Service / Pod** | 使用者透過 YAML 定義並部署,Minikube 自動管理 | --- **Service** ``` NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE backend-service NodePort 10.103.101.179 <none> 80:32421/TCP 7d7h frontend-service NodePort 10.107.76.110 <none> 85:30788/TCP 7d6h kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 7d8h mssql-service NodePort 10.109.59.124 <none> 1433:32645/TCP 7d5h ``` - **TYPE** 服務型態,決定「叢集內外」如何連到它。常見有: - `ClusterIP`(預設):只有叢集內部能用虛擬 IP 存取。 - `NodePort`:在每個 Node 開一個高位連接埠(30000–32767),可用 `NodeIP:NodePort` 從外部打進來。 - `LoadBalancer`:會向雲端 LB 申請一個外部 IP;成功後 EXTERNAL-IP 會出現。 - **CLUSTER-IP** 叢集內的虛擬 IP(VIP)。Pod 彼此之間用這個 IP 存取 Service。Headless 會顯示 None。 - **EXTERNAL-IP 為什麼是 `<none>`?** 因為 `backend-service`、`frontend-service`、`mssql-service` 都是 NodePort,而 kubernetes 是 ClusterIP。 這兩種型態本來就不會幫你配置雲端外部 IP,所以 EXTERNAL-IP 會是 `<none>`(或空白)。只有 LoadBalancer 類型在雲端環境(GKE/AKS/EKS…)才會出現一個外部 IP(建立中會顯示 <pending>)。在裸機/自架環境若要 EXTERNAL-IP,通常要自己裝像 MetalLB 這類 L2/L3 LB。 - **PORT(S)** 顯示格式是:`<servicePort>:<nodePort>/<protocol>`(NodePort/LoadBalancer 會有 nodePort)。 例如 80:32421/TCP 代表: - 對外的 port 為: 32421 - 對內的 port 為: 80 --- 以下次本次實做的流程圖 ```mermaid graph TD %% 定義外部使用者與存取點 User((User / Browser)) DevTool((Dev Tools<br/>SSMS / DBeaver)) %% 定義 Cluster 邊界 subgraph K8s_Cluster [Minikube Cluster] style K8s_Cluster fill:#f9f9f9,stroke:#333,stroke-width:2px %% Frontend Layer subgraph Front_Layer [Frontend Layer] style Front_Layer fill:#e1f5fe,stroke:#0277bd SVC_Front["Service: frontend-service<br/>Type: NodePort<br/>Port: 85 -> 30788"] deploy_Front[("Deployment:<br/>frontend-deployment<br/>(Replicas: 1)")] style deploy_Front fill:#b3e5fc,stroke:#0277bd,stroke-dasharray: 5 5 Pod_Front["Pod: frontend-deployment-xxx<br/>(Static Assets)"] CM_Front(ConfigMap: frontend-config) end %% Backend Layer subgraph Back_Layer [Backend Layer] style Back_Layer fill:#fff3e0,stroke:#ef6c00 SVC_Back["Service: backend-service<br/>Type: NodePort<br/>Port: 80 -> 32421"] deploy_Back[("Deployment:<br/>backend-deployment<br/>(Replicas: 1)")] style deploy_Back fill:#ffe0b2,stroke:#ef6c00,stroke-dasharray: 5 5 Pod_Back["Pod: backend-deployment-xxx<br/>(API Server)"] CM_Back(ConfigMap: backend-config) Sec_Back(Secret: backend-secret) end %% Database Layer subgraph DB_Layer [Database Layer] style DB_Layer fill:#e8f5e9,stroke:#2e7d32 SVC_DB["Service: mssql-service<br/>Type: NodePort<br/>Port: 1433 -> 32645"] deploy_DB[("Deployment:<br/>mssql-deployment<br/>(Replicas: 1)")] style deploy_DB fill:#c8e6c9,stroke:#2e7d32,stroke-dasharray: 5 5 Pod_DB["Pod: mssql-deployment-xxx<br/>(Database)"] Sec_DB(Secret: mssql-secret) PVC_DB[("PVC: mssql-data<br/>2Gi, RWO")] PV_DB[PV: Standard Storage] end end %% 控制關係 deploy_Front -. "Manages" .-> Pod_Front deploy_Back -. "Manages" .-> Pod_Back deploy_DB -. "Manages" .-> Pod_DB %% --- 關鍵修正:流量關係 (Traffic Flow) --- %% 1. User 拿靜態網頁 User == "1. Load UI (HTML/JS)<br/>(NodePort: 30788)" ==> SVC_Front SVC_Front --> Pod_Front %% 2. User (Browser) 直接打 API User == "2. Fetch API Data<br/>(NodePort: 32421)" ==> SVC_Back SVC_Back --> Pod_Back %% 3. Backend 連 DB (內部流量) Pod_Back -- "3. SQL Query<br/>(Internal ClusterIP)" --> SVC_DB SVC_DB --> Pod_DB %% 4. 開發工具連 DB DevTool == "Admin Access<br/>(NodePort: 32645)" ==> SVC_DB %% 設定檔注入 CM_Front -.-> Pod_Front CM_Back -.-> Pod_Back Sec_Back -.-> Pod_Back Sec_DB -.-> Pod_DB %% 儲存掛載 Pod_DB === PVC_DB PVC_DB === PV_DB ``` ### 如何在裸機上建立 k8s 如果不用雲端託管服務 (EKS/GKE),要怎麼用裸機 (Bare Metal) 建立一個 Cluster?這時候就會需要讓 `kubeadm` 派上用場。 整個流程可以分為三部曲:**初始化 Master** -> **產生通行證 (Token)** -> **Worker 加入**。 #### 第一步:Master 初始化 在準備當作 Control Plane (Master) 的那台機器上,我們會執行這行最重要的指令: ```bash # 在 Master Node 執行 sudo kubeadm init --pod-network-cidr=10.244.0.0/16 ``` 這指令執行下去的幾分鐘內,發生了什麼事?: 1. **Pre-flight Checks:** 檢查機器規格(CPU/RAM夠不夠?Swap 有沒有關掉?)。 2. **憑證中心 (CA) 建立:** 在 `/etc/kubernetes/pki` 產生一堆憑證(CA Certificate)。這是整個叢集信任的基礎。 3. **產生設定檔:** 產生 admin.conf (給 kubectl 用) 和其他元件的 Config。 4. **啟動 Static Pods:** 叫 Kubelet 去把 API Server, etcd, Scheduler, Controller Manager 以 **Static Pod** 的形式跑起來。 5. **生成 Token:** 最後,它會印出讓 Worker 加入的指令。 #### 第二步:產生 Token 通常 kubeadm init 跑完最後一行就會給你 Join command。但如果你當時沒記下來,或者那是很久以前建的 Cluster(Token 預設 24 小時過期),如果還要產生新的token,可以參考以下方法。 ```bash kubeadm token create --print-join-command ``` #### 第三步: 加入 Worker Node 你拿著第二步得報的指令,登入到你的 Worker Node (確保 Docker/Containerd 和 Kubelet 已經裝好了),然後直接貼上執行: ```bash # 在 Worker Node 執行 sudo kubeadm join 192.168.1.100:6443 --token abcdef.0123456789abcdef \ --discovery-token-ca-cert-hash sha256:5c4a3b2... ``` 在執行的過程中發生了什麼事? 1. Worker Node 嘗試連線 `192.168.1.100:6443` (API Server)。 2. Worker 檢查對方的憑證 Hash 是否跟指令裡的一樣 (驗證 Master 身分)。 3. Worker 遞出 Token (驗證自己身分)。 4. Master 授權 Worker 下載 Kubelet 的設定檔。 5. Worker 的 Kubelet 啟動,並向 Master 回報 #### 第四步: CNI 網路插件 執行完 kubeadm join 後,kubectl get nodes 看到的狀態是 `NotReady`。 因為 ==K8s 預設沒有網路功能==。直到你安裝 CNI (Container Network Interface) 插件(如 Flannel, Calico, Weave)之前,Pod 之間是無法通訊的,DNS 也不會運作。 所以必須在 Master 上再執行一次 Apply (例如安裝 Flannel): ```bash kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml ``` 裝完這個,Node 才會變成 `Ready`。 ### 資源配置與設定 在生產環境中,因為資源有限,如果不寫資源限制,會發生著名的 「吵鬧鄰居 (Noisy Neighbor)」 問題——某個有 Bug 的 Pod 把整台 Node 的記憶體吃光,導致同一台機器上其他乖乖的 Pod(甚至 K8s 自己的元件)全部被卡死或殺掉。 #### 觀念解析 K8s 排程器 (Scheduler) 會根據 pod 的 yaml 檔中的特定欄位來做決策: - Requests (請求值/低消): - 意義: 告訴 K8s 至少需要這麼多資源 - 作用: Scheduler 用這個數字來決定要把 Pod 丟到哪一台 Node。如果 Node 剩餘資源少於 Requests,Pod 就會處於 **Pending** 狀態排不進去。 - Limits (限制值/天花板): - 意義: 告訴 K8s 最多只能用這麼多資源 - 作用: 防止單一 Pod 吃光資源。 **單位怎麼算?** - CPU: - 1 = 1 顆 vCPU (或是 1 個 Hyperthread)。 - 100m = 0.1 顆 vCPU (m 代表 millicores,千分之一核)。 - 注意:CPU 超用會被 Throttled (限速),變慢但不會死。 - Memory (RAM): - Mi (Mebibytes) 或 Gi (Gibibytes)。 - 注意:Memory 超用會被 OOMKilled (Out Of Memory Killed),直接被殺掉重啟。 #### 實際案例 ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: backend-deployment spec: replicas: 1 selector: matchLabels: app: dividend-backend template: metadata: labels: app: dividend-backend spec: containers: - name: backend image: eason20011215/dividendtracker-backend:v1.5 imagePullPolicy: Always ports: - containerPort: 5001 envFrom: - configMapRef: name: backend-config - secretRef: name: backend-secret # --- 新增這一段 --- resources: requests: memory: "128Mi" # 啟動至少要 128MB cpu: "100m" # 啟動至少要 0.1 核 limits: memory: "256Mi" # 最多只能用 256MB,超過就殺掉 cpu: "500m" # 最多只能用 0.5 核,超過就變慢 # ----------------- ``` #### QoS Classes 設定 (Quality of Service Classes) 當資源不夠時 K8s 怎麼決定誰是 VIP,誰是先被砍掉的犧牲品嗎? 這取決於你怎麼設定 Requests 和 Limits,K8s 會自動給你的 Pod 貼上 **QoS 標籤**,並且可以分為以下等級: 1. **Guaranteed (保證班/VIP):** - 設定:`requests == limits` (且 CPU/RAM 都要設)。 - 特權:這是最尊貴的 Pod。當 Node 資源不夠要殺人時,它最後才會有事。 - 適用:Database (你的 MSSQL 應該要設成這樣)。 2. **Burstable (彈性班/普通人):** - 設定:`requests` 小於 `limits` (或者沒設 Limit)。 - 特權:平時可以突發 (Burst) 用多一點,但如果資源緊繃,它比 Guaranteed 容易被殺。 - 適用:Backend/Frontend (你的 Flask/React 適合這個,因為流量會有高低峰)。 3. **BestEffort (盡力而為/賤民):** - 設定:完全沒寫 resources。 - 命運:當 Node 資源快滿了,K8s 第一個殺的就是這種 Pod。 - 適用:不重要的測試任務。 ## Cross-Origin 與反向代理 ### Cross-Origin (跨來源)介紹: 瀏覽器的 **同源政策 (Same-Origin Policy, SOP)** 規定,只有當以下**三個條件完全相同時**,才視為「同源 (Same-Origin)」,否則就是「跨來源 (Cross-Origin)」: 1. **協議 (Protocol):** 例如都是 https。 2. **主機 (Host/Domain):** 例如都是 example.com。 3. **埠號 (Port):** 例如都是 443 (或省略)。 **Origin 與 Host 的關係:** 當瀏覽器發送跨來源請求時,會自動帶上 Origin 與 Header 告訴後端:「我是從哪裡來的」。 - Header `Host`:請求的目的地 (後端伺服器)。 - Header `Origin`:請求的發起地 (前端頁面所在的網址)。 如果 `Origin` 不等於 `Host`,那就是 Cross-Origin。 ### Nginx 反向代理 (Reverse Proxy) 反向代理 就像是一個「中間人」或是「總機」。在前後端分離的架構中,前端 (`localhost:3000`) 和後端 (`localhost:8080`) 通常在不同的 Port,這導致了跨來源 (Cross-Origin) 問題。 我們可以使用 Nginx 跑在 `localhost:80` (標準 HTTP Port),讓它統一接收請求: - `/` 開頭的請求轉發給前端。 - `/api` 開頭的請求轉發給後端。 結果:對瀏覽器來說,它只面對 `localhost:80` 這一個對象。前端跟後端變成了「同一個來源 (Same-Origin)」,CORS 問題瞬間解決(因為根本沒有跨域了)。 ```mermaid graph LR Browser(瀏覽器<br>Origin: localhost:80) Nginx(Nginx<br>localhost:80) FE(前端 Server<br>localhost:3000) BE(後端 API<br>localhost:8080) Browser -- GET /index.html --> Nginx Nginx -- 轉發 --> FE Browser -- POST /api/login --> Nginx Nginx -- 轉發 --> BE style Nginx fill:#f9f,stroke:#333 style Browser fill:#ccf,stroke:#333 ``` ### 對於 CSRF 的影響 用了 Nginx 把前後端變成同源,解決了 CORS,但是CSRF 風險反而更高。 1. **CORS 不是防 CSRF 的:** CORS 只是限制瀏覽器「能不能讀取回應內容」,它並不阻止瀏覽器「發送請求」。即便沒有 Nginx,CSRF 攻擊一樣能發送請求。 2. **同源讓 Cookie 更容易傳送:** 在 Nginx 反向代理下,因為瀏覽器認定前後端是 Same-Origin: - 瀏覽器會預設自動帶上 Cookie (不再受限於第三方 Cookie 的限制)。 - 如果你沒有額外設定 CSRF Token 或 SameSite 屬性,駭客只要造一個連結指向你的 Nginx `/api/transfer`,瀏覽器就會因為是同源而送出 Cookie,導致 CSRF 攻擊成功。 #### 在 Nginx 架構下如何防範 CSRF 既然 Nginx 把請求「偽裝」成同源,防禦策略就要針對「如何正確識別來源」與「限制 Cookie」。 1. 正確傳遞 Header (關鍵設定) 後端常常依靠檢查 `Origin` 或 `Referer` Header 來防禦 CSRF。但在反向代理下,後端收到的請求是由 Nginx 發出的。 問題: 如果沒設定,後端看到的來源可能是 `127.0.0.1` (Nginx 的 IP),而不是真實使用者的來源。 解法: 在 `nginx.conf` 中設定轉發 Header,讓後端能看到真實的 `Host` 和 `Origin`。 ```nginx location /api { proxy_pass http://backend_server; # 告訴後端,真實的 Host 是什麼 proxy_set_header Host $http_host; # 告訴後端,真實的請求者 IP proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 重要:確保 Origin 資訊正確傳遞,否則後端的 CSRF 檢查可能會誤判 proxy_set_header Origin $http_origin; } ``` 2. **利用 SameSite=Strict (最佳解法):** 因為 Nginx 讓前後端變成了「同一個 Domain」,這時候使用 `SameSite` Cookie 策略變得非常完美且容易。 - 設定 Cookie 為 `SameSite=Strict` 或 `Lax`。 - 因為前後端現在都在 `example.com` 下 (透過 Nginx),前端發 API 請求屬於 第一方請求,Cookie 會正常發送。 - 駭客網站 `hacker.com` 發來的請求屬於 第三方,Cookie 會被瀏覽器擋下。 - 這是反向代理架構下最強大的優勢。 3. 雙重 Cookie 驗證 (Double Submit Cookie) 如果不使用 Session,也可以讓 Nginx 對 `/api` 路徑強制檢查某個 Header 是否等於 Cookie 的值。雖然通常這是在 App 層 (後端程式碼) 做,但也可以透過 Nginx 的 Lua 腳本模組在 Gateway 層先擋掉非法請求。 ### 總結 - Cross-Origin 是瀏覽器的限制,Nginx 反向代理 可以用來「騙」瀏覽器我們是同源的。 - 變成同源後,CORS 問題消失,但 Cookie 會自動發送,因此必須啟用 CSRF 防禦。 - 設定重點: 1. Nginx 必須 proxy_set_header 轉發真實來源資訊。 2. 後端設定 Cookie SameSite=Strict (在同源架構下最好用)。 ## HTTPS 與 SSL/TLS >參考: [每個軟體工程師都應該懂的HTTPS:深入淺出加密原理、TLS協議](https://www.shubo.io/https/) HTTPS 是安全版本的 HTTP。HTTP 和 HTTPS 的差別在於,HTTPS 使用 TLS 協議來加密 HTTP 請求和回應。 HTTP 不安全的理由主要有三個: 1. 因為 HTTP 的數據是明文傳遞的,也就是說當我們在使用 HTTP 時,我們的敏感資訊如密碼、信用卡號是未加密的,可以被任何壞人的第三者看到。 2. 因為 HTTP 沒有身份驗證的功能,我們沒辦法確定通信的對方就是他所宣稱的身份。 3. 因為 HTTP 沒有可靠的驗證內容的方法,我們沒辦法確定通訊的內容沒有被竄改過,也就是所謂的中間人攻擊 (MITM,Man-in-the-middle attack)。 TLS 可以有效的修正這些問題。使用了 TLS 協議,會有以下的好處: 1. 加密:客戶端和伺服器之間傳輸的所有數據都是經過加密的,第三者即使攔截數據也只能看到一串亂碼,沒辦法知道通訊的內容。 2. 身份驗證:透過 TLS 證書的機制,可以驗證對方的身份。 3. 完整性驗證:透過 MAC (Message Authentication Code,消息驗證碼) 的機制,可以保證數據在傳遞過程中沒有被篡改。 ### SSL/TLS 是什麼? SSL (Secure Socket Layer) 是一種加密協議,可以讓客戶端和伺服器端之間的通信保持加密。 TLS (Transport Layer Security,傳輸層安全性協議) 是 SSL 的更安全版本,目前網路的世界中的安全通訊大多採用 TLS,TLS 1.3 是最新的版本。 TLS 的功能如下: 1. 加密:TLS 對內容加密,因此即使第三方攔截了數據,也沒辦法判讀內容,只能看到一串亂碼。 2. 身份驗證:TLS 用證書確定通信的伺服器的身份。 3. 完整性驗證:TLS 確保數據在傳輸的過程中沒有被竄改。 ### SSL/TLS 證書 SSL 證書 (SSL certificate),準確來說是 TLS 證書 (TLS certificate),是在 SSL/TLS 連線中用來進行伺服器身份驗證的一份文件,是由 CA (Certificate Authority,證書頒發機構) 發給個人或企業。 SSL/TLS 證書包含了以下重要資訊: - 網域 (domain) 的擁有者是誰 - 伺服器的 public key (並且被 CA 的 private key 簽署 (sign) 過)。 為了要能夠進行伺服器的身份驗證,首先在網站需要在伺服器上安裝 SSL/TLS 證書,然後在 TLS 握手的過程中傳給客戶端。客戶端像是瀏覽器通常內建 CA 的 public key,因此可以驗證被 CA 簽署過的證書,達到驗證伺服器身份的目的。 ### 實做過程 **步驟 1:產生憑證 (Key & Cert)** 先使用 `openssl` 來產生金鑰 ```bash # 產生私鑰 (tls.key) 和 公鑰證書 (tls.crt) # 有效期 365 天 # CN (Common Name) 必須設定為您的網域 dividend.local openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout tls.key \ -out tls.crt \ -subj "/CN=dividend.local/O=DividendTracker" ``` 打開 `tls.crt` 可以發現裡面包含諸多資訊 ``` dividend.local Identity: dividend.local Verified by: dividend.local Expires: 廿廿七年一月卅日 Subject Name CN (Common Name): dividend.local O (Organization): DividendTracker Issuer Name CN (Common Name): dividend.local O (Organization): DividendTracker Issued Certificate Version: 3 Serial Number: 39 C9 8D 9F 67 91 03 FF C8 47 5E 6D AF FB 27 99 AD 8F 3D 77 Not Valid Before: 2026-01-30 Not Valid After: 2027-01-30 Certificate Fingerprints SHA1: 58 A6 B1 4C 1C 19 E5 3D C8 E4 2E 1D A5 A7 AF 6A 15 02 8B 6E MD5: D9 B1 84 41 AF 8C DC 9C BE 67 C9 5A 27 13 17 EC Public Key Info Key Algorithm: RSA Key Parameters: 05 00 Key Size: 2048 Key SHA1 Fingerprint: C5 90 C6 22 07 09 62 C1 06 F1 1F C7 97 14 65 2D C6 A2 C3 3B Public Key: 30 82 01 0A 02 82 01 01 00 DA B2 05 05 13 C6 F3 3E A4 D1 75 2A 88 7A 4D 62 61 B8 A1 8A 98 1A AF 34 8F 9C E2 E1 ED 5E 8E 75 66 C3 35 39 64 C8 1B 0D 87 90 DE 1D 25 36 4D 3F 07 E2 BE 42 9E 9E 03 2A A2 B3 BA 7F 00 BF 9C F7 D5 47 4D 32 64 EF 86 61 5A 11 EE AD 80 9B AB CC 08 02 F3 C4 B7 AD 76 8D B7 B1 BA CB 44 54 82 CC D8 EB 1E 75 33 C4 80 E2 E5 6E B6 D5 0F BF B9 4E 38 17 C9 90 89 F8 E4 3B 92 1C 44 63 B1 EB 7E AB DD DE 91 11 6F ED 5A 2D 89 A1 88 BA 2E 4C 28 65 5D EE A1 64 DB B5 7E 9D EA 75 A5 30 8D 7D A7 2F F8 59 6C 39 DF 77 8B 23 DC C5 EF A1 90 85 50 A3 EC A8 F7 56 80 CA 92 66 F0 FB AC C7 D3 4F EF 95 BF 72 99 AF 62 9C 2D 4D F6 1B FF 8B EE 31 B5 E9 1A 5B C4 D3 80 0F D1 99 C0 99 E6 86 1B 4F 30 7B CF 82 80 60 36 81 7F 37 90 04 6A 18 A5 E9 B0 BC 91 0C 20 38 54 2E FA DD 9F AA 3F 38 C6 89 54 07 02 03 01 00 01 Subject Key Identifier Key Identifier: 36 1E 8D A1 C4 27 DB 39 9E EC D5 5C 54 61 5C 00 C1 0C E5 C9 Critical: No Extension Identifier: 2.5.29.35 Value: 30 16 80 14 36 1E 8D A1 C4 27 DB 39 9E EC D5 5C 54 61 5C 00 C1 0C E5 C9 Critical: No Basic Constraints Certificate Authority: Yes Max Path Length: Unlimited Critical: Yes Signature Signature Algorithm: 1.2.840.113549.1.1.11 Signature Parameters: 05 00 Signature: AA 52 F6 20 8E A0 75 B0 0F DC 03 B4 A3 FE E3 3F 98 83 44 D6 D7 00 C1 E1 8D FB 3F B8 A6 F2 31 7D 4D E6 06 AC 97 54 09 D7 6B 3F 99 D7 CF 99 5D 3F 7C 10 11 36 32 2F 04 1B E0 6D 89 5B E3 A7 6E A6 01 BA 80 62 23 56 1E 76 82 B3 C0 23 DD 58 3D BF 0A 96 5A 8B 45 F5 47 21 91 CC FB 32 2B 07 2A 39 DF F2 5E 73 7D B8 3C FC F8 21 EB FD 77 16 EB D8 4A 9F DE BF EA F0 DC AB 39 67 8D 07 C8 CE 84 D0 09 E0 25 3A 86 8C B0 C8 97 F6 C9 15 A9 2B F3 74 47 6B CB DF 2A FB F6 99 11 D4 16 8B 86 B1 3E 98 53 05 D7 68 4B 9A 8E 12 DF A4 FD CF 46 87 74 59 C9 B1 D2 22 34 8E 28 78 41 F1 69 7B FE 65 2E BD 04 5A 25 D2 6B 6A B5 E6 DD 09 73 98 8C 85 81 8C 83 72 1F 4D 50 2E 6E 08 B2 D6 C4 78 41 D5 E6 26 60 59 1A 08 A8 42 8A 0E 0D 0A 96 17 2A C7 01 7A 18 26 F4 6F 73 4C C1 AB 81 E8 F6 EF 44 21 EA 1C ``` **步驟 2:建立 Kubernetes Secret** Ingress 無法直接讀取檔案,它需要從 K8s 的 Secret 資源中拿取憑證。 ```bash # 建立一個 type 為 tls 的 secret,名稱叫 dividend-tls kubectl create secret tls dividend-tls --key tls.key --cert tls.crt ``` 檢查是否建立成功: ```bash $ kubectl get secret dividend-tls NAME TYPE DATA AGE dividend-tls kubernetes.io/tls 2 11s ``` ### 步驟 3:修改 Ingress YAML ### 步驟 4:建立 ```bash eason@eason-System-Product-Name:~/DividendTracker/DividendTracker/backend$ kubectl get ingress NAME CLASS HOSTS ADDRESS PORTS AGE dividend-ingress nginx dividend.local 192.168.58.2 80, 443 14d ```