前一天我們提到了 VM 與容器的差異,那麼今天我們就快速了解一下,要怎麼撰寫一份 Dockerfile、建立一個 image,以及啟動一個容器,也會提一下 Docker Compose 如何一次啟動多個容器,因為實際上在部署 K8s 時,通常都是將既有架構下的多個容器遷移進 K8s 內,了解 Docker Compose 其實也有助於釐清現行架構有哪些服務是有關聯的。
這邊我會使用 ASP.NET Core API 作為舉例,透過 SDK 與 runtime 的分離,說明 multi-stage build 的概念。
Dockerfile 是文字檔,依序描述 Docker 如何取得基底環境、複製檔案、執行建置指令,最後決定 container 啟動時要使用甚麼環境變數以及跑什麼程式。
有幾個常見的名詞可以先釐清:
Dockerfile 處理的是「一個服務如何被封裝」,而 Compose 處理「多個服務如何在本機一起運作」。
熟悉程式開發的人,我想都知道程式從原始碼到能運作執行,主要會經過兩個階段,以 ASP.NET Core API 為例:
build 或 publish。publish 產出的檔案。從上面這兩點可以得知,實際上要讓程式運行,最終只需要 Runtime 來運行程式,SDK 只有建置程式碼時才需要,
所以在製作 Image 時,我們就可以不需要連 SDK 都一起打包。
如果把 SDK、原始碼、NuGet 快取與建置工具全部放進最後的 production image,容器雖然還是能跑,但 image 會更大,也帶進容器運作時不需要的內容,有時候更會因此增加 Image 的資安風險(例如 SDK 的 base image 有新的 CVE 漏洞等...),所以在規劃 Dockerfile 時,最好還是往最輕量化的方向去設計還是比較好的。
Dockerfile 可以有多個 FROM,每個 FROM 都開始一個新的 stage,最後的 stage 只用 COPY --from=<stage> 取得所需要的資源。
前一個 stage 的 SDK、原始碼或是中間使用到的任何檔案、資源都不會出現在最終打包的 image 內,這就是 multi-stage build 的意義所在。
以下是一個製作 ASP.NET Core API 的 Dockerfile 範例:
# syntax=docker/dockerfile:1
ARG DOTNET_VERSION=10.0
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
WORKDIR /src
# 先複製專案檔並還原相依套件。
COPY ["src/Workspace.Api/Workspace.Api.csproj", "src/Workspace.Api/"]
RUN dotnet restore "src/Workspace.Api/Workspace.Api.csproj"
# 再複製其餘原始碼並產生可部署的檔案。
# 第一個 . 代表 Dockerfile 的上下文目錄,也就是整個專案根目錄。
# 第二個 . 代表要複製到的目標目錄,也就是 WORKDIR 所指向的目錄。
COPY . .
WORKDIR /src/src/Workspace.Api
RUN dotnet publish -c Release -o /out --no-restore
# 打包,最終 image 只有 ASP.NET Core runtime 與 publish 檔案結果。
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS final
# 設定最終 image 的工作目錄。
WORKDIR /app
# 設定環境變數與工作目錄。
ENV ASPNETCORE_HTTP_PORTS=8080
# 說明這個 container 對外提供的 port
EXPOSE 8080
# 複製建置結果到最終 image。
COPY --from=build /out .
# 以非 root 使用者執行,增加容器安全性。
USER app
# 指定 container 啟動後要執行的命令。
ENTRYPOINT ["dotnet", "Workspace.Api.dll"]
這份 Dockerfile 的流程如下:
先複製 .csproj 並執行 restore,再複製全部原始碼;當只有程式碼改變、相依套件沒有改變時,Docker 可以重用先前的 restore 結果,避免每次都重新下載 NuGet 套件。

multi-stage build 說明的是單一服務如何從 Dockerfile 產生 image;Docker Compose 則是另一個責任層,用來描述多個已建置完成的 container 如何一起啟動與協作。兩者的邊界如下圖:

