visionA/scripts/deploy-stage.sh
jim800121chen eb66a7287a feat(deploy): visionA Cloud dev / stage docker compose + Caddy/nginx + 部署腳本
新增雲端版部署設定(Phase 0.6 dev + Phase 0.7 stage 分兩套):

dev 環境(docker-compose.dev.yml):
- 5 service all-in-one(postgres + member-center + visionA-backend + frontend + Caddy)
- Caddy 自動 HTTPS for localhost
- .env.dev.example 範本(使用者拷出 .env.dev 後 docker compose up -d)
- Makefile dev-with-mc 9 個 target

stage 環境(docker-compose.stage.yml + docker/Dockerfile.stage):
- multi-stage build(node22 frontend + go1.26 backend × 2 + nginx-alpine runtime)
  最終 image 319 MB,含 nginx + nodejs + tini + bash
- entrypoint.stage.sh 4 process 共命運(nginx + api-server + remote-proxy +
  next.js standalone)用 wait -n + SIGTERM trap
- nginx.stage.conf:白名單 server_name stage-9527.innovedus.com + 444 default_server
  + /healthz 例外(127.0.0.0/8 only)+ /api/ 與 /storage/ 強制 no-store
  + /tunnel/connect WS upgrade + 100M body / 3600s timeout
- 對外 mapping 0.0.0.0:9527:80(公司 host nginx 在外層處理 HTTPS termination
  — Let's Encrypt stage-9527.innovedus.com 自動續簽)
- named volume visiona-data(不用 bind mount,因 stage docker daemon 在 host root
  無 mkdir 權限)

部署腳本(scripts/deploy-stage.sh):
- 仿 edge-ai-platform/scripts/deploy-docker.sh 早期 save/load 模式
- 為什麼不用 internal registry:公司 192.168.0.130:5000 開了 auth、無帳密
- 流程:buildx --load → docker save | gzip → DOCKER_HOST docker load → compose up
- 含 --rollback <tag> / --skip-build / --no-push / --skip-deploy 選項
- timestamp + git SHA tag 留 rollback 餘地

文件(docs/):
- DEV-SETUP.md:dev 環境一鍵起步驟
- SMOKE-TEST.md:手動煙測 checklist(OIDC flow / pairing / tunnel)
- STAGE-DEPLOY.md:stage 完整手冊(架構圖 / 環境前置 / 部署 step / rollback /
  7 種故障排除 / 緊急救回 POC)

.env.stage.example 對齊 backend A1 改造:
- VISIONA_OIDC_CLIENT_SECRET 留空(PKCE-only public client)
- VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET 留空(Phase 1 預留鉤子)
- 所有 secret 用 placeholder(CHANGE_ME_OPENSSL_RAND_HEX_32)

.dockerignore:避免 node_modules / .next / .git 等進 build context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:44 +08:00

278 lines
11 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# visionA — Stage Deploysave/load 模式,仿 edge-ai-platform/scripts/deploy-docker.sh 早期版本)
#
# 為什麼不用 internal registry
# 公司 192.168.0.130:5000 registry 開了 auth401 UNAUTHORIZED且 visionA dev 端沒帳密。
# 2026-05-01 改走 buildx --load → docker save | gzip → DOCKER_HOST docker load 模式,
# 完全不依賴 registry。Phase 1 拿到 registry 帳密後可改回 push 模式grep 本檔的 SAVE_LOAD 區塊)。
#
# 流程:
# 0. 前置檢查docker buildx、git status、必要工具
# 1. buildx build linux/amd64 --load 到本機 docker
# 2. docker save | gzip > /tmp/visiona-stage.tar.gz
# 3. DOCKER_HOST=tcp://192.168.0.130:2375 docker load < /tmp/visiona-stage.tar.gz
# 4. DOCKER_HOST=... docker compose up -d
# 5. healthcheckcurl https://stage-9527.innovedus.com:9527/healthz
#
# 用法:
# bash scripts/deploy-stage.sh # build + deploy
# bash scripts/deploy-stage.sh --skip-build # 不重 build直接 redeploy 已 load 的 :stage
# bash scripts/deploy-stage.sh --rollback <tag> # rollback 到指定 timestamp tag須仍存在 stage docker
# bash scripts/deploy-stage.sh --no-push # 只 build 不 save/loadlocal 驗證用)
# bash scripts/deploy-stage.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 設定(以使用者 2026-05-01 提供值為準)────────────
DOCKER_REMOTE="${DOCKER_HOST:-tcp://192.168.0.130:2375}"
IMAGE_NAME="visiona"
IMAGE_TAG="stage"
# 不再用 internal registry — image 透過 docker save/load 直接搬到 stage docker
# 沒有 REGISTRY prefix 時 docker 會把 image 留在本地 daemondocker-compose 也以此為基準
IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}"
SAVE_TARBALL="/tmp/${IMAGE_NAME}-${IMAGE_TAG}.tar.gz"
STAGE_HOST_PATH="/opt/visiona" # stage host 上 compose file + .env 放置目錄
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
Usage:
bash scripts/deploy-stage.sh [OPTIONS]
Options:
--skip-build Skip buildx step, redeploy existing :stage tag
--no-push Build only, do not push to internal registry (local validation)
--rollback <tag> Rollback to specified timestamp tag (e.g. stage-20260501-153045)
--skip-deploy Build & push only, do not run remote compose
--help Show this help
Environment:
DOCKER_HOST Remote Docker daemon (default: tcp://192.168.0.130:2375)
Examples:
bash scripts/deploy-stage.sh
bash scripts/deploy-stage.sh --skip-build
bash scripts/deploy-stage.sh --rollback stage-20260501-153045
bash scripts/deploy-stage.sh --no-push # local Dockerfile 驗證
After deployment, browse:
https://stage-9527.innovedus.com:9527/
HELP
}
# ──────────── parse args ────────────
SKIP_BUILD=false
NO_PUSH=false
SKIP_DEPLOY=false
ROLLBACK_TAG=""
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=true; shift ;;
--no-push) NO_PUSH=true; shift ;;
--skip-deploy) SKIP_DEPLOY=true; shift ;;
--rollback) ROLLBACK_TAG="$2"; shift 2 ;;
--help|-h) show_help; exit 0 ;;
*) error "Unknown option: $1 (use --help)" ;;
esac
done
# rollback mode跳過 build直接 deploy 指定 tag
if [ -n "$ROLLBACK_TAG" ]; then
SKIP_BUILD=true
NO_PUSH=true
info "Rollback mode: deploying $REGISTRY/$IMAGE_NAME:$ROLLBACK_TAG"
fi
# ──────────── pre-flight ────────────
step "0/5 Pre-flight checks"
command -v docker >/dev/null 2>&1 || error "docker 未安裝"
docker buildx version >/dev/null 2>&1 || error "docker buildx 未安裝macOS: 內建Linux: docker-buildx-plugin"
if [ "$SKIP_BUILD" = false ]; then
[ -f "$DOCKERFILE" ] || error "找不到 Dockerfile.stage$DOCKERFILE"
fi
[ -f "$COMPOSE_FILE" ] || error "找不到 docker-compose.stage.yml$COMPOSE_FILE"
# git 檢查warn onlystage 部署允許 dirty
if [ "$SKIP_BUILD" = false ] && [ -z "${ROLLBACK_TAG}" ]; then
cd "$PROJECT_ROOT"
if ! git diff-index --quiet HEAD -- 2>/dev/null; then
warn "git working tree 有未 commit 的變更stage build 仍會繼續"
warn "建議先 commit否則 image 與 git SHA 對不上難以追蹤"
if [ -t 0 ]; then
read -r -p "繼續嗎? [y/N] " yn
[[ "$yn" =~ ^[Yy]$ ]] || error "取消(請先 commit 或加 --skip-build"
else
warn "non-interactive shell — 自動繼續"
fi
fi
fi
GIT_SHA="$(cd "$PROJECT_ROOT" && 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}"
info "Project root : $PROJECT_ROOT"
info "Docker remote : $DOCKER_REMOTE"
info "Image : $IMAGE_REF"
[ -n "$ROLLBACK_TAG" ] && info "Rollback tag : ${IMAGE_NAME}:${ROLLBACK_TAG}"
[ -z "$ROLLBACK_TAG" ] && info "Timestamp tag : ${IMAGE_NAME}:${TIMESTAMPED_TAG}"
info "Git SHA : $GIT_SHA"
# ──────────── SAVE_LOAD 區塊build → save → load ────────────
# 為什麼這樣做:公司 internal registry 開了 authdev 端無帳密 → 改走 docker save/load
# Phase 1 若拿到 registry 帳密,可改回 buildx --push + remote docker pullgrep "SAVE_LOAD"
if [ "$SKIP_BUILD" = false ]; then
step "1/5 Build (buildx, linux/amd64, --load 進本機 daemon)"
BUILDX_FLAGS=(
--platform linux/amd64
-f "$DOCKERFILE"
-t "${IMAGE_NAME}:${IMAGE_TAG}"
-t "${IMAGE_NAME}:${TIMESTAMPED_TAG}"
--load
)
cd "$PROJECT_ROOT"
docker buildx build "${BUILDX_FLAGS[@]}" .
info "build 完成:本機已有 ${IMAGE_NAME}:${IMAGE_TAG}${IMAGE_NAME}:${TIMESTAMPED_TAG}"
if [ "$NO_PUSH" = false ]; then
step "2/5 Save image → 傳到 stage dockersave | load 模式)"
info "docker save | gzip → ${SAVE_TARBALL}(含 :${IMAGE_TAG} + :${TIMESTAMPED_TAG} 兩個 tag"
# 同時 save 兩個 tag — load 後 stage docker 會兩個都有,方便 rollback
docker save "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:${TIMESTAMPED_TAG}" \
| gzip > "$SAVE_TARBALL"
SIZE=$(du -h "$SAVE_TARBALL" | awk '{print $1}')
info "tarball 產出:$SIZE @ $SAVE_TARBALL"
info "DOCKER_HOST=$DOCKER_REMOTE docker load透過 TCP 傳輸到 stage daemon..."
gunzip -c "$SAVE_TARBALL" | DOCKER_HOST="$DOCKER_REMOTE" docker load
info "stage docker daemon 已 load 完成"
# 清掉 tarball內含完整 image 內容)— 不該留在 dev 機 /tmp
rm -f "$SAVE_TARBALL"
info "清除本機 tarball$SAVE_TARBALL"
else
info "skip step 2/5--no-push"
fi
else
info "skip 1/5, 2/5--skip-build"
fi
# ──────────── deploy ────────────
if [ "$SKIP_DEPLOY" = true ] || [ "$NO_PUSH" = true ]; then
info "skip 3/5, 4/5, 5/5--skip-deploy 或 --no-push"
info "下一步:在 stage host 上手動執行"
hint " ssh stage-host"
hint " cd $STAGE_HOST_PATH"
hint " docker compose -f docker-compose.stage.yml up -d"
exit 0
fi
step "3/5 Tag rollback targetrollback mode only"
# rollback mode把 stage docker 上既存的 timestamp tag 改名成 :stagesave/load 模式不需要 push
if [ -n "$ROLLBACK_TAG" ]; then
info "rollback把 stage docker 上的 ${IMAGE_NAME}:${ROLLBACK_TAG} retag 為 ${IMAGE_NAME}:${IMAGE_TAG}"
if ! DOCKER_HOST="$DOCKER_REMOTE" docker image inspect "${IMAGE_NAME}:${ROLLBACK_TAG}" >/dev/null 2>&1; then
error "rollback tag 不存在於 stage docker${IMAGE_NAME}:${ROLLBACK_TAG}
可用 tag list 查看DOCKER_HOST=$DOCKER_REMOTE docker images $IMAGE_NAME"
fi
DOCKER_HOST="$DOCKER_REMOTE" docker tag \
"${IMAGE_NAME}:${ROLLBACK_TAG}" \
"${IMAGE_NAME}:${IMAGE_TAG}"
info "retag 完成"
else
info "skip 3/5非 rollback mode"
fi
step "4/5 Deploy via docker compose"
# 假設 stage host 上 /opt/visiona/ 已有 docker-compose.stage.yml + .env.stage
# (首次部署需手動 scp 上去;見 docs/STAGE-DEPLOY.md
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 "5/5 Verify"
# 5a. container 狀態
DOCKER_HOST="$DOCKER_REMOTE" docker ps --filter name=visiona --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# 5b. 等 healthcheck 變 healthy最多 60 秒)
deadline=$(( $(date +%s) + 60 ))
healthy=false
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"
warn "查看 log: DOCKER_HOST=$DOCKER_REMOTE docker logs visiona --tail 100"
fi
# 5c. 對外 URL probe公司 host nginx → container
if curl -fsS --max-time 10 "$HEALTHZ_URL" >/dev/null 2>&1; then
info "對外 healthz ✓ ($HEALTHZ_URL)"
else
warn "對外 healthz 失敗 — 可能公司 host nginx 還沒接好 :9527 → container"
warn "請檢查公司 IT 那邊 stage-9527.innovedus.com 反代設定"
fi
echo ""
echo -e "${GREEN}=== Deploy 完成 ===${NC}"
echo ""
info "URL : https://${STAGE_DOMAIN}:${STAGE_PORT}/"
info "Logs: DOCKER_HOST=$DOCKER_REMOTE docker logs -f visiona"
echo ""
hint "Rollback hint"
hint " bash scripts/deploy-stage.sh --rollback ${TIMESTAMPED_TAG:-<舊 tag>}"
hint ""
hint "緊急救回 POCvisionA 部 fail 時):"
hint " DOCKER_HOST=$DOCKER_REMOTE docker stop visiona"
hint " DOCKER_HOST=$DOCKER_REMOTE docker start edge-ai-platform"
echo ""