---
# System prepended metadata

title: '[.NET 筆記 028] Dockerfile for .NET 8: Multi-stage Build 與容器化部署'

---

## [.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 的預設行為