From 700b7b08ba0dd9a7a76bd272254a5e586cb86d37 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Mon, 11 May 2026 10:35:21 +0800 Subject: [PATCH] =?UTF-8?q?chore(stage):=20=E6=96=B0=E5=A2=9E=20v2=20deplo?= =?UTF-8?q?y=20=E6=B5=81=E7=A8=8B=EF=BC=88remote=20build=20via=20DOCKER=5F?= =?UTF-8?q?HOST=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 (deploy-stage.sh) 走 docker save | gzip | docker load 模式,需要把 81MB tarball 一次性 POST 到 stage docker daemon /images/load API。5/4 / 5/9 兩次 驗證 VPN 下 docker daemon 對單一大 POST hang(卡 30+ 分鐘 / i/o timeout), 公司網段直連才可靠。 v2 仿 edge-ai-platform/scripts/deploy-docker.sh 改用 DOCKER_HOST=stage docker build — multi-stage build 完全在 stage daemon 上執行: - 跨網路只傳 build context(~44 MB streaming,VPN 友善) - alpine base / nodejs / go mod / pnpm install 都由 stage daemon 自己 pull - layer cache 留在 stage daemon,後續 incremental build 更快 - 5/9 VPN 下實測 work:first build ~3min、redeploy(layer cache) ~10s 連帶修: - pnpm-workspace.yaml: 加 onlyBuiltDependencies (sharp / unrs-resolver / @tailwindcss/oxide / esbuild) — pnpm 10 預設拒跑依賴 build script、 乾淨環境第一次 install 撞 ERR_PNPM_IGNORED_BUILDS - package.json: 加 packageManager: pnpm@10.30.1 — 鎖 pnpm 版本,corepack 在 stage daemon 第一次跑時不會拉到最新 pnpm 11(行為差異) - Dockerfile.stage: COPY pnpm-workspace.yaml 進 builder context、否則 容器內 install 看不到 trust list v1 (deploy-stage.sh) 保留作為公司網段直連備援;v2 是 VPN / 預設模式。 --- docker/Dockerfile.stage | 4 +- scripts/deploy-stage-v2.sh | 213 +++++++++++++++++++++++++++ visionA-frontend/package.json | 1 + visionA-frontend/pnpm-workspace.yaml | 25 ++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100755 scripts/deploy-stage-v2.sh create mode 100644 visionA-frontend/pnpm-workspace.yaml diff --git a/docker/Dockerfile.stage b/docker/Dockerfile.stage index 3a6c36e..f8d3d51 100644 --- a/docker/Dockerfile.stage +++ b/docker/Dockerfile.stage @@ -41,7 +41,9 @@ RUN corepack enable WORKDIR /src # 先 COPY lockfile 讓依賴 layer 可以被 cache。 -COPY visionA-frontend/package.json visionA-frontend/pnpm-lock.yaml ./ +# pnpm-workspace.yaml 含 onlyBuiltDependencies trust list(pnpm 10 不執行依賴 build script, +# 必須明確 allow,否則乾淨環境第一次 install 撞 ERR_PNPM_IGNORED_BUILDS)。 +COPY visionA-frontend/package.json visionA-frontend/pnpm-lock.yaml visionA-frontend/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile # 複製其餘原始碼後 build。 diff --git a/scripts/deploy-stage-v2.sh b/scripts/deploy-stage-v2.sh new file mode 100755 index 0000000..89ebca2 --- /dev/null +++ b/scripts/deploy-stage-v2.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# +# visionA — Stage Deploy v2(remote build 模式,仿 edge-ai-platform/scripts/deploy-docker.sh) +# +# 為什麼有 v2: +# v1(deploy-stage.sh)走 docker save | gzip | docker load 模式,需要把 81MB +# tarball 一次性 POST 到 stage docker daemon `/images/load`。經驗證 VPN 下 +# docker daemon 對單一大 POST 有 read timeout / hang 問題(5/4 卡 30+ 分、 +# 5/9 i/o timeout / 也卡 30 分),公司網段直連才可靠。 +# +# v2 改用 `DOCKER_HOST=stage docker buildx build .` — multi-stage build +# 完全在 stage daemon 上執行: +# - 跨網路只傳 build context(~44 MB,streaming 上傳,VPN 友善) +# - alpine base / nodejs apk / go mod download / pnpm install 都是 stage +# daemon 自己 pull / 抓(公司內網 → docker hub / npm registry) +# - layer cache 留在 stage daemon、後續 deploy 只重 build 改動的 layer +# +# 流程(仿 edge-ai-platform deploy-docker.sh): +# 0. Pre-flight:docker buildx、git status +# 1. DOCKER_HOST=stage docker buildx build(multi-stage 全在 stage 跑) +# 2. DOCKER_HOST=stage docker compose up -d(同 v1) +# 3. Verify:container status + healthz +# +# 跟 v1 (deploy-stage.sh) 的差別: +# - v1:本機 buildx --load → save | gzip | load → compose up +# - v2:直接 DOCKER_HOST=stage buildx build → compose up(沒 save/load 步驟) +# +# 用法: +# bash scripts/deploy-stage-v2.sh # full deploy +# bash scripts/deploy-stage-v2.sh --skip-build # 不重 build,只 compose up +# bash scripts/deploy-stage-v2.sh --no-deploy # build 完不 compose up(驗證 build) +# bash scripts/deploy-stage-v2.sh --help + +set -euo pipefail + +# ──────────── 路徑 ──────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DOCKERFILE="$PROJECT_ROOT/docker/Dockerfile.stage" +COMPOSE_FILE="$PROJECT_ROOT/docker-compose.stage.yml" + +# ──────────── stage 設定 ──────────── +DOCKER_REMOTE="${DOCKER_HOST:-tcp://192.168.0.130:2375}" +IMAGE_NAME="visiona" +IMAGE_TAG="stage" +IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}" +STAGE_DOMAIN="stage-9527.innovedus.com" +STAGE_PORT="9527" +HEALTHZ_URL="https://${STAGE_DOMAIN}:${STAGE_PORT}/healthz" + +# ──────────── colors ──────────── +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +CYAN='\033[0;36m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } +step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; } +hint() { echo -e "${BLUE}[HINT]${NC} $*"; } + +show_help() { + cat <<'HELP' +visionA — Stage Deploy v2 (remote build 模式) + +Usage: + bash scripts/deploy-stage-v2.sh [OPTIONS] + +Options: + --skip-build Skip build, just compose up(image 已 build 過) + --no-deploy Build only, do not compose up + --help Show this help + +Environment: + DOCKER_HOST Remote Docker daemon (default: tcp://192.168.0.130:2375) + +Examples: + bash scripts/deploy-stage-v2.sh + bash scripts/deploy-stage-v2.sh --skip-build + bash scripts/deploy-stage-v2.sh --no-deploy + +After deployment: + https://stage-9527.innovedus.com:9527/ +HELP +} + +# ──────────── parse args ──────────── +SKIP_BUILD=false +NO_DEPLOY=false + +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=true; shift ;; + --no-deploy) NO_DEPLOY=true; shift ;; + --help|-h) show_help; exit 0 ;; + *) error "Unknown option: $1 (use --help)" ;; + esac +done + +# ──────────── pre-flight ──────────── +step "0/3 Pre-flight checks" + +command -v docker >/dev/null 2>&1 || error "docker 未安裝" +[ -f "$DOCKERFILE" ] || error "找不到 Dockerfile.stage:$DOCKERFILE" +[ -f "$COMPOSE_FILE" ] || error "找不到 docker-compose.stage.yml:$COMPOSE_FILE" + +# 確認 stage daemon 連得上(v2 必須能連、v1 也是) +info "確認 stage docker daemon 連線:$DOCKER_REMOTE" +if ! DOCKER_HOST="$DOCKER_REMOTE" docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then + error "無法連到 stage docker daemon @ $DOCKER_REMOTE + 提示: + - 公司內網需直連 192.168.0.130:2375 + - VPN 下小流量 OK(version / images),但大流量會卡 + - v2 模式跨網路傳 ~44 MB build context,比 v1 友善但仍非 100% 保證" +fi +STAGE_VERSION=$(DOCKER_HOST="$DOCKER_REMOTE" docker version --format '{{.Server.Version}}' 2>/dev/null || echo unknown) +info "stage daemon 連線 OK(server version: $STAGE_VERSION)" + +# git 狀態(warn only) +cd "$PROJECT_ROOT" +GIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +TIMESTAMPED_TAG="${IMAGE_TAG}-${TIMESTAMP}-${GIT_SHA}" + +if ! git diff-index --quiet HEAD -- 2>/dev/null; then + warn "git working tree 有未 commit 的變更(stage build 仍會繼續)" +fi + +info "Project root : $PROJECT_ROOT" +info "Docker remote : $DOCKER_REMOTE" +info "Image : $IMAGE_REF" +info "Timestamp tag : ${IMAGE_NAME}:${TIMESTAMPED_TAG}" +info "Git SHA : $GIT_SHA" + +# ──────────── build (在 stage daemon 上) ──────────── +if [ "$SKIP_BUILD" = false ]; then + step "1/3 Remote build (DOCKER_HOST=stage docker buildx build)" + + info "build 完全在 stage daemon 上執行;只跨網路傳 build context (~44 MB streaming)" + + # 用 buildx(buildx 預設 builder 是 default = remote daemon driver) + # 不需要 --load / --push:build 完 image 直接留在 stage daemon + # 不需要 --platform:DOCKER_HOST 指向 stage daemon、stage 是 linux/amd64、native build + cd "$PROJECT_ROOT" + DOCKER_HOST="$DOCKER_REMOTE" docker build \ + -f "$DOCKERFILE" \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:${TIMESTAMPED_TAG}" \ + . + + info "build 完成 — stage daemon 上已有 ${IMAGE_NAME}:${IMAGE_TAG} + ${IMAGE_NAME}:${TIMESTAMPED_TAG}" +else + info "skip 1/3(--skip-build)— 假設 ${IMAGE_NAME}:${IMAGE_TAG} 已存在於 stage daemon" +fi + +# ──────────── deploy ──────────── +if [ "$NO_DEPLOY" = true ]; then + info "skip 2/3, 3/3(--no-deploy)" + info "下一步:執行 bash scripts/deploy-stage-v2.sh --skip-build 完成 deploy" + exit 0 +fi + +step "2/3 Deploy via docker compose" + +DOCKER_HOST="$DOCKER_REMOTE" docker compose \ + -f "$COMPOSE_FILE" \ + --project-directory "$PROJECT_ROOT" \ + -p visiona-stage \ + up -d --remove-orphans + +info "container 已啟動,等 healthcheck(最多 60s)..." + +# ──────────── verify ──────────── +step "3/3 Verify" + +DOCKER_HOST="$DOCKER_REMOTE" docker ps --filter name=visiona --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +deadline=$(( $(date +%s) + 60 )) +healthy=false +status=unknown +while [ "$(date +%s)" -lt "$deadline" ]; do + status=$(DOCKER_HOST="$DOCKER_REMOTE" docker inspect --format='{{.State.Health.Status}}' visiona 2>/dev/null || echo "unknown") + if [ "$status" = "healthy" ]; then + healthy=true + break + fi + sleep 3 +done + +if [ "$healthy" = true ]; then + info "container healthy ✓" +else + warn "container healthcheck 超時(status=$status)— 注意 5/1 已知 healthcheck 從 container 內被 nginx server_name 擋成 444 是 false negative,外部 healthz 才是真實狀態" +fi + +if curl -fsS --max-time 10 "$HEALTHZ_URL" >/dev/null 2>&1; then + info "對外 healthz ✓ ($HEALTHZ_URL)" +else + warn "對外 healthz 失敗:$HEALTHZ_URL" + warn "查看 log: DOCKER_HOST=$DOCKER_REMOTE docker logs visiona --tail 100" +fi + +echo "" +echo -e "${GREEN}=== Deploy v2 完成 ===${NC}" +echo "" +info "URL : https://${STAGE_DOMAIN}:${STAGE_PORT}/" +info "Logs: DOCKER_HOST=$DOCKER_REMOTE docker logs -f visiona" +echo "" +hint "Rollback hint:DOCKER_HOST=$DOCKER_REMOTE docker tag visiona:stage- visiona:stage && bash scripts/deploy-stage-v2.sh --skip-build" +echo "" diff --git a/visionA-frontend/package.json b/visionA-frontend/package.json index ef6eacc..1e3d73f 100644 --- a/visionA-frontend/package.json +++ b/visionA-frontend/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "description": "visionA Cloud 前端(Phase 0 雛形骨架)", + "packageManager": "pnpm@10.30.1", "scripts": { "dev": "next dev", "build": "next build", diff --git a/visionA-frontend/pnpm-workspace.yaml b/visionA-frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..a656351 --- /dev/null +++ b/visionA-frontend/pnpm-workspace.yaml @@ -0,0 +1,25 @@ +# pnpm 10 設定(不是 monorepo workspace、純粹用來放 onlyBuiltDependencies) +# +# 為什麼有此檔: +# pnpm 10 預設不執行依賴的 install 階段 build script(postinstall / preinstall) +# 作為 supply-chain 攻擊防護。需要明確 allow-list 哪些 package 可以跑 build script。 +# +# 本機 install 不會撞是因為 ~/.config/pnpm 或 node_modules 殘留 trust; +# 但乾淨環境(如 stage docker build / CI)第一次 pnpm install 就會 ERR_PNPM_IGNORED_BUILDS。 +# +# 為什麼這幾個 package 需要: +# - sharp@0.34.5 — next.js image optimization、native libvips binding,install 時 prebuild download +# - unrs-resolver@1.11.1 — eslint-config-next 用的 rust binary resolver +# - @tailwindcss/oxide — tailwindcss 4 的 rust 引擎 +# - esbuild — vitest / next 內部建置工具,install 時下載對應平台 binary +# +# 如何加新項目: +# 1. CI 撞 ERR_PNPM_IGNORED_BUILDS 時看訊息列出的 package 名 +# 2. 確認該 package 來源可信(npmjs / github 看 popularity) +# 3. 加到下方 onlyBuiltDependencies 並 commit + +onlyBuiltDependencies: + - sharp + - unrs-resolver + - "@tailwindcss/oxide" + - esbuild