## [.NET 筆記 028] Dockerfile for .NET 8: Multi-stage Build 與容器化部署 ###### 📅 2026-05-11 --- ### 📌 介紹-1. 什麼是 Docker?為什麼需要它? Docker 白話說就是「**把程式和它需要的所有環境一起打包成一個箱子,在任何電腦上都能跑**」 Example: 沒有 Docker 時可能遇到的問題 ``` 開發端:可以跑 伺服器:跑不起來,少了 .NET 8 Runtime 測試環境:版本不對,結果不一樣 ``` 有了 Docker 後: ``` 開發者 → 打包成 Docker Image → 推到伺服器 伺服器 → 拉 Image → 跑起來 → 環境完全一致 ``` --- ### 📌 介紹-2. Docker 核心概念 | 概念 | 白話說明 | |:----|:--------| | **Image (映像檔)** | 打包好的「箱子」,包含程式 + 環境,唯讀不可改 | | **Container (容器)** | 從 Image 啟動的「執行中實例」,可以有多個 | | **Dockerfile** | 打包的「說明書」,告訴 Docker 怎麼建立 Image | | **.dockerignore** | 告訴 Docker 打包時「哪些檔案不要放進去」 | | **Registry** | Image 的倉庫 (如 Docker Hub, Azure Container Registry) | ``` Dockerfile (說明書) ↓ docker build Image (打包好的箱子) ↓ docker run Container (執行中) ``` --- ### 📌 介紹-3. 什麼是 Multi-stage Build? Multi-stage Build 白話說就是「**用一個大工具箱編譯程式,再把編譯好的成品放到一個小箱子裡執行**」 ``` 階段 1:build (用 SDK Image,約 900MB) → 還原套件、編譯程式碼 階段 2:publish (在 build 上繼續) → 發佈最終產出 階段 3:final (用 Runtime Image,約 100MB) → 只放編譯好的 .dll,不放原始碼和 SDK → 最終 Image 很小,部署更快 ``` | 比較 | 不用 Multi-stage | 用 Multi-stage | |:----|:---------------|:--------------| | Image 大小 | ~900MB (含 SDK) | ~100MB (只含 Runtime) | | 安全性 | 包含原始碼 and SDK | 只有編譯後的 .dll | | 部署速度 | 慢 | 快 | --- ### 📌 介紹-4. 進階範例專案的 Dockerfile 架構 進階範例專案的 Web and Platform 都有各自的 Dockerfile,結構完全相同,分四個階段: ``` FROM dotnet/aspnet:8.0 AS base ← 1. base:定義執行環境 FROM dotnet/sdk:8.0 AS build ← 2. build:還原套件 + 編譯 FROM build AS publish ← 3. publish:發佈最終產出 FROM base AS final ← 4. final:只複製發佈產出,啟動程式 ``` > 💡 `.dockerignore` 排除了 `bin/`, `obj/`, `.vs/`, `node_modules/` 等不需要的檔案,加快 build 速度 --- ### 📌 介紹-5. .NET Docker Image 的兩種選擇 | Image | 包含內容 | 大小 | 用途 | |:------|:--------|:----|:----| | `mcr.microsoft.com/dotnet/sdk:8.0` | SDK + Runtime + CLI 工具 | ~900MB | 編譯用 (build 階段) | | `mcr.microsoft.com/dotnet/aspnet:8.0` | 只有 ASP.NET Core Runtime | ~100MB | 執行用 (final 階段) | > 💡 最終 Image 一定用 `aspnet`,不用 `sdk`,這是 Multi-stage Build 的核心概念 --- ### 📌 Step-0. 開啟專案 繼上一篇 [[.NET 筆記 027]](https://hackmd.io/@dada00321/SkLJM090Zg) ➡️ 專案:API_test_260508 --- ### 📌 Step-1. 確認 Docker Desktop 已安裝 在終端機執行: ``` docker --version ``` 如果出現版本號就 OK,沒有的話回到 017 筆記安裝 Docker Desktop --- ### 📌 Step-2. 建立 .dockerignore 在**專案根目錄** (和 `.csproj` 同一層) 新增 `.dockerignore` (仿照進階範例專案) : ``` **/.classpath **/.dockerignore **/.env **/.git **/.gitignore **/.project **/.settings **/.toolstarget **/.vs **/.vscode **/*.*proj.user **/*.dbmdl **/*.jfm **/azds.yaml **/bin **/charts **/docker-compose* **/Dockerfile* **/node_modules **/npm-debug.log **/obj **/secrets.dev.yaml **/values.dev.yaml LICENSE README.md ``` > 💡 `.dockerignore` 和 `.gitignore` 概念一樣,告訴 Docker 哪些檔案不需要複製進 Image > 排除 `bin/` and `obj/` 可以避免本機的編譯產物干擾 Docker build --- ### 📌 Step-3. 建立 Dockerfile 在**專案根目錄** (和 `.csproj` 同一層) 新增 `Dockerfile` (仿照進階範例專案的結構) : ```dockerfile # ============================================================ # 階段 1:base — 定義執行環境 # 使用 ASP.NET Core Runtime Image (輕量,~100MB) # ============================================================ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base USER app WORKDIR /app EXPOSE 8080 EXPOSE 8081 # ============================================================ # 階段 2:build — 還原套件 + 編譯 # 使用 SDK Image (包含編譯工具,~900MB) # ============================================================ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src # 先複製 .csproj 並還原套件 (利用 Docker Layer Cache) # 只要 .csproj 沒變,這層就不需要重新執行 COPY ["API_test_260508.csproj", "."] RUN dotnet restore "./API_test_260508.csproj" # 複製所有原始碼並編譯 COPY . . RUN dotnet build "./API_test_260508.csproj" -c $BUILD_CONFIGURATION -o /app/build # ============================================================ # 階段 3:publish — 發佈最終產出 # ============================================================ FROM build AS publish ARG BUILD_CONFIGURATION=Release RUN dotnet publish "./API_test_260508.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false # ============================================================ # 階段 4:final — 只複製發佈產出,啟動程式 # 最終 Image 只有 Runtime + 編譯好的 .dll,不含原始碼和 SDK # ============================================================ FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "API_test_260508.dll"] ``` **逐行解讀:** | 指令 | 說明 | |:----|:----| | `FROM ... AS base` | 從 ASP.NET Runtime Image 開始,命名為 `base` | | `USER app` | 用非 root 使用者執行,安全性較好 | | `EXPOSE 8080 8081` | 宣告容器會使用的 Port (.NET 8 預設是 8080) | | `FROM ... AS build` | 切換到 SDK Image,開始編譯 | | `ARG BUILD_CONFIGURATION=Release` | 編譯組態,預設 Release | | `COPY ["*.csproj", "."]` | 先只複製 `.csproj`,利用 Layer Cache 加速 | | `RUN dotnet restore` | 還原 NuGet 套件 | | `COPY . .` | 複製所有原始碼 | | `RUN dotnet build` | 編譯 | | `RUN dotnet publish` | 發佈 (最佳化後的產出) | | `/p:UseAppHost=false` | 不產生平台特定的可執行檔,用 `dotnet` 指令啟動 | | `FROM base AS final` | 回到輕量的 Runtime Image | | `COPY --from=publish /app/publish .` | 只複製 publish 階段的產出 | | `ENTRYPOINT [...]` | 容器啟動時執行的指令 | --- ### 📌 Step-4. 建立 Docker Image 在**專案根目錄** (和 `.csproj` 同一層) 的終端機執行: ``` docker build -t api-test-260508 . ``` | 參數 | 說明 | |:----|:----| | `-t api-test-260508` | 幫 Image 取名 (`-t` = tag) | | `.` | Build context 是目前目錄 | 執行後會看到四個階段依序執行: ``` [+] Building ... => [base 1/2] FROM mcr.microsoft.com/dotnet/aspnet:8.0 => [build 1/5] FROM mcr.microsoft.com/dotnet/sdk:8.0 => [build 2/5] COPY ["API_test_260508.csproj", "."] => [build 3/5] RUN dotnet restore => [build 4/5] COPY . . => [build 5/5] RUN dotnet build => [publish 1/1] RUN dotnet publish => [final 1/2] COPY --from=publish /app/publish . ``` > 💡 第一次 build 會比較久 (需要下載 base image),之後因為 Docker Layer Cache,只有改到的檔案才會重新 build --- ### 📌 Step-5. 啟動 Container ``` docker run -d --name api-test -p 5000:8080 -p 5001:8081 api-test-260508 ``` | 參數 | 說明 | |:----|:----| | `-d` | 背景執行 | | `--name api-test` | 容器取名 `api-test` | | `-p 5000:8080` | 本機 5000 Port 對應容器 8080 Port | | `-p 5001:8081` | 本機 5001 Port 對應容器 8081 Port | | `api-test-260508` | 使用剛才建立的 Image | > 💡 .NET 8 容器內預設使用 Port 8080 (HTTP) 和 8081 (HTTPS),和開發時的 7225 不同 --- ### 📌 Step-6. 驗證 Container 是否正常 **確認容器狀態:** ``` docker ps ``` 應該看到 `api-test` 的 STATUS 是 `Up` **測試 API 是否可以呼叫:** 在瀏覽器或終端機: ``` curl http://localhost:5000/health ``` 應該回傳 Health Check 的 JSON 結果 **如果沒有回應,查看 Log:** ``` docker logs api-test ``` > 💡 容器內的程式看不到本機的 SQL Server and Redis > 需要將專案中所有 `localhost` 取代為 `host.docker.internal` > PS: 這是 Docker Desktop 提供的特殊 DNS,可以從容器內連到本機 --- ### 📌 Step-7. 設定容器連線 — appsettings.Docker.json 為了不影響開發環境的設定,新增一個 Docker 專用的設定檔 `appsettings.Docker.json`: ```json { "ConnectionStrings": { "DefaultConnection": "Data Source=host.docker.internal\\SQL2025;Persist Security Info=True;User ID=test_user_1;Password=@#QWER4321;Encrypt=False;TrustServerCertificate=True;", "Redis": "host.docker.internal:6379" } } ``` 然後在 Dockerfile 的 `final` 階段加上環境變數,讓 .NET 讀取 Docker 設定檔: ```dockerfile FROM base AS final WORKDIR /app # 設定環境為 Docker,讓 .NET 自動載入 appsettings.Docker.json ENV ASPNETCORE_ENVIRONMENT=Docker COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "API_test_260508.dll"] ``` 然後在 `Program.cs` 確認有讀取對應的 appsettings: ```csharp var builder = WebApplication.CreateBuilder(args); // .NET 預設會自動讀取 appsettings.{ASPNETCORE_ENVIRONMENT}.json // 設定 ASPNETCORE_ENVIRONMENT=Docker 後,會自動載入 appsettings.Docker.json ``` > 💡 `.NET` 會自動讀取 `appsettings.{環境名稱}.json`,不需要額外設定 > `ASPNETCORE_ENVIRONMENT=Docker` → 自動載入 `appsettings.Docker.json` --- ### 📌 Step-8. 常用 Docker 管理指令 ```bash # 查看所有容器 docker ps -a # 停止容器 docker stop api-test # 重新啟動容器 docker start api-test # 查看容器 Log docker logs api-test # 即時查看 Log (像 tail -f) docker logs -f api-test # 進入容器內部 (除錯用) docker exec -it api-test bash # 刪除容器 (需先 stop) docker rm api-test # 刪除 Image docker rmi api-test-260508 # 查看所有 Image docker images # 重新 build (改了 code 後) docker build -t api-test-260508 . docker stop api-test && docker rm api-test docker run -d --name api-test -p 5000:8080 api-test-260508 ``` --- ### 📌 Step-9. 驗證完整流程 ``` 1. docker build -t api-test-260508 . ← 建立 Image 2. docker run -d --name api-test \ -p 5000:8080 api-test-260508 ← 啟動容器 3. curl http://localhost:5000/health ← 測試 Health Check 4. docker logs api-test ← 查看 Log ``` ➡️ 預期執行結果: ```json { "status": "Healthy", "checks": [ { "name": "redis-cache", "status": "Healthy" }, { "name": "sql-server", "status": "Healthy" } ] } ``` ➡️ 實際執行結果: - PowerShell 測試: `curl http://localhost:5000/health` ![image](https://hackmd.io/_uploads/r1m1Idy1fl.png) - URL 測試: `http://localhost:5000/health` ![image](https://hackmd.io/_uploads/Hya-IdJ1Gl.png) --- ### 📌 總結:進階範例專案 vs 範例專案 | | 進階範例專案 | 範例專案 | |:--|:-----------|:--------| | Dockerfile 數量 | 2 個 (Web + Platform) | 1 個 | | Multi-stage | ✅ 4 階段 (base → build → publish → final) | ✅ 相同架構 | | Base Image | `aspnet:8.0` | ✅ 相同 | | SDK Image | `sdk:8.0` | ✅ 相同 | | `.dockerignore` | ✅ | ✅ 相同內容 | | EXPOSE Port | 80, 443 | 8080, 8081 (.NET 8 新預設) | | `/p:UseAppHost=false` | ✅ | ✅ | | docker-compose | 無 (可能用 K8s 部署) | 可日後加入 | > 💡 進階範例專案的 EXPOSE 寫的是 `80, 443` (舊版寫法),但 .NET 8 容器預設改用 `8080, 8081` > 範例專案直接用新版的 Port,更符合 .NET 8 的預設行為