Share Notes

chundev

View the Project on GitHub latteouka/share-notes

自建上游廠商的 Java 單體部署 — 私有 Harbor + Tomcat stack 的六個坑

日期:2026-04-10 技術棧:Ubuntu 24.04 / Docker 29 / containerd 2.2 (v3 config) / Harbor 2.11 / Tomcat 9 / PostgreSQL 13 / Angular SPA


TL;DR

把一個專有 Java 單體產品(Tomcat + PostgreSQL + ActiveMQ + nginx reverse-proxy)加上私有 Harbor registry 從原廠壓縮包自建部署,中間踩了六個坑:Docker 與 containerd v3 的雙重信任設定Harbor health API 的 timing 假警報docker compose 有 build: 指令卻沒 DockerfileAngular <base href> 跟原廠 README 指定的目錄名稱 driftcat >> 遇到沒結尾 newline 的檔案會黏行production WAR 誤打進 test config 導致 ClassNotFoundException。每一個坑都很典型,都在教我們同一件事:壓縮包式的 vendor 部署,文件和程式碼 drift 是常態


背景

很多企業會買一個「原廠壓縮包 + 安裝指南 PDF + Ansible script」形式的專有產品。這類產品的部署方式通常是:

原廠 tgz ──► 解壓 ──► 放到固定目錄 ──► rename ──► docker load / docker compose up

這條 pipeline 看起來簡單,但每一步都可能踩到文件與程式碼不同步的坑。下面記錄自建部署過程中遇到的六個可轉移的技術陷阱,對任何人在自建 vendor 產品時都有參考價值。


坑 1:HTTP 私有 registry 要同時對 Docker 和 containerd 雙重信任

症狀

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 side:

// /etc/docker/daemon.json
{
  "insecure-registries": ["registry.local:18080"]
}

systemctl restart docker

containerd side (現代 hosts.d 做法,推薦)

  1. config.toml 啟用 hosts.d 目錄:
# /etc/containerd/config.toml (v3 format)
[plugins.'io.containerd.cri.v1.images'.registry]
  config_path = '/etc/containerd/certs.d'
  1. 建立 registry 專屬設定檔:
# /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.tomlregistry.mirrors 好很多的現代做法。

⚠️ containerd v2.x 的 TOML 格式陷阱

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 規則沒生效,難以察覺。


坑 2:Harbor health API 的 timing 假警報

症狀

./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”。結果整體 statusunhealthy,即使其他 8 個 component 都正常。

解法:等到整體 status 真的 healthy,不是 HTTP 200

❌ 錯誤的 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 系統都適用。


坑 3:build: 指令但目錄沒有 Dockerfile

症狀

原廠 docker-compose.yaml 長這樣:

services:
  tomcat:
    build: tomcat
    image: tomcat:9.0.98-jre11-temurin-jammy
    # ... volumes, env, etc

./tomcat/ 目錄裡只有 config 檔案,沒有 Dockerfiledocker compose up 跑起來行為不穩定:

根因

Docker 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 loaddocker 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 時直接化簡。


坑 4:Angular <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 就踩。

解法:rename 目錄對齊 <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 的程式碼。自建部署時:

這三步都要做,不能只做 README 說的那一步。


坑 5: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)。

解法 1:用 printf 補 newline

printf '\n' >> rewrite.config
cat new-rules.txt >> rewrite.config

解法 2:用 Ansible 的 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 的時候特別容易踩。


坑 6:production WAR 誤打包 test configuration

症狀

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,外部沒辦法修。但可以:

  1. 確認 exception 出現的 class 是 test package(package 名有 test / .test. / tests
  2. 確認主 app 啟動成功Server startup in [xxx] milliseconds 訊息存在)
  3. 測主要功能,如果能動,忽略 exception

如果強迫症想清乾淨:

# 從 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 名是不是有 testtest class 的 exception 通常是 non-fatal


可轉移的 pattern:壓縮包式 vendor 部署的系統性脆弱

上面六個坑看似獨立,但背後是同一個結構問題:

原廠的自動化 pipeline = 內部工具鏈 (build → package → deploy) + 文件描述 + 壓縮包

外部自建時 = 只有壓縮包 + 文件描述,沒有內部工具鏈

內部工具鏈會處理很多「文件沒寫」的事情:

vendor 自己測得通,因為他們跑完整 pipeline;外部照文件手動 deploy 就會掉進這些隱性約定的坑

自建部署的檢查清單

遇到 vendor 壓縮包部署,不管文件寫什麼,都要額外做這些檢查:

  1. docker-compose.yamlbuild: 但目錄沒 Dockerfile? → 刪掉 build:
  2. 前端 index.html<base href="/xxx/"> → 部署目錄必須叫 xxx(不管 README 說什麼)
  3. 任何 .conf 檔要被 append? → 先 printf '\n' >> 再 append
  4. Health check API 回 200 但 body 說 unhealthy? → 檢查 body 的 status 欄位
  5. Stack trace 裡有 test package? → 看後面有沒有 “startup finished”,有就忽略
  6. 用 HTTP 私有 registry? → Docker daemon.json 和 containerd hosts.d 都要設

學到的事


參考資料