#!/usr/bin/env bash # # visionA — Stage Deploy(save/load 模式,仿 edge-ai-platform/scripts/deploy-docker.sh 早期版本) # # 為什麼不用 internal registry: # 公司 192.168.0.130:5000 registry 開了 auth(401 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. healthcheck:curl 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 # rollback 到指定 timestamp tag(須仍存在 stage docker) # bash scripts/deploy-stage.sh --no-push # 只 build 不 save/load(local 驗證用) # 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 留在本地 daemon,docker-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 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 only,stage 部署允許 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 開了 auth,dev 端無帳密 → 改走 docker save/load # Phase 1 若拿到 registry 帳密,可改回 buildx --push + remote docker pull(grep "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 docker(save | 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 target(rollback mode only)" # rollback mode:把 stage docker 上既存的 timestamp tag 改名成 :stage(save/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 "緊急救回 POC(visionA 部 fail 時):" hint " DOCKER_HOST=$DOCKER_REMOTE docker stop visiona" hint " DOCKER_HOST=$DOCKER_REMOTE docker start edge-ai-platform" echo ""