## [.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`

- URL 測試: `http://localhost:5000/health`

---
### 📌 總結:進階範例專案 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 的預設行為