Skip to content

csning1998/tutorial-dockerize-spring-boot

Repository files navigation

How to Basic Docker?

Section 1. Introduction

A. What is Docker

Docker 是一個開源的容器化平台,旨在簡化應用程式的自動化部署、擴展和管理。Docker 創建輕量級、獨立可執行的容器,其中包含執行系統或應用程式所需的所有元素:程式碼、運行環境、系統工具、函式庫和設定。

B. Docker Image File

通常是指 Dockerfile,這是包含一串指令的文字檔案,主要是定義如何建立一個 Docker Image。一般會在 Dockerfile 裡面宣告應用程式的執行環境、包含基礎系統、要安裝的套件、環境變數、檔案複製的指令等。

一個映像檔並不是一個文件檔案,而是一個清單(Manifest),清單中會定義其他檔案所位置,而與相關檔案組合成一個映像檔。一個簡易的 Dockerfile 如下:

FROM python:3.9
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

在 Docker 生態系統中,Docker Image File 是建立映像檔的起點,Docker 引擎會在執行 docker build 指令之後,解析 Dockerfile 的指令,逐層執行並生成對應的映像檔層,最終形成 Container Image。

C. Container Image

所謂 容器映像檔(Container Image)是一個 binary 包裝的檔案,會將所有應用程式所必須要包含的檔案都封裝在裡面。而一個 Container Image 是由一系列唯讀的檔案系統層(Read-only Layers)堆疊而成。每一層都是對其下層的一組修改,這種由下往上的堆疊關係最終構成了完整的映像檔。

一般來說可以在 Host 端建立 Container image、也可以在 Registry 中下載已經存在的映像檔。無論哪一種情況,只要映像檔在 Host 端上,就可以透過執行映像檔來啟動作業系統容器中的應用程式。

一般來說,透過 Dockerfile 建立出來的成品就是一個 Container Image,在 Docker 的使用情境下都會說是 Docker Image。在建立出映像檔後,這個映像檔就會是一個 不可變基礎設施(Immutable Infrastructure)。

D. Registry

Registry 是一個儲存和分發 Docker Image 的服務,一般透過 Docker 建立出來的 Container Image 存放的位置會在 Registry 上。而 Docker Hub 是最為知名且預設的公開 Registry;某些情況下出於安全性考量,也會使用 Red Hat 的 http://quay.io/。某些情況下可能會需要私有 Registry,可以使用 Docker 官方提供的開源 Registry 映像檔registry:2),功能較基礎,適合簡單環境。

一個完整的映像檔名稱結構為

[registry-host/][username/]<image-name>[:tag]
  • registry-host:Registry 的伺服器位址。如果省略,預設為 Docker Hub (docker.io)。例如:gcr.ioquay.ioprivate-registry.mycorp.com
  • username/:使用者或組織名稱。在 Docker Hub 上,官方映像檔(如 pythonnginx)會省略這個部分,屬於 library 命名空間。
  • <image-name>:Repository 的名稱。
  • :tag:標籤,通常用來表示版本。如果省略,預設為 latest

通常在 Docker Hub 上面要抓取 Docker 映像,可以不需要指定 registry-host。在前述 Dockerfile 中執行第一列 FROM python:3.9 的時候,Docker 會先搜尋 Host 端是否存有這個 Image。如果有,就會直接用存在電腦內現成的 Image 檔案;若無,就會預設從 Docker Hub 裡面找尋。

但如果是在另一個預設使用 Quay.io 的 Podman 裡面要抓取 Docker Hub 上面的映像檔,就必須要指定 registry-host。例如:

FROM docker.io/hashicorp/terraform:1.13.0 AS terraform

E. Repository

Repository 是在 Registry 中,用來存放特定映像檔不同版本的集合(Collection)。例如,在 Docker Hub 這個 Registry 中,有一個名為 ubuntu 的 Repository,裡面存放了 ubuntu:24.04ubuntu:25.10 等多個版本的映像檔。

Repository 的維護者可以推送一個全新的映像檔,隨後標記一個已經存在的 Tag。

例如,今天 my-app:1.0 標籤指向 A 映像檔。下週,開發者修復了一個 Bug 並建立了 B 映像檔,他仍然可以使用 my-app:1.0 這個標籤進行發佈。這時候,my-app:1.0 標籤就會從 A 映像檔重新指向 B 映像檔。

這就是為什麼在正式的生產環境中,強烈建議不要使用latest Tag,因為這個標籤所指向的映像檔可能隨時在開發者不知情的情況下被更換,導致部署結果不一致。

既然 Tag 是可變的,那要如何確保每次拉取的都是一模一樣的映像檔呢?方法是使用 Digest(摘要)。每一個 Docker Image 在建立完成後,都會根據其內容(所有層的數據和設定)產生一個唯一的、不可變的 SHA256 雜湊值,這就是這個映像檔的 Digest。

  • Tag:人類可讀的、可變的指標。
  • Digest:機器產生的、絕對不變的唯一標識符。

當開發者推送一個映像檔到 Registry 時,會在終端機裡面看到類似 ... digest: sha256:2b7412e6465c... size: 1337 的輸出。如果要保證部署的映像檔一直是現有的映像檔,可以直接使用 @ 符號指定 Digest。例如要抓取圖片中的 ARM64 V8 架構下的 ubuntu:24.04 的映像檔,可以使用以下指令

docker pull ubuntu@sha256:9cbed754112939e914291337b5e554b07ad7c392491dba6daf25eef1332a22e8

如此一來,即使 ubuntu:24.04 這個 Tag 在未來被重新指向其他映像檔,使用上述 Digest 拉取時永遠都會得到這個特定版本的映像檔。

F. Container Layer