時常會聽到容器服務基本上都跑在 Alpine Linux 上,其實也不一定。以 .NET 來說,.NET 10 官方 image 預設是 Ubuntu,而也有提供 Alpine、Ubuntu chiseled 等選項。
前面寫的 Dockerfile,處理的是單一 ASP.NET Core API 如何被建置成 image,但實際的服務環境通常不會只有一個 API,可能還會有其他 API、資料庫、快取或前端服務。
這時候就可以使用 Docker Compose,藉由讀取撰寫好的 docker-compose.yml,依照裡面的 services 定義建立多個 container,並處理它們之間的網路、環境變數與 volume 掛載。
docker run 啟動單一 Container在需要一次啟動多個服務之前,可以先用 docker run 驗證剛剛建立的 image 能不能正常運作。
以下指令會在背景啟動 workspace-api,並把本機的 8080 port 對應到 container 內 ASP.NET Core API 監聽的 8080 port:
docker run --name workspace-api --rm -d -p 8080:8080 workshop/workspace-api:dev
--name workspace-api:替這個 container 指定容易辨識的名稱。--rm:container 停止後自動刪除,適合本機測試;若要保留停止後的 container 來 debug,則可以先不使用這個參數。-d:在背景執行,若不加 -d,應用程式的輸出會直接顯示在終端機。-p 8080:8080:前面的 8080 是本機電腦的 port,後面的 8080 是 container 內程式監聽的 port。workshop/workspace-api:dev:要啟動的 image 名稱與 tag。啟動後可以使用 docker ps 確認 container 是否還在執行,或以 docker logs workspace-api 查看應用程式輸出。
以下是一個簡化後的範例:
api-gateway 提供 API 的統一入口,並將請求轉送到 directory-api 或 workspace-api
workspace-api 也會呼叫 directory-api
services:
api-gateway:
image: workshop/api-gateway:dev
ports:
- "8080:8080"
environment:
DirectoryApi__BaseUrl: http://directory-api:8080
WorkspaceApi__BaseUrl: http://workspace-api:8080
depends_on:
directory-api:
condition: service_started
workspace-api:
condition: service_started
directory-api:
image: workshop/directory-api:dev
environment:
ConnectionStrings__AppDb: Host=postgres;Port=5432;Database=workspace;Username=app;Password=dev-password
depends_on:
postgres:
condition: service_healthy
workspace-api:
image: workshop/workspace-api:dev
environment:
DirectoryApi__BaseUrl: http://directory-api:8080
ConnectionStrings__AppDb: Host=postgres;Port=5432;Database=workspace;Username=app;Password=dev-password
depends_on:
directory-api:
condition: service_started
postgres:
condition: service_healthy
postgres:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: dev-password
POSTGRES_DB: workspace
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d workspace"]
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres-data:
這份 Docker Compose 有以下幾個重點:
services 下的名稱同時是 Compose 管理的服務名稱。workspace-api 可以直接用 http://directory-api:8080 呼叫另一個服務,不需要先查 container IP。api-gateway 是 API 的統一入口。前端或外部工具只需要知道 Gateway 的位址;Gateway 再依 path 或其他路由規則轉送到內部 API。directory-api 與 workspace-api 沒有設定 ports,因此不會直接對本機電腦開放。ports: "8080:8080" 的前面是本機電腦的 port,後面才是 container 內程式實際監聽的 port。只有需要從本機瀏覽器或像是 postman 的工具要直接呼叫該容器的服務,才需要設定這一段。environment 是在 container 啟動時注入設定的方式,以這一個範例來說,PostgreSQL 的連線字串與 API 的 BaseUrl 都是透過 environment 環境變數注入的。volumes 讓 PostgreSQL 的資料不會因為 container 被刪除就一起消失。depends_on 可以協助控制啟動順序,搭配 service_healthy 時可等待資料庫 healthcheck 成功才啟動後續服務。完成設定後,可以使用以下指令在背景啟動所有的容器:
docker compose up -d
此時 Docker Compose 會建立一個預設 network,讓這份 Docker Compose 中的容器能透過 service 名稱互相連線。
後續把服務搬到 Kubernetes 時,這個「以 Service 名稱而不是透過 IP 呼叫服務」的概念會繼續使用,但實作會改由 Kubernetes Service 與 CoreDNS 接手。
以下是本次主題會使用的微服務架構,後續會將這些運作在 Docker Compose 的服務,逐一部署到 Kubernetes:

portal-web 負責入口與前端整合,而兩個前端的 API 請求則統一先進入 api-gateway,Gateway 再依路徑將請求轉送到內部 API,因此 directory-api 與 workspace-api 不會直接對外。
workspace-api 若需要共用目錄資料,仍可以在內部以 east-west 流量呼叫 directory-api。