chundev
日期:2026-04-10 技術棧:Ubuntu 24.04 / Docker 29 / containerd 2.2 (v3 config) / Harbor 2.11 / Tomcat 9 / PostgreSQL 13 / Angular SPA
把一個專有 Java 單體產品(Tomcat + PostgreSQL + ActiveMQ + nginx reverse-proxy)加上私有 Harbor registry 從原廠壓縮包自建部署,中間踩了六個坑:Docker 與 containerd v3 的雙重信任設定、Harbor health API 的 timing 假警報、docker compose 有 build: 指令卻沒 Dockerfile、Angular <base href> 跟原廠 README 指定的目錄名稱 drift、cat >> 遇到沒結尾 newline 的檔案會黏行、production WAR 誤打進 test config 導致 ClassNotFoundException。每一個坑都很典型,都在教我們同一件事:壓縮包式的 vendor 部署,文件和程式碼 drift 是常態。
很多企業會買一個「原廠壓縮包 + 安裝指南 PDF + Ansible script」形式的專有產品。這類產品的部署方式通常是:
原廠 tgz ──► 解壓 ──► 放到固定目錄 ──► rename ──► docker load / docker compose up
這條 pipeline 看起來簡單,但每一步都可能踩到文件與程式碼不同步的坑。下面記錄自建部署過程中遇到的六個可轉移的技術陷阱,對任何人在自建 vendor 產品時都有參考價值。
Harbor 起來了、docker push 成功,但 Kubernetes Pod 引用 Harbor image 的時候 Pod 卡在 ErrImagePull:
Failed to pull image "registry.local:18080/library/foo:test":
http: server gave HTTP response to HTTPS client
Docker 和 containerd 是兩個獨立的 container runtime,各自有自己的 registry 信任清單。即使它們跑在同一台機器上,彼此也不共享設定。
docker push/pull 讀 /etc/docker/daemon.json/etc/containerd/config.toml 或 /etc/containerd/certs.d/*/hosts.toml只設一邊只會讓一半能動。
Docker side:
// /etc/docker/daemon.json
{
"insecure-registries": ["registry.local:18080"]
}
→ systemctl restart docker
containerd side (現代 hosts.d 做法,推薦):
config.toml 啟用 hosts.d 目錄:# /etc/containerd/config.toml (v3 format)
[plugins.'io.containerd.cri.v1.images'.registry]
config_path = '/etc/containerd/certs.d'
# /etc/containerd/certs.d/registry.local:18080/hosts.toml
server = "http://registry.local:18080"
[host."http://registry.local:18080"]
capabilities = ["pull", "resolve", "push"]
skip_verify = true
→ systemctl restart containerd
關鍵:config_path 一次設好之後,之後新增 registry 不用再 restart containerd — kubelet 下次 pull 時會自動讀 certs.d 目錄。這是比改 config.toml 裡 registry.mirrors 好很多的現代做法。
containerd 2.x 用 version = 3 config,跟 1.x 的 v2 格式差異巨大:
| 項目 | containerd 1.x (v2) | containerd 2.x (v3) |
|---|---|---|
| plugin key 命名 | "io.containerd.grpc.v1.cri" |
'io.containerd.cri.v1.images' |
| quote 字元 | 雙引號 | 單引號 |
| 子 key 結構 | 扁平 | 按職能拆分 (.images, .runtime 等) |
任何從網路抄的 containerd registry config 範例,如果來自 2023 年以前,幾乎都是 v2 格式,直接貼到 2.x 會被忽略(key 對不上等於沒設)。debug 時只會看到 registry mirror 規則沒生效,難以察覺。
./install.sh 跑完,過 10-20 秒查 Harbor 的 health API:
GET /api/v2.0/health
回傳 status: "unhealthy",其中 jobservice 元件顯示:
{
"name": "jobservice",
"status": "unhealthy",
"error": "failed to check health: Get \"http://jobservice:8080/api/v1/stats\":
dial tcp: lookup jobservice on 127.0.0.11:53: server misbehaving"
}
Harbor 由 9 個 container 組成,啟動順序不一致。jobservice 是最後起的(需要等 DB migration 完成),Docker HEALTHCHECK 有 start_period,這段時間該 container 會被回報為 unhealthy。
core 元件的 health check 是輪詢所有 sibling component 的 health endpoint,jobservice 還沒開 port 時就會拿到 “connection refused / DNS lookup failed”。結果整體 status 變 unhealthy,即使其他 8 個 component 都正常。
❌ 錯誤的 CI/Ansible 檢查方式:
- name: Wait for Harbor
uri:
url: "http://registry.local/api/v2.0/health"
status_code: 200
retries: 30
delay: 5
這個只會等 HTTP 回應到 200,不管 JSON 裡面說什麼。Harbor 在 jobservice 還沒好時也會回 200,只是 body 裡寫 status: unhealthy。
✅ 正確做法:檢查 JSON 的 status 欄位:
- name: Wait for Harbor truly healthy
uri:
url: "http://registry.local/api/v2.0/health"
register: harbor_health
retries: 60
delay: 5
until: harbor_health.json.status == "healthy"
或更嚴格:逐一檢查每個 component。
HTTP 狀態碼不等於業務狀態。對複合系統的 health API,要看 response body 的語意,不要只看 HTTP status。這個 pattern 對 Prometheus、Grafana、Istio 等任何 multi-component 系統都適用。
build: 指令但目錄沒有 Dockerfile原廠 docker-compose.yaml 長這樣:
services:
tomcat:
build: tomcat
image: tomcat:9.0.98-jre11-temurin-jammy
# ... volumes, env, etc
但 ./tomcat/ 目錄裡只有 config 檔案,沒有 Dockerfile。docker compose up 跑起來行為不穩定:
unable to prepare context: /tomcat/Dockerfile: no such fileDocker Compose 對 build: + image: 同時指定的解析語意在演進:
| 版本 | 行為 |
|---|---|
compose v1 (docker-compose) |
只在 image 不存在時 build |
| compose v2 早期 | 同上 |
| compose v2.28+ | 開始傾向 build 優先,某些情況會強制 build |
原廠 pipeline 依賴「image 已存在 → skip build」的隱式行為,但這個行為不是 compose 的正式契約。他們可能靠一個內部建置工具預先把 image load 進 docker,然後就能讓 compose 跳過 build。外部使用者不知道這個依賴。
build: 指令既然我們自己會先 docker load 或 docker build -t xxx pre-build 好 image,compose 裡的 build: 就完全多餘。用 Ansible 直接刪掉:
- name: Remove build directives from compose
lineinfile:
path: ./docker-compose.yaml
regexp: ''
state: absent
loop:
- '^\s*build:\s*tomcat\s*$'
- '^\s*build:\s*activemq\s*$'
- '^\s*build:\s*reverse-proxy\s*$'
Vendor 壓縮包的 compose 檔通常反映他們內部 build pipeline 的中間狀態,不是給外部單獨跑的 manifest。看到 build: 但目錄空的、或 image tag 對不上的 manifest,優先假設它是「內部工具流程的一部分」,自己 deploy 時直接化簡。
<base href> 跟部署目錄 drift原廠 README 說:
tar zxvf frontend-ui-58.0.0.tgz -C webapp/app && \
mv webapp/app/package webapp/app/ai-stack
乖乖照做。瀏覽器打開 /ai-stack/,看到 Angular loading 畫面,然後白屏。F12 console 滿是:
GET /user-portal/main.abc123.js 404 (Not Found)
GET /user-portal/styles.def456.css 404 (Not Found)
GET /user-portal/0.c6bd917b2cff65ac.js 404 (Not Found)
打開 webapp/app/ai-stack/index.html 會看到:
<head>
<base href="/user-portal/">
...
</head>
Angular 的 <base href> 是給瀏覽器的「全域資源前綴」hint。瀏覽器載入這個 HTML 後,所有相對 URL(<script src="main.js">、<link href="styles.css">、Angular router 內部的 fetch)都會以 <base href> 為起點。
即使 HTML 檔案本身在 /ai-stack/index.html,瀏覽器還是會照 <base href="/user-portal/"> 去 /user-portal/main.js 找資源。目錄名是 ai-stack,資源請求是 /user-portal/*,全部 404。
這是典型的 doc/code drift:舊版的 Angular build 的 <base href> 真的是 /ai-stack/,前端某次 refactor 改成 /user-portal/ 但 README 沒更新。vendor 內部的自動化 build 腳本可能自動處理 rename 和 rewrite,所以他們自己測不到;外部照文件手動 deploy 就踩。
<base href>,而不是改 HTML# 正確的 rename — 對齊 Angular base href
mv webapp/app/package webapp/app/user-portal
不要改 index.html 的 base href,因為那是 vendor 前端 build 產物,下次升級會被覆蓋、而且可能影響 Angular router 的內部路徑計算。改部署層(目錄名)是最乾淨的 fix。
Angular SPA 是 client-side routing。使用者如果直接打開 /user-portal/dashboard,server 找不到這個實體路徑會回 404,而不是讓 Angular 自己處理路由。
需要在 webserver(Tomcat 的話是 rewrite.config + RewriteValve)加一條:
RewriteCond %{REQUEST_URI} ^\/user-portal\/.+
RewriteCond %{REQUEST_URI} !^\/user-portal\/.+(\..+)$
RewriteRule ^.*$ /user-portal/index.html [L]
這規則說:任何 /user-portal/* 的 URL,如果最後一段沒有副檔名(不是檔案),都 rewrite 成 index.html,讓 Angular router 接手。有副檔名的(*.js, *.css, *.png)就維持原樣去找實體檔案。
<base href> 是 Angular/React SPA 的「部署約定」,不是「執行環境設定」。它寫死在 build artifact 裡,改它等於改 vendor 的程式碼。自建部署時:
index.html 確認 <base href> 值這三步都要做,不能只做 README 說的那一步。
cat >> 遇到沒結尾 newline 的檔案會黏行用 cat >> rewrite.config append 新規則,結果 server 重啟後新規則完全沒生效,連 log 都沒錯誤。直接 cat 檢視:
...
RewriteRule ^.*$ /ai-stack/index.html [L]RewriteCond %{REQUEST_URI} ^\/user-portal\/.+
RewriteCond %{REQUEST_URI} !^\/user-portal\/.+(\..+)$
RewriteRule ^.*$ /user-portal/index.html [L]
看出來了嗎?[L] 和 RewriteCond 黏在同一行。
原廠的 rewrite.config 檔案 EOF 沒有 newline(違反 POSIX 慣例:text file 最後一行應該以 \n 結尾)。cat >> file 不會自己補 newline,直接從當前游標位置開始寫。結果 [L] 後面立刻接 RewriteCond,RewriteValve 把它當成「[L] 後面還有 flag」,整條規則解析失敗(silent fail)。
printf 補 newlineprintf '\n' >> rewrite.config
cat new-rules.txt >> rewrite.config
blockinfile 模組- name: Add new rewrite rule
blockinfile:
path: rewrite.config
block: |
RewriteCond %{REQUEST_URI} ^\/user-portal\/.+
RewriteCond %{REQUEST_URI} !^\/user-portal\/.+(\..+)$
RewriteRule ^.*$ /user-portal/index.html [L]
marker: "# {mark} user-portal rewrite rule"
insertafter: EOF
blockinfile 會自動處理 EOF newline,而且 {mark} 註記讓這個 block 之後可以被 ansible 安全地更新或刪除。
POSIX 規定 text file 最後一行必須以 \n 結尾,但不是所有工具都遵守。當你要 append 到一個「來源未知」的文字檔時,假設它沒有結尾 newline,先補再寫。這個坑在 CI/CD pipeline 寫 shell 的時候特別容易踩。
Tomcat 啟動 log 裡有這一段 stack trace:
java.lang.ClassNotFoundException: org.h2.Driver
at org.apache.catalina.loader.WebappClassLoaderBase.loadClass(WebappClassLoaderBase.java:1349)
at java.base/java.lang.Class.forName(Unknown Source)
at com.vendor.app.bpmn.test.BpmnTestConfiguration.dataSource(BpmnTestConfiguration.java:75)
at ...CGLIB...
但後面又看到:
INFO: Deployment of web application directory [/usr/local/tomcat/webapps/ROOT] has finished in [50,438] ms
INFO: Server startup in [50780] milliseconds
看起來 app 還是起來了。
注意 stack trace 的 package: com.vendor.app.bpmn.test.BpmnTestConfiguration — 裡面有 test 這個字。這是Spring 的測試設定類,本來應該只在 mvn test 時被載入,用 H2 in-memory database 跑單元測試。
但 vendor 把它誤打包進 production WAR。Tomcat 啟動時 Spring 掃描所有 @Configuration 類別,看到 BpmnTestConfiguration 也嘗試初始化,執行 dataSource() 方法時 Class.forName("org.h2.Driver") — production WAR 沒包 h2.jar,拋 ClassNotFoundException。
為什麼 app 還是起來? Spring 的 @Configuration 類別初始化失敗時,如果沒有其他 bean 依賴它,Spring 會記 log 並跳過。production app 的 DataSource bean 是用 PostgreSQL(不是 H2),完全沒依賴 BpmnTestConfiguration,所以主 app 照常啟動。
這是 vendor 的 product defect,外部沒辦法修。但可以:
test / .test. / tests)Server startup in [xxx] milliseconds 訊息存在)如果強迫症想清乾淨:
# 從 WAR 刪掉 test 相關 classes
unzip -l ROOT.war | grep -i test
cd ROOT-extracted
rm -rf WEB-INF/classes/com/vendor/app/bpmn/test/
jar -cf ../ROOT.war .
但這動 vendor 的 build artifact,升級會被覆蓋,不推薦。最好是回報給 vendor 讓他們在下個 build 修掉。
Java/Spring 的 auto-scanning 對 production WAR 的 bean classes 沒有區分能力。如果 build pipeline 沒明確排除 test sources(Maven 的 test scope、Gradle 的 testCompile),就有可能把 test config 打進 production JAR。看到 stack trace 時先看 package 名是不是有 test — test class 的 exception 通常是 non-fatal。
上面六個坑看似獨立,但背後是同一個結構問題:
原廠的自動化 pipeline = 內部工具鏈 (build → package → deploy) + 文件描述 + 壓縮包
外部自建時 = 只有壓縮包 + 文件描述,沒有內部工具鏈
內部工具鏈會處理很多「文件沒寫」的事情:
vendor 自己測得通,因為他們跑完整 pipeline;外部照文件手動 deploy 就會掉進這些隱性約定的坑。
遇到 vendor 壓縮包部署,不管文件寫什麼,都要額外做這些檢查:
docker-compose.yaml 有 build: 但目錄沒 Dockerfile? → 刪掉 build:index.html 有 <base href="/xxx/">? → 部署目錄必須叫 xxx(不管 README 說什麼).conf 檔要被 append? → 先 printf '\n' >> 再 appendtest package? → 看後面有沒有 “startup finished”,有就忽略hosts.d 目錄,比改主 config 乾淨太多。<base href> 是 SPA 的部署約定不是執行環境設定 — 它寫死在 build artifact 裡,部署時要 rename 目錄對齊它,不要改 HTML。cat >> 到未知檔案前先 printf '\n' — POSIX 說檔案末尾要有 newline,但不是所有工具都遵守。Ansible 用 blockinfile 比較安全。test package 通常是 non-fatal — Spring 會 log 並 skip 失敗的 @Configuration,主 app 不受影響。<base href> 的 Angular 官方說明build: 跟 image: 同時指定時的行為\n 結尾