一個容器映像檔是一層層檔案系統堆疊出來的,而每一層都會根據前一層的檔案系統的內容進行新增、刪除、或是修改。這屬於 overlay 檔案系統中的一種,但也可能包含其他種實作方式,例如 aufsoverlayoverlay2 等。

考慮這個專案的目錄結構

.
├── Dockerfile
├── requirements.txt
└── app.py

以及以下五層的 Dockerfile

FROM python:3.9
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

這個 Dockerfile 會的層級關係如下:

  1. FROM python:3.9

    首先從 Docker Hub 抓取 python:3.9 映像檔作為基礎層,一般會包含 Python 3.9 的執行環境、某一種 Ubuntu 或 Alpine 作業系統、以及一些有關的相依套件。會提供一個包含 Python 3 的完整的檔案系統,例如 /usr/bin/python3

  2. WORKDIR /app

    這一層會繼承第 1 層的 python:3.9 的檔案系統,僅設定工作目錄為 /app。如果這檔案不存在,容器會自己在這裡面建立目錄。檔案系統相較前層多了 /app 目錄

  3. COPY . .

    這一層會繼承第 2 層的檔案系統,將 Host 端執行 docker build 的當下所有內容,複製到容器內部的工作目錄 /app 中。在本例中是 requirements.txtapp.py 兩者。這時候容器內部就會出現 /app/app.py 以及 /app/requirements.txt 等檔案。

  4. RUN pip install -r requirements.txt

    這一層會繼承第 3 層的檔案系統,執行 pip install -r requirements.txt,在 Python 中,這是用來安裝相依套件所使用的,執行指令後將套件安裝到檔案系統,例如 /usr/local/lib/python3.9/site-packages

  5. CMD ["python", "app.py"]

    這一層會繼承第 4 層的檔案系統,但不進行修改,僅設定容器執行環境的預設指令。

G. Commands of a Dockerfile

Docker 的 Dockerfile 中,除了以上指令之下,以下是一些常見的指令:

  • ADD:類似於 COPY,但具有比較多的功能。例如可以透過 URL 下載檔案,並且在新增壓縮檔(如 .tar.gz.zip)時自動解壓縮。
    • 範例ADD http://example.com/file.tar.gz /path/to/dest/
    • 範例ADD myapp.zip /app/
  • EXPOSE:用來宣告容器在執行時會監聽的網路埠口,必須在執行 docker run 時使用 pP 參數才能將主機的埠口映射到容器內部。
    • 範例EXPOSE 80 443
  • ENV:用來設定環境變數,這些變數會被後續的 RUNCMDENTRYPOINT 指令使用,並在容器執行時持續存在。
    • 範例ENV APP_VERSION=1.0.0
    • 範例ENV PATH="/usr/local/bin:$PATH"
  • LABEL:會以 key-value pair 的形式對映像檔做一些標記,可以用來描述映像檔的維護者、版本、授權資訊等。
    • 範例LABEL maintainer="<email_address>"
    • 範例LABEL version="1.0"
  • ENTRYPOINT:是做為一個固定的執行點,設定容器啟動時所要執行的主要指令,使映像檔可以像可執行程式一樣被呼叫。與 CMD 不同的是,CMD 通常作為其參數。
    • 範例ENTRYPOINT ["python", "app.py"]
  • USER:設定在執行後續 RUNCMDENTRYPOINT 指令時的用戶和群組,主要是考量安全性之下,降低容器的運行的執行權限。
    • 範例USER appuser
  • ARG:用來定義 build 容器時的變數,但不會被保留在最後的映像檔中。
    • 範例ARG BUILD_VERSION
    • 範例RUN echo "Building version $BUILD_VERSION"
  • VOLUME:用來建立一個指定的掛載點,並標示此掛載點將從主機或其他容器掛載資料。主要是為了避免將資料直接寫入映像檔層中,同時確保資料能夠持久保存,例如 Log 或是一些資料庫資料等。
    • 範例VOLUME ["/data"]
  • ONBUILD:為映像檔新增一個觸發器,用於建立可重複使用的基底映像檔,例如應用程式框架的映像檔。換句話說,當此映像檔被當作另一個映像檔的 FROM 指令時,ONBUILD 後續的指令會被執行。
    • 範例ONBUILD COPY . /app/
  • STOPSIGNAL:設定容器在停止時接收的系統呼叫(signal),例如 SIGINTSIGTERM 等,進行 gracefully suspend 以釋放資源。
    • 範例STOPSIGNAL SIGINT

Example: Build a Spring Boot Application

A. Scenario

開發者收到業務需求,需要建立一個 Nginx 容器,作為反向代理,監聽主機阜口 80,將請求轉發到後端。其中後端是一個使用 Java 21 和 Gradle Build 的 Spring Boot 應用程式,包含一個 /hello 路由,被觸發後會回傳 {"message": "Hello, World!"} 的 JSON 內容。

.
├── Dockerfile
├── .dockerignore
├── nginx.conf
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
└── src/main/java/com/example
    ├── HelloController.java
    └── HelloWorldApplication.java
  • Files in this Project

    • nginx.conf

      events {
         worker_connections 1024;
      }
      
      http {
         server {
            listen 80;
            server_name localhost;
      
            location /hello {
               proxy_pass http://backend:8080/hello;
               proxy_set_header Host $host;
               proxy_set_header X-Real-IP $remote_addr;
            }
         }
      }
      
    • gradle.build.kts

      plugins {
         id("org.springframework.boot") version "3.2.1"
         id("io.spring.dependency-management") version "1.1.6"
         java
      }
      
      group = "com.example"
      version = "0.0.1-SNAPSHOT"
      
      java {
         toolchain {
            languageVersion.set(JavaLanguageVersion.of(21))
         }
      }
      
      repositories {
         mavenCentral()
      }
      
      dependencies {
         implementation("org.springframework.boot:spring-boot-starter-web")
      }
      
      tasks.withType<Test> {
         useJUnitPlatform()
      }
    • settings.gradle.kts

      rootProject.name = "helloworld"
    • src/main/java/com/example/HelloWorldApplication.java

      package com.example;
      
      import org.springframework.boot.SpringApplication;
      import org.springframework.boot.autoconfigure.SpringBootApplication;
      
      @SpringBootApplication
      public class HelloWorldApplication {
         public static void main(String[] args) {
            SpringApplication.run(HelloWorldApplication.class, args);
         }
      }
    • src/main/java/com/example/HelloController.java

      package com.example;
      
      import org.springframework.web.bind.annotation.RestController;
      import org.springframework.web.bind.annotation.GetMapping;
      
      @RestController
      class HelloController {
         @GetMapping("/hello")
         public String hello() {
            return "{\"message\": \"Hello, World!\"}";
         }
      }
    • .dockerignore

      .git
      .gitignore
      .idea/
      .vscode/
      build/
      *.log
      gradlew.bat
      Dockerfile
      docker-compose.yaml
      .dockerignore
      src/test/
      
    • .gitignore

      bin/
      build/
      .vscode/
      .gradle/
      gradlew.bat
      *.log
      *.tmp
      *.bak
      

回顧一下在沒有 Docker 的情況下,一個標準的 Spring Boot App 在 Build 的流程是

  1. 在 Host 端要安裝 JDK
  2. 在專案內有 Gradle Wrapper 檔案,即 gradlew
  3. 在專案根目錄下要使用 ./gradle build,讓 Gradle 下載相依套件來編譯程式碼
  4. Gradle 在 build/libs/ 目錄下產生一個可以執行的 Artifact 檔案 *.jar
  5. 透過 java -jar build/libs/your-app.jar 指令來執行應用程式

現在 Dockerfile 的目標就是將以上流程「自動化」並「容器化」。

B. Stage 1. Build

一個 *.jar 檔案是是透過一個包含 JDK 與 Gradle 的環境來執行 ./gradlew build。對於一些不是很大型的應用程式,可以對應 Java 版本而選擇使用一些輕量級版本的映像檔做為基底,如 openjdk:21-jdk-slim

接下來要成功執行 build,專案中會需要:

  • Building 的宣告檔案:build.gradle.ktssettings.gradle.kts
  • Gradle Wrapper:gradlew 腳本和 gradle/ 目錄。
  • 原始碼:src/ 目錄。

接下來要使用 RUN ./gradlew build 進行 Build 階段。在這個簡單案例下先使用 -x test 跳過測試。在容器中使用 --no-daemon 也是一個好習慣,主要是確保 Gradle 程式在打造階段結束後可以完全退出。

以上內容寫成 Dockerfile 就會是這樣:

# Build stage
FROM **openjdk:21-jdk-slim** AS builder
WORKDIR /app

# Copy Gradle Wrapper first
COPY gradle ./gradle
COPY gradlew .

# Then copy build scripts and source code
COPY build.gradle.kts settings.gradle.kts ./
COPY src ./src

# Grant execution permission and build
RUN chmod +x ./gradlew
RUN ./gradlew build -x test --no-daemon

但是現在專案原始碼內,並沒有 gradlew,所以會需要一個階段來產生 Wrapper。那因為 Gradle Build 的前提是「需要 Wrapper」,因此可以在 Build Stage 之前加入 Wrapper Stage,例如使用 gradle:8.5-jdk21 做為基底

# Generate Gradle Wrapper
FROM gradle:8.5-jdk21 AS wrapper-generator
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts /app/
RUN gradle wrapper

現在 build stage 所需要的 gradlewgradle/ 檔案就可以直接從這個 wrapper-generator 階段複製過去,而不是透過 host 端的檔案。要注意的是,因為這個更先前的階段將檔案掛入 /app 的路徑下執行 Gradle build,因此就要明確指定要從 wrapper-generator 階段的映像檔複製的。所以 COPY 這邊的路徑要進行修改:

...
# Copy Gradle Wrapper first
COPY --from=wrapper-generator /app/gradle ./gradle
COPY --from=wrapper-generator /app/gradlew .
...

B. Stage 2. Runtime

在 Build Stage 所產生的映像檔非常臃腫,因為包含了 JDK、Gradle 快取、所有原始碼和中繼檔案。但在執行 Spring Boot 應用程式時,原則上只需要 JRE 與 *.jar 檔案即可。因此可以使用 eclipse-temurin:21-jre 進行,這個映像檔僅有 95.05MB。

首先要先使用 COPY --from=builder ... 語法,只從前一個 Build Stage 複製位於 /app/build/libs/ 目錄下的 .jar 檔案,可以重新命名為 app.jar 以方便後續執行。隨後,再使用 CMD 指令來定義容器啟動時要執行的命令,也就是 java -jar app.jar。因此這就產生了 Dockerfile 的最後一部分:

# Runtime stage
FROM **eclipse-temurin:21-jre**
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
CMD ["java", "-jar", "app.jar"]

可以嘗試使用原先的 openjdk:21-jdk-slim 作為 Rumtime Stage 的基底映像檔,可以注意到兩者打包出來的映像檔資源差距大約為 300MB。這在一些 CI/CD Pipeline 中,就會直接攸關「計費問題」。

  • 點擊查看完整的 Dockerfile

    # Generate Gradle Wrapper
    FROM gradle:8.5-jdk21 AS wrapper-generator
    WORKDIR /app
    COPY build.gradle.kts settings.gradle.kts /app/
    RUN gradle wrapper
    
    # Build stage
    FROM openjdk:21-jdk-slim AS builder
    WORKDIR /app
    COPY --from=wrapper-generator /app/gradle ./gradle
    COPY --from=wrapper-generator /app/gradlew .
    
    COPY build.gradle.kts settings.gradle.kts ./
    COPY src ./src
    
    RUN chmod +x ./gradlew
    RUN ./gradlew build -x test --no-daemon
    
    # Runtime stage
    FROM eclipse-temurin:21-jre
    WORKDIR /app
    COPY --from=builder /app/build/libs/*.jar app.jar
    CMD ["java", "-jar", "app.jar"]
    

C. Building

接下來可以執行以下指令進行 build

docker build -t spring-boot-app .

其中 -t spring-boot-app 選項指定映像檔的名稱和標籤(預設為 latest);指令結尾的單個點(.)設定當前目錄為 building 的內容。這表示構建過程會在執行命令的目錄中尋找 Dockerfile 和 HelloWorldApplication.java 等必要檔案。

  • 點擊查看一個可能的輸出 Log:

    [+] Building 72.2s (21/21) FINISHED                                                                           docker:desktop-linux
     => [internal] load build definition from Dockerfile                                                                          0.0s
     => => transferring dockerfile: 609B                                                                                          0.0s
     => [internal] load metadata for docker.io/library/gradle:8.5-jdk21                                                           2.7s
     => [internal] load metadata for docker.io/library/openjdk:21-jdk-slim                                                        1.6s
     => [auth] library/gradle:pull token for registry-1.docker.io                                                                 0.0s
     => [auth] library/openjdk:pull token for registry-1.docker.io                                                                0.0s
     => [internal] load .dockerignore                                                                                             0.0s
     => => transferring context: 151B                                                                                             0.0s
     => [wrapper-generator 1/4] FROM docker.io/library/gradle:8.5-jdk21@sha256:873def2b8a73d00f5616043ac9ff65576b055942701391ad  18.9s
     => => resolve docker.io/library/gradle:8.5-jdk21@sha256:873def2b8a73d00f5616043ac9ff65576b055942701391ad66499f1a1f53f1b2     0.0s
     => => sha256:822673a51b0379e525d4a57bb58691ca446834f548080cdaaf1e0eda2c65b46b 171B / 171B                                    0.6s
     => => sha256:f9df75e9bd9e3056345db805305424878d210811c2330a713cb4bd65be21c682 132.54MB / 132.54MB                           12.7s
     => => sha256:68b15aeeba9ab04b48fdd28f0c75093b55db92e953eb63562a758f323d33e8ce 51.12MB / 51.12MB                              8.8s
     => => sha256:c08d3d9a2cb6db298970f5600d3f4e107261efb74f2dc354a5dcf1e36df1626d 4.37kB / 4.37kB                                1.0s
     => => sha256:a55c7dbfbcf51eb48f161d216fbd46ecacaf8b1e7b953d817e2759029814935a 733B / 733B                                    0.7s
     => => sha256:72f2ab3d5e230728112ba972dd5f1ec34d3110e9c536173a4c85699422fd7985 173B / 173B                                    0.6s
     => => sha256:8147c5140d802de257ae5b5b8b9bea14e1e65e1ac7b1468752795c95fb10e1d8 157.79MB / 157.79MB                           15.1s
     => => sha256:4d031894e4c0411af099ca88f2f4707adffb7144d8d3dc5d40457cededf240b5 18.86MB / 18.86MB                              2.9s
     => => sha256:b90a30ba7a05123de8a1e1661ed0ddb6563ad55ca11133e21e3d19f7e6bce76a 28.40MB / 28.40MB                              3.8s
     => => extracting sha256:b90a30ba7a05123de8a1e1661ed0ddb6563ad55ca11133e21e3d19f7e6bce76a                                     0.3s
     => => extracting sha256:4d031894e4c0411af099ca88f2f4707adffb7144d8d3dc5d40457cededf240b5                                     0.2s
     => => extracting sha256:8147c5140d802de257ae5b5b8b9bea14e1e65e1ac7b1468752795c95fb10e1d8                                     0.8s
     => => extracting sha256:72f2ab3d5e230728112ba972dd5f1ec34d3110e9c536173a4c85699422fd7985                                     0.0s
     => => extracting sha256:a55c7dbfbcf51eb48f161d216fbd46ecacaf8b1e7b953d817e2759029814935a                                     0.0s
     => => extracting sha256:c08d3d9a2cb6db298970f5600d3f4e107261efb74f2dc354a5dcf1e36df1626d                                     0.0s
     => => extracting sha256:68b15aeeba9ab04b48fdd28f0c75093b55db92e953eb63562a758f323d33e8ce                                     0.5s
     => => extracting sha256:f9df75e9bd9e3056345db805305424878d210811c2330a713cb4bd65be21c682                                     1.1s
     => => extracting sha256:822673a51b0379e525d4a57bb58691ca446834f548080cdaaf1e0eda2c65b46b                                     0.0s
     => [builder 1/8] FROM docker.io/library/openjdk:21-jdk-slim@sha256:7072053847a8a05d7f3a14ebc778a90b38c50ce7e8f199382128a533  0.0s
     => => resolve docker.io/library/openjdk:21-jdk-slim@sha256:7072053847a8a05d7f3a14ebc778a90b38c50ce7e8f199382128a53385160688  0.0s
     => [internal] load build context                                                                                             0.0s
     => => transferring context: 314B                                                                                             0.0s
     => CACHED [builder 2/8] WORKDIR /app                                                                                         0.0s
     => [wrapper-generator 2/4] WORKDIR /app                                                                                      0.1s
     => [wrapper-generator 3/4] COPY build.gradle.kts settings.gradle.kts /app/                                                   0.0s
     => [wrapper-generator 4/4] RUN gradle wrapper                                                                               18.5s
     => [builder 3/8] COPY --from=wrapper-generator /app/gradle ./gradle                                                          0.0s
     => [builder 4/8] COPY --from=wrapper-generator /app/gradlew .                                                                0.0s
     => [builder 5/8] COPY build.gradle.kts settings.gradle.kts ./                                                                0.0s
     => [builder 6/8] COPY src ./src                                                                                              0.0s
     => [builder 7/8] RUN chmod +x ./gradlew                                                                                      0.2s
     => [builder 8/8] RUN ./gradlew build -x test --no-daemon                                                                    30.7s
     => [stage-2 3/3] COPY --from=builder /app/build/libs/*.jar app.jar                                                           0.0s
     => exporting to image                                                                                                        0.4s
     => => exporting layers                                                                                                       0.3s
     => => exporting manifest sha256:e02cda605f4280b3556c5a5037421408514c2f13b01b1e136e4ec4f6e217fcdb                             0.0s
     => => exporting config sha256:2ba8fd1f5f3b2745118e867216b13cbb3658a6b8ca1a8e374c48cace1d50bda7                               0.0s
     => => exporting attestation manifest sha256:e4c02f58572f4825252570a582ee72b3e91c165f50bf4a2f6bb2127cffc6c0a0                 0.0s
     => => exporting manifest list sha256:0d7e55f43e0e975fd126bf59a5954b274040a6a42980477ae7375841e5d9d8db                        0.0s
     => => naming to docker.io/library/spring-boot-app:latest                                                                     0.0s
     => => unpacking to docker.io/library/spring-boot-app:latest                                                                  0.1s
    

在完成 Docker Build 之後,可以透過 docker images 指令查看目前系統中所有的映像檔

REPOSITORY               TAG       IMAGE ID       CREATED         SIZE
spring-boot-app          latest    0d7e55f43e0e   2 minutes ago   748MB

D. Run

映像檔建立完成後,使用以下指令可將應用程式作為容器運行,並指定映像檔名稱:

docker run -d --name backend -p 8080:8080 spring-boot-app

這時可以看到終端機輸出這個 image 的 sha256 碼。在加上 -d 標籤時,代表 detached mode。這時若要查看 backend 容器內部的 Log,可以用以下指令查看;若加上 -f 標籤,則可以讓該終端機 session 持續即時印出 Docker 容器的輸出,要退出時僅需要使用 Ctrl+C 即可。

docker logs backend
docker logs backend -f

另外可以使用 docker ps 查看目前容器狀態

CONTAINER ID   IMAGE             COMMAND               CREATED         STATUS         PORTS      NAMES
05ccdafa8009   spring-boot-app   "java -jar app.jar"   4 seconds ago   Up 3 seconds   8080/tcp   backend

如果有使用 Docker Desktop,則可以透過 GUI 查看。這時候透過以下指令測試,應該要回傳 {"message": "Hello, World!"}

curl http://localhost:8080/hello

至此,一個基本的 Docker image 就建立完成了。接下來要處理 Nginx 轉發的部分

E. Docker Network

Docker 容器啟動時,會預設使用 bridge 網路,這使容器內部網路能直接連接到 host 上的虛擬網路橋接器(通常為 docker0)。容器無法辨識出其所連接的網路類型(例如 bridge、host 或 overlay),也無法判斷與其連接的是其他容器還是主機網路。容器僅能看到一個包含 IP 位址、網關、路由表、DNS 服務和其他網路設定資訊的網路介面。

  • 可以透過以下指令查看所有 Docker Network:

    docker network ls

    可能會看到以下輸出

    NETWORK ID     NAME      DRIVER    SCOPE
    c7528f237084   bridge    bridge    local
    8fc1f1f5f81d   host      host      local
    bff4072787c8   none      null      local
    
  • 可以透過以下指令查看這個 Docker Network 的一些細節,終端機會輸出一個 JSON 物件,內容是這個網路的相關資訊、哪些容器使用這個網路… 等資訊。

    docker network inspect bridge

使用者可以自行定義一個 bridge 網路來連接多個容器,此時 Docker 會啟用內建的 DNS 伺服器。這個 DNS 伺服器的功能是讓網路內的任何容器都能透過其他容器的「名稱」來解析其內部 IP 位址。回顧 nginx.conf 的內容:

events {
   worker_connections 1024;
}

http {
   server {
      listen 80;
      server_name localhost;

      location /hello {
         proxy_pass http://backend:8080/hello;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
      }
   }
}

這裡的 http://backend:8080 指示 Nginx 將請求轉發到名為 backend 的主機的 8080 埠。在傳統環境中,backend 必須是 DNS 伺服器有記錄或是在 /etc/hosts 檔案中定義的主機名稱。但在 Docker 的自定義網路中,只要 Spring Boot 容器命名為 backend,Nginx 容器就能透過 Docker 內建 DNS 自動將 backend 解析到 Spring Boot 容器的內部 IP 位址。因此,可以透過以下方式實現這個架構

  1. 要確認這個 Spring Boot 的映像檔有被建立起來

    docker build -t spring-boot-app .
  2. 在 docker 中建立一個使用者定義的網路

    docker network create app-network

    這時候會輸出這個網路的 sha256 碼,用 docker network ls 時,可以看到這個被建立出來的網路出現在 docker 網路的清單內。接下來所有加入到 app-network 的容器都可以透過其容器名稱進行通訊。

  3. 啟動 Spring Boot 容器做後端服務,這時有兩個關鍵點需要注意

    docker run -d \
       --name backend \
       --network app-network \
       spring-boot-app
    • -name backend:容器命名必須與 nginx.conf 中的 proxy_pass 設定完全一致。
    • -network app-network:將這個容器連接到剛剛建立的 app-network
    • -d:在背景執行(detached mode)

    完成後,docker 一樣會輸出這容器的 sha256 碼

  4. 啟動 Nginx 容器做前端代理,這邊要注意的標籤較多

    docker run -d \
       --name nginx-proxy -p 8080:80 \
       --network app-network \
       -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro nginx
    • --network app-network:將 Nginx 容器連接到同一個 app-network,這樣才能找到 backend 容器。
    • -p 8080:80:將本機 Host 的 8080 埠映射到 Nginx 容器的 80 埠,讓外部瀏覽器能夠存取到 Nginx。
    • -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro:將 host 目錄下的 nginx.conf 檔案到 Nginx 容器內部,覆蓋預設設定檔。
      • $(pwd) 代表目前的目錄(在 Linux、WSL2、macOS 和 Windows Git Bash 皆適用)。
      • :/etc/nginx/nginx.conf 是 Nginx 容器內部存放設定檔的路徑。
      • :ro 表示唯讀(read-only),防止容器修改 host 端設定檔。

    因為容器內並沒有 Nginx 映像檔,因此 Docker 會直接從 Docker Hub 拉下最新的 Nginx 映像檔

    Unable to find image 'nginx:latest' locally
    latest: Pulling from library/nginx
    fdf316665463: Download complete
    0878ecc8b0af: Extracting 1 s
    d482c1064d09: Download complete
    8f42b11f40a7: Download complete
    75e874aacbee: Download complete
    68e4d7b9f947: Download complete
    605513a168b0: Download complete
    
  5. 完成以上步驟後,完整的請求流程如下:

    1. 瀏覽器存取 http://localhost:8080/hello
    2. 請求送到本機的 8080 埠,Docker 將其轉發到 nginx-proxy 容器的 80
    3. Nginx 容器收到請求,根據 nginx.conf 設定,將請求代理到 http://backend:8080/hello
    4. Nginx 容器向 app-network 內的 DNS 服務查詢 backend 的資訊
    5. Docker DNS 回應 backend 的 IP 位址為 172.x.x.x,即 Spring Boot 容器的內部 IP
    6. Nginx 容器向 http://172.x.x.x:8080/hello 發送請求
    7. backend 容器(Spring Boot App)處理請求後回傳 {"message": "Hello, World!"} 的 JSON
    8. 回應通過原路徑回傳至瀏覽器。

F. Limit the Use of Resource

Docker 可透過 Linux 核心中的 cgroup 技術來限制應用程式的資源使用。在開發階段設定這些限制,能幫助開發者提早發現應用程式在資源受限環境中可能出現的問題。

  1. RAM 限制

    記憶體是最常需要限制的資源,特別是對於 Spring Boot 這類執行在 JVM 上的應用程式。因為 JVM 在沒有資源限制的情況下,可以無限制擴張資源導致環境出現問題。

    假定要對容器設定一個 512MB 的資源上限,可以透過 -m--memory 的方式做硬性限制,讓容器使用的記憶體絕對不能超過這個值。如果超過,Linux 核心的 OOM Killer(Out-of-Memory Killer)機制就會被觸發,強制停止容器內的一個行程來釋放記憶體,這一般會直接導致容器崩潰退出;另外也可以透過 --memory-swap 的參數做硬碟交換空間(Swap)

    docker run -d \
       --name backend \
       --network app-network \
       --memory 512m \
       --memory-swap 1G \
       spring-boot-app

    若要限制容器完全不能使用 Swap,可以將 --memory-swap 的值設為與 --memory 相同。

    --memory-reservation 則是設定記憶體的軟性限制,Docker 會嘗試將容器的記憶體使用量控制在這個值以下。但當系統記憶體充足時,容器可以暫時超過這個保留值,直到達到 --memory 參數所設定的硬性上限為止。

  2. CPU 限制

    CPU 的限制比記憶體稍微複雜一些,主要有兩種方式,分別是絕對限制(--cpus)和相對權重(--cpu-shares)。對於大部分情況下,使用絕對限制會比較直觀。例如 -cpus="1.0" 表示容器最多可以使用 1 個 CPU 核心的全部運算能力、而 -cpus="0.5":表示容器最多可以使用 1 個 CPU 核心的一半運算能力,以此類推。

    以 Nginx 這類 I/O 密集型而非 CPU 密集型的服務,一般在處理請求上非常快。因此可以給予較少的 CPU 資源,例如半個核心;相對來說,作為後端應用程式,可能需要較多的計算資源,尤其是在啟動或處理較為複雜的業務需求時。

    docker run -d \
       --name nginx-proxy \
       -p 8080:80 \
       --network app-network \
       -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
       --cpus="0.5" \
       -m "128m" \
       nginx
    
    docker run -d \
       --name backend \
       --network app-network \
       --cpus="1.0" \
       -m "512m" \
       spring-boot-app

    當 CPU 資源沒有出現競爭情況時,無論 --cpu-shares 值為何,容器可以自由使用其所需求的 CPU 資源。這些配額僅在多個容器競爭 CPU 資源時,才會影響 CPU 排班行程。--cpu-shares 值(預設為 1024)並不代表絕對的 CPU 時間或百分比,而是定義相對於其他執行中容器的 CPU 配額比例。例如,在 CPU 競爭期間,具有 --cpu-shares 2048 的容器會獲得 --cpu-shares 1024 容器兩倍的 CPU 時間。

    docker run -d \
       --name nginx-proxy \
       -p 8080:80 \
       --network app-network \
       -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
       --cpu-shares 1024 \
       -m "128m" \
       nginx
    
    docker run -d \
       --name backend \
       --network app-network \
       --cpu-shares 2048 \
       -m "512m" \
       spring-boot-app

    假定現在有大量併發請求出現,使得 Nginx 和 Spring Boot 都需要進行大量運算,可能共同需要的 CPU 已經超出主機的核心總數量(例如在一個僅分配 2CPU 的虛擬機中)。這時候在有定義 --cpu-shares 的情況下,Docker 的排程器就會開始介入。根據 2048:1024(即 2:1)的比例來分配 CPU 時間。在繁忙的這段時間裡,backend 容器能獲得的 CPU 運算時間將會是 nginx-proxy 容器的兩倍,讓一些大型程式在運作上可以較為優先。

G. Volume

當容器被刪除時,其內部檔案系統上的所有變更都會遺失。而 Volume(卷宗)則是將 Host 主機上的目錄或 Docker 管理的儲存區域,掛到容器內部的路徑上。這樣只要應用程式寫入此路徑的任何資料,實際上會直接存儲在 Host 主機或 Docker 管理的儲存區上。即使容器被刪除,資料依然會完整保留下來。


開發者收到新的業務需求,要在既有服務之上,在 Spring Boot 應用程式中做一個 counter 來紀錄頁面 refresh 的次數,放到卷宗裡面。

.
├── Dockerfile
├── .dockerignore
├── nginx.conf
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
└── src/main/java/com/example
    ├── HelloController.java
    └── HelloWorldApplication.java

現在要針對 HelloController.java 撰寫一個函數(如 incrementAndGet())使其可以讀取、遞增並寫入一個計數器檔案,路徑設定在容器的一個特定目錄下(例如 /data),稍後會將卷宗掛到這個目錄上。因此:

package com.example;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;

@RestController
public class HelloController {

   private final Path counterFile = Paths.get("/data/counter.txt");
   private final Object lock = new Object();

   @GetMapping("/hello")
   public String hello() {
      long count = incrementAndGet();
      return "{\"message\": \"Hello, World!\", \"refresh_count\": " + count + "}";
   }

   private long incrementAndGet() {
      synchronized (lock) {
         long currentCount = 0;
         try {
            // If the file doesn't exist, create it first
            if (!Files.exists(counterFile)) {
               // Ensure parent directory exists
               Files.createDirectories(counterFile.getParent());
               Files.writeString(counterFile, "0");
            }
            // Read the current count
            String content = Files.readString(counterFile);
            currentCount = Long.parseLong(content.trim());
         } catch (IOException | NumberFormatException e) {
            // If reading fails or format is incorrect, start from 0
            currentCount = 0;
         }

         // Increment and write back
         long newCount = currentCount + 1;
         try {
            Files.writeString(
               counterFile,
               String.valueOf(newCount),
               StandardOpenOption.CREATE,
               StandardOpenOption.TRUNCATE_EXISTING
            );
         } catch (IOException e) {
            // If writing fails, you can log it here
            e.printStackTrace();
         }
         return newCount;
      }
   }
}

現在 Spring Boot 應用程式因為被修改了,因此要重新 build image 與建立 container。注意新的 container 名稱不能與既有的衝突到。若確定舊版本的容器已經不再使用,可以先行移除

docker build -t spring-boot-app .
docker stop backend
docker remove backend

此時透過映像檔建立容器

docker run -d \
   --name backend \
   --network app-network \
   --cpus="1.0" \
   --memory "512m" \
   --volume app-data:/data \
   spring-boot-app

其中 app-data 是為 Volume 的名字,若 Volume 不存在,Docker 會自動在 /var/lib/docker/volumes/ 路徑下建立。而分隔符 : 右側的 /data 是容器內部的路徑,也就是說會將 app-data 這個 Volume 掛載到容器的 /data 目錄下。

使用 docker volume ls 可以看到目前 Docker Host 上存在的所有 Volume,如果要知道某個特定 Volume 的更詳細資訊,例如在 Host 主機上實際的儲存位置,可以使用以下指令

docker volume inspect app-data

但有時候為了方便把專案「移植」到其他系統,這個資料就要可以被存放在專案根目錄下面。在 Docker 中會使用 Bind Mount,如

docker run -d \
   --name backend \
   --network app-network \
   --cpus="1.0" \
   --memory "512m" \
   --volume "$(pwd)/data:/data" \
   spring-boot-app

可以注意到專案根目錄會多出一個 /data 路徑,而一般不會將這些卷宗進行版控(除非有特殊需求)。因此專案根目錄下方的 /data 就需要加入 .gitignore 裡面。

在執行後,可以用 curl http://localhost:8080/hello 或是直接在瀏覽器內輸入網址與路由查看計數器。

H. Inspect in a Container

需要確認檔案是否已成功掛載到容器中時,可以使用 docker exec 指令進入正在運行的容器 shell 進行檢查:

docker exec -it backend bash

這個指令會直接進入 docker 預設的 WORKDIR 裡面(參考 Dockerfile),而卷宗一般存放在容器根目錄的 /data 裡面,因此需要注意這邊的路徑問題。操作完成後,輸入 exit 即可退出容器的 shell。若不想進入容器,可以用 -c 指定要執行的指令,例如要查看卷宗內計數器的次數

docker exec -it backend bash -c "cat /data/counter.txt"

I. Remove / Prune

當映像檔建構完成時,可以用 docker rmi <tag-name> 或是 docker rmi <image-id> 進行移除。一般只要在映像檔 ID 是唯一的,就可以用 3 到 4 個字元刪掉映像檔即可,不用輸入完整的 sha256 碼。

除非很明確移除映像檔,否則即便使用相同名稱構建新的映像檔, 該映像檔也會永久存在該系統中。利用相同的標籤建立出新的映像檔,只會讓標籤移動到新的映像檔,而非刪除或取代既有的映像檔。因此,若不斷建立新的映像檔,就會導致系統中很多硬碟空間被浪費掉。可以透過 docker images 指令查看目前存在機器上的映像檔,而後進行刪除

若確定目前停止運作的映像檔都是可以被刪除的(例如沒有用 docker run 的映像檔),可以用以下指令進行清理

docker system prune

或是說在一個頻繁建立新的映像檔的 CI/CD Pipeline 裡面,可以設定一個 cornjob 當作一個映像檔清除機制,可能每小時或每天重複執行這些 cornjob,取決於系統中在一段時間內存有映像檔的數量。

Section 2. Compose File

A. Why Compose File?

每次在啟動服務時,頻繁輸入指令可能會有出錯的問題。即便把這些指令透過複製貼上的方式,某些程度上可以避免人為失誤,但在編排這些指令之前,偵錯也需要花上一段時間。因此,就會需要一個方式將 **指令式(Imperative)語言轉變為宣告式(Declarative)**語言。Docker 官方提供 Docker Compose 作為一個用來定義和執行多容器 Docker 應用程式的工具,主要是透過 YAML 格式的設定檔,一般命名為 docker-compose.yaml。開發者就可以在這個檔案中,以的方式描述應用程式所需的所有服務(Services)、網路(Networks)和卷宗(Volumes)。

使用 Docker Compose 的好處是:

  1. 所有容器的啟動參數、相依關係、網路設定都集中在一個檔案中,一目了然。
  2. 只需要一個簡單的指令 docker-compose up 就能啟動整個應用程式架構,用 docker-compose down 就能全部關閉並移除。
  3. Compose 檔案可以直接做版本控制,確保任何團隊成員都能在自己的機器上以完全相同的方式啟動整個應用程式。

B. How to Write a Compose File

  1. 宣告 Services

    1. 針對 backend 服務,從建立容器的指令來看

      docker run -d --name backend --network app-network --cpus="1.0" --memory "512m" --volume app-data:/data spring-boot-app

      可以將指令中的 flag 一一對應到 YAML 裡面:

      services:
         backend:
            # build: . indicates that Docker Compose will look for a Dockerfile in the current directory to build the image
            build: .
            # container_name corresponds to the --name flag
            container_name: backend
            # restart: unless-stopped ensuring the container automatically restarts when closed unexpectedly
            restart: unless-stopped
            # networks corresponds to the --network flag
            networks:
               - app-network
            # volumes corresponds to the --volume flag
            volumes:
               - ./data:/data # Use Bind Mount (Preferred)
            #       - app-data:/data  # Use default volume
            # resource limits need to be placed in the deploy.resources.limits block
            deploy:
               resources:
                  limits:
                     cpus: "1.0"
                     memory: 512M
    2. 針對 nginx-proxy 服務,從建立容器的指令來看

      docker run -d --name nginx-proxy -p 8080:80 --network app-network -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro nginx

      可以將指令中的 flag 一一對應到 YAML 裡面:

      services:
         # ... backend service definition ...
      
         nginx-proxy:
            # Directly use the official Nginx image from Docker Hub
            image: nginx
            container_name: nginx-proxy
            restart: unless-stopped
            # ports corresponds to the -p flag
            ports:
               - "8080:80"
            networks:
               - app-network
            # Bind Mount paths can directly use relative paths with ./
            volumes:
               - ./nginx.conf:/etc/nginx/nginx.conf:ro
            # depends_on ensures the backend service starts before nginx-proxy
            depends_on:
               - backend

      其中 depends_on 的作用是讓 Compose nginx-proxy 服務相依於 backend 服務。也就是在啟動時,會先建立與啟動 backend 容器,然後才是 nginx-proxy,避免 Nginx 啟動時出現後端服務不存在的問題。

  2. 宣告 Network

    需要一個名為 app-network 的網路,這需要在頂層的 networks 區塊中宣告

    # ... services block ...
    networks:
       app-network:
          # driver: bridge is the default value, usually can be omitted
          driver: bridge
  3. 宣告 Volume(如果沒有使用 Bind Mount**)**

    對於 Volume app-data,也需要在頂層的 volumes 區塊中宣告

    services:
       backend:
          ...
          volumes:
             - app-data:/data
    volumes:
       app-data:
          # driver: local is the default value, usually can be omitted
          driver: local
  • 完整的 docker-compose.yaml 如下

    • 使用 Bind Mount

      services:
         backend:
            build: .
            container_name: backend
            restart: unless-stopped
            networks:
               - app-network
            volumes:
               - ./data:/data
            deploy:
               resources:
                  limits:
                     cpus: "1.0"
                     memory: 512M
      
         nginx-proxy:
            image: nginx
            container_name: nginx-proxy
            restart: unless-stopped
            ports:
               - "8080:80"
            networks:
               - app-network
            volumes:
               - ./nginx.conf:/etc/nginx/nginx.conf:ro
            depends_on:
               - backend
      
      networks:
         app-network:
            driver: bridge
    • 不使用 Bind Mount

      services:
         backend:
            build: .
            container_name: backend
            restart: unless-stopped
            networks:
               - app-network
            volumes:
               - app-data:/data
            deploy:
               resources:
                  limits:
                     cpus: "1.0"
                     memory: 512M
      
         nginx-proxy:
            image: nginx
            container_name: nginx-proxy
            restart: unless-stopped
            ports:
               - "8080:80"
            networks:
               - app-network
            volumes:
               - ./nginx.conf:/etc/nginx/nginx.conf:ro
            depends_on:
               - backend
      
      networks:
         app-network:
            driver: bridge
      
      volumes:
         app-data:
            driver: local

C. Operation with Compose File

有了 compose 檔案後,管理整個應用程式架構就變得相對簡單

  1. 啟動 compose 檔案宣告的服務、網路、與卷宗

    docker-compose up -d --build
    • 其中 up 是啟動服務、d 是在背景執行(detached mode)
    • -build 是強制重新建立 backend 服務的映像檔,例如 Java 程式碼或 Dockerfile 有被修改時所使用
  2. 可以用 docker ps 查看服務狀態

  3. 停止 compose 檔案宣告的服務、網路、與卷宗

    docker compose down
    docker compose down -v  # If not using Bind Mount

About

This is a demo project with tutorial: How to Basic Docker

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published