chore(deploy): containerize server deployment
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.githooks
|
||||||
|
.next
|
||||||
|
.pnpm-store
|
||||||
|
.vercel
|
||||||
|
.vscode
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
env-example
|
||||||
|
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
logs
|
||||||
|
archives
|
||||||
|
pictures
|
||||||
|
|
||||||
|
*.log
|
||||||
|
*.pem
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
docs/nextauth
|
||||||
|
e2e
|
||||||
|
trigger-debug.txt
|
||||||
+103
-120
@@ -1,19 +1,21 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Post-receive hook: push 后自动 build + start
|
# Post-receive hook: push 后自动构建 Docker 镜像并重启容器
|
||||||
|
|
||||||
# pnpm 环境变量
|
# pnpm / node 环境仍保留,便于服务器缺少 Docker 时做人工排查。
|
||||||
export NVM_DIR="$HOME/.nvm"
|
export NVM_DIR="$HOME/.nvm"
|
||||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
|
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||||
|
|
||||||
# 全局变量统一声明。具体值由各步骤函数写入。
|
|
||||||
REPO_TOPLEVEL=""
|
REPO_TOPLEVEL=""
|
||||||
LOG_FILE=""
|
LOG_FILE=""
|
||||||
CURRENT_BRANCH=""
|
CURRENT_BRANCH=""
|
||||||
|
DEPLOY_ENV=""
|
||||||
|
ENV_FILE=""
|
||||||
START_PORT=""
|
START_PORT=""
|
||||||
BUILD_EXIT=0
|
IMAGE_TAG=""
|
||||||
STOP_WAIT_SECONDS=3
|
CONTAINER_NAME=""
|
||||||
STOP_VERIFY_RETRIES=3
|
COMPOSE_PROJECT_NAME=""
|
||||||
|
COMPOSE_EXIT=0
|
||||||
|
|
||||||
resolve_repo_context() {
|
resolve_repo_context() {
|
||||||
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null)"
|
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null)"
|
||||||
@@ -44,8 +46,8 @@ write_metadata_header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sync_worktree() {
|
sync_worktree() {
|
||||||
echo "=== sync_worktree: git reset --hard HEAD @ $(date +%Y-%m-%dT%H:%M:%S%z) ==="
|
echo "=== sync_worktree: git reset --hard HEAD @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
git reset --hard HEAD
|
git reset --hard HEAD >> "$LOG_FILE" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
copy_env_by_branch() {
|
copy_env_by_branch() {
|
||||||
@@ -54,67 +56,86 @@ copy_env_by_branch() {
|
|||||||
case "$CURRENT_BRANCH" in
|
case "$CURRENT_BRANCH" in
|
||||||
main)
|
main)
|
||||||
cp -f env-example/.env.production.example .env.production
|
cp -f env-example/.env.production.example .env.production
|
||||||
|
ENV_FILE=".env.production"
|
||||||
|
DEPLOY_ENV="prod"
|
||||||
|
START_PORT=9185
|
||||||
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.production (main → prod) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
test)
|
test)
|
||||||
cp -f env-example/.env.local.example .env.local
|
cp -f env-example/.env.local.example .env.local
|
||||||
|
ENV_FILE=".env.local"
|
||||||
|
DEPLOY_ENV="test"
|
||||||
|
START_PORT=9135
|
||||||
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.local (test → test) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
dev)
|
dev)
|
||||||
cp -f env-example/.env.development.example .env.local
|
cp -f env-example/.env.development.example .env.local
|
||||||
|
ENV_FILE=".env.local"
|
||||||
|
DEPLOY_ENV="dev"
|
||||||
|
START_PORT=9135
|
||||||
echo "=== env: copied .env.local (dev → dev) ===" >> "$LOG_FILE"
|
echo "=== env: copied .env.local (dev → dev) ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "=== env: UNKNOWN branch '$CURRENT_BRANCH', skip env copy ===" >> "$LOG_FILE"
|
ENV_FILE=".env.local"
|
||||||
|
DEPLOY_ENV="$CURRENT_BRANCH"
|
||||||
|
START_PORT=9135
|
||||||
|
echo "=== env: UNKNOWN branch '$CURRENT_BRANCH', use defaults ===" >> "$LOG_FILE"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
prepare_compose_env() {
|
||||||
# 4. select_port_by_branch —— 根据分支选择 next start 端口
|
SHORT_SHA="$(git rev-parse --short HEAD)"
|
||||||
# main 分支 → 9185
|
IMAGE_TAG="cozsweet-web:${DEPLOY_ENV}-${SHORT_SHA}"
|
||||||
# test 分支 → 9135
|
CONTAINER_NAME="cozsweet-web-${DEPLOY_ENV}"
|
||||||
# dev 分支 → 9135
|
COMPOSE_PROJECT_NAME="cozsweet-web-${DEPLOY_ENV}"
|
||||||
# ============================================================
|
|
||||||
select_port_by_branch() {
|
export COZSWEET_IMAGE="$IMAGE_TAG"
|
||||||
case "$CURRENT_BRANCH" in
|
export COZSWEET_CONTAINER_NAME="$CONTAINER_NAME"
|
||||||
main)
|
export COZSWEET_ENV_FILE="$ENV_FILE"
|
||||||
START_PORT=9185
|
export COZSWEET_HOST_PORT="$START_PORT"
|
||||||
;;
|
export COMPOSE_PROJECT_NAME
|
||||||
test|dev)
|
export DOCKER_BUILDKIT=1
|
||||||
START_PORT=9135
|
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||||
;;
|
|
||||||
*)
|
{
|
||||||
START_PORT=9135
|
echo "=== deploy env: $DEPLOY_ENV ==="
|
||||||
;;
|
echo "=== env file: $COZSWEET_ENV_FILE ==="
|
||||||
esac
|
echo "=== image: $COZSWEET_IMAGE ==="
|
||||||
export START_PORT
|
echo "=== container: $COZSWEET_CONTAINER_NAME ==="
|
||||||
echo "=== start port: $START_PORT ===" >> "$LOG_FILE"
|
echo "=== host port: $COZSWEET_HOST_PORT ==="
|
||||||
|
echo "=== compose project: $COMPOSE_PROJECT_NAME ==="
|
||||||
|
echo ""
|
||||||
|
} >> "$LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
docker_compose() {
|
||||||
# 5. run_build —— 跑 `pnpm install` + `pnpm run build`,输出丢,捕获退出码写日志
|
if docker compose version >/dev/null 2>&1; then
|
||||||
# 返回:build 退出码(main 用 if ! run_build 决定是否继续)
|
docker compose "$@"
|
||||||
# ============================================================
|
return $?
|
||||||
run_build() {
|
fi
|
||||||
echo "=== build START @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
|
||||||
pnpm run build:deploy > /dev/null 2>&1
|
if command -v docker-compose >/dev/null 2>&1; then
|
||||||
BUILD_EXIT=$?
|
docker-compose "$@"
|
||||||
echo "=== build EXIT_CODE=$BUILD_EXIT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== docker compose command not found ===" >> "$LOG_FILE"
|
||||||
|
return 127
|
||||||
|
}
|
||||||
|
|
||||||
|
run_container_build() {
|
||||||
|
echo "=== docker build START @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
|
docker_compose build web >> "$LOG_FILE" 2>&1
|
||||||
|
COMPOSE_EXIT=$?
|
||||||
|
echo "=== docker build EXIT_CODE=$COMPOSE_EXIT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
echo "" >> "$LOG_FILE"
|
echo "" >> "$LOG_FILE"
|
||||||
return $BUILD_EXIT
|
return $COMPOSE_EXIT
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 6. stop_existing_next —— 强杀当前分支端口上的旧进程
|
|
||||||
# 1) 使用 ss 查找占用 START_PORT 的进程
|
|
||||||
# 2) 直接 SIGKILL,避免旧服务 drain 太久导致新服务无法绑定端口
|
|
||||||
# 3) 等待 STOP_WAIT_SECONDS,让系统释放端口
|
|
||||||
# 4) 循环复查端口占用并再次强杀残留
|
|
||||||
# ============================================================
|
|
||||||
find_port_pids() {
|
find_port_pids() {
|
||||||
if ! command -v ss >/dev/null 2>&1; then
|
if ! command -v ss >/dev/null 2>&1; then
|
||||||
echo "=== ss command not found; cannot inspect port $START_PORT ===" >> "$LOG_FILE"
|
echo "=== ss command not found; cannot inspect legacy port $START_PORT ===" >> "$LOG_FILE"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -124,99 +145,61 @@ find_port_pids() {
|
|||||||
| sort -u
|
| sort -u
|
||||||
}
|
}
|
||||||
|
|
||||||
force_kill_pids() {
|
container_exists() {
|
||||||
PIDS_TO_KILL="$1"
|
docker ps -a --format '{{.Names}}' 2>/dev/null | grep -Fx "$CONTAINER_NAME" >/dev/null 2>&1
|
||||||
if [ -z "$PIDS_TO_KILL" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "=== force kill PIDS: $PIDS_TO_KILL ===" >> "$LOG_FILE"
|
|
||||||
kill -9 $PIDS_TO_KILL 2>/dev/null || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_port_release() {
|
stop_legacy_next_if_needed() {
|
||||||
RETRY=0
|
if container_exists; then
|
||||||
while [ "$RETRY" -lt "$STOP_VERIFY_RETRIES" ]; do
|
echo "=== existing container found; skip legacy port cleanup ===" >> "$LOG_FILE"
|
||||||
REMAINING_PIDS="$(find_port_pids)"
|
|
||||||
if [ -z "$REMAINING_PIDS" ]; then
|
|
||||||
echo "=== port $START_PORT released ===" >> "$LOG_FILE"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== port $START_PORT still busy, retry=$RETRY, pids=$REMAINING_PIDS ===" >> "$LOG_FILE"
|
|
||||||
force_kill_pids "$REMAINING_PIDS"
|
|
||||||
sleep "$STOP_WAIT_SECONDS"
|
|
||||||
RETRY=$((RETRY + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
REMAINING_PIDS="$(find_port_pids)"
|
|
||||||
if [ -n "$REMAINING_PIDS" ]; then
|
|
||||||
echo "=== stop FAILED: port $START_PORT still occupied by PIDS: $REMAINING_PIDS ===" >> "$LOG_FILE"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== port $START_PORT released ===" >> "$LOG_FILE"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_existing_next() {
|
|
||||||
echo "=== stop existing next on port $START_PORT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
|
||||||
PIDS="$(find_port_pids)"
|
PIDS="$(find_port_pids)"
|
||||||
|
if [ -z "$PIDS" ]; then
|
||||||
if [ -n "$PIDS" ]; then
|
echo "=== no legacy process found on port $START_PORT ===" >> "$LOG_FILE"
|
||||||
force_kill_pids "$PIDS"
|
|
||||||
else
|
|
||||||
echo "=== no process found on port $START_PORT ===" >> "$LOG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep "$STOP_WAIT_SECONDS"
|
|
||||||
|
|
||||||
if ! wait_for_port_release; then
|
|
||||||
echo "=== stop ABORT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
|
||||||
echo "" >> "$LOG_FILE"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== stop DONE @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
|
||||||
echo "" >> "$LOG_FILE"
|
|
||||||
return 0
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== first container deploy: kill legacy PIDS on port $START_PORT: $PIDS ===" >> "$LOG_FILE"
|
||||||
|
kill -9 $PIDS 2>/dev/null || true
|
||||||
|
sleep 3
|
||||||
}
|
}
|
||||||
|
|
||||||
launch_next() {
|
launch_container() {
|
||||||
echo "=== start LAUNCH port=$START_PORT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
echo "=== docker compose up START @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
PORT="$START_PORT" nohup pnpm run start >> "$LOG_FILE" 2>&1 &
|
stop_legacy_next_if_needed
|
||||||
START_PID=$!
|
docker_compose up -d --remove-orphans web >> "$LOG_FILE" 2>&1
|
||||||
echo "=== start PID=$START_PID @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
COMPOSE_EXIT=$?
|
||||||
|
echo "=== docker compose up EXIT_CODE=$COMPOSE_EXIT @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
|
echo "" >> "$LOG_FILE"
|
||||||
|
return $COMPOSE_EXIT
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 8. log_abort —— build 失败时写结束标记(不输出 START LAUNCH)
|
|
||||||
# ============================================================
|
|
||||||
log_abort() {
|
log_abort() {
|
||||||
echo "=== ABORTED @ $(date +%Y-%m-%dT%H:%M:%S%z) (build failed, start skipped) ===" >> "$LOG_FILE"
|
echo "=== ABORTED @ $(date +%Y-%m-%dT%H:%M:%S%z) ($1) ===" >> "$LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# main —— 调度所有步骤
|
|
||||||
# ============================================================
|
|
||||||
main() {
|
main() {
|
||||||
resolve_repo_context
|
resolve_repo_context
|
||||||
init_log_file
|
init_log_file
|
||||||
write_metadata_header # 先覆盖写元信息(这样 env copy 日志能保留在后面)
|
write_metadata_header
|
||||||
|
|
||||||
sync_worktree # 先同步工作树到新 HEAD(与 git config denyCurrentBranch 配合)
|
sync_worktree
|
||||||
copy_env_by_branch # 追加 env copy 日志
|
copy_env_by_branch
|
||||||
|
prepare_compose_env
|
||||||
|
|
||||||
select_port_by_branch # 根据当前分支决定 next start 端口
|
if ! run_container_build; then
|
||||||
|
log_abort "docker build failed, old container kept running"
|
||||||
if ! run_build; then
|
exit $COMPOSE_EXIT
|
||||||
log_abort
|
|
||||||
exit $BUILD_EXIT # build 失败 → 不启 start(避免用旧 .next 跑新代码)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! stop_existing_next; then
|
if ! launch_container; then
|
||||||
exit 1 # 端口未释放 → 不启动新服务,避免 EADDRINUSE
|
log_abort "docker compose up failed"
|
||||||
|
exit $COMPOSE_EXIT
|
||||||
fi
|
fi
|
||||||
launch_next # 再启新
|
|
||||||
|
echo "=== DEPLOY DONE @ $(date +%Y-%m-%dT%H:%M:%S%z) ===" >> "$LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PNPM_HOME=/pnpm
|
||||||
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat \
|
||||||
|
&& corepack enable \
|
||||||
|
&& corepack prepare pnpm@10.30.3 --activate
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
ARG NEXT_ENV_FILE=.env.local
|
||||||
|
ENV CI=true
|
||||||
|
ENV SENTRY_UPLOAD_SOURCEMAPS=0
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN --mount=type=secret,id=next_env,target=/tmp/next_env,required=false \
|
||||||
|
if [ -f /tmp/next_env ]; then cp /tmp/next_env "$NEXT_ENV_FILE"; fi; \
|
||||||
|
pnpm run build; \
|
||||||
|
rm -f .env .env.local .env.development .env.production
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ${COZSWEET_IMAGE:-cozsweet-web:local}
|
||||||
|
container_name: ${COZSWEET_CONTAINER_NAME:-cozsweet-web}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_ENV_FILE: ${COZSWEET_ENV_FILE:-.env.local}
|
||||||
|
secrets:
|
||||||
|
- source: next_env
|
||||||
|
target: next_env
|
||||||
|
env_file:
|
||||||
|
- ${COZSWEET_ENV_FILE:-.env.local}
|
||||||
|
environment:
|
||||||
|
HOSTNAME: 0.0.0.0
|
||||||
|
NODE_ENV: production
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
PORT: "3000"
|
||||||
|
ports:
|
||||||
|
- "${COZSWEET_HOST_PORT:-9135}:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"require('node:http').get('http://127.0.0.1:3000', r => process.exit(r.statusCode < 500 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
next_env:
|
||||||
|
file: ${COZSWEET_ENV_FILE:-.env.local}
|
||||||
+62
-39
@@ -1,15 +1,15 @@
|
|||||||
# 自动构建与自动重启流程
|
# 自动构建与自动重启流程
|
||||||
|
|
||||||
本文档说明当前项目通过 Git remote + `post-receive` hook 实现自动构建、自动重启的流程。核心文件为 [.githooks/post-receive](../.githooks/post-receive)。
|
本文档说明当前项目通过 Git remote + `post-receive` hook 实现自动构建、自动重启的流程。当前服务器端运行方式已经容器化,核心文件为 [.githooks/post-receive](../.githooks/post-receive)、[Dockerfile](../Dockerfile) 和 [docker-compose.yml](../docker-compose.yml)。
|
||||||
|
|
||||||
## 部署入口
|
## 部署入口
|
||||||
|
|
||||||
当前涉及两个服务器 remote:
|
当前涉及两个服务器 remote:
|
||||||
|
|
||||||
| 环境 | Git remote | 推送分支 | 服务端端口 | 部署脚本 |
|
| 环境 | Git remote | 推送分支 | 宿主机端口 | 容器端口 | 部署脚本 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 测试环境 | `test` | `test` | `9135` | `scripts/deploy/deploy_web_test.sh` |
|
| 测试环境 | `test` | `test` | `9135` | `3000` | `scripts/deploy/deploy_web_test.sh` |
|
||||||
| 生产环境 | `production` | `main` | `9185` | `scripts/deploy/deploy_web.sh` |
|
| 生产环境 | `production` | `main` | `9185` | `3000` | `scripts/deploy/deploy_web.sh` |
|
||||||
|
|
||||||
当前 remote 配置示例:
|
当前 remote 配置示例:
|
||||||
|
|
||||||
@@ -73,43 +73,57 @@ production root@43.106.13.130:/root/cozsweet-repos/main
|
|||||||
5. 执行 `git reset --hard HEAD`,让服务端工作树同步到刚推送的最新 commit。
|
5. 执行 `git reset --hard HEAD`,让服务端工作树同步到刚推送的最新 commit。
|
||||||
6. 根据当前分支复制环境变量文件。
|
6. 根据当前分支复制环境变量文件。
|
||||||
7. 根据当前分支选择 Next.js 启动端口。
|
7. 根据当前分支选择 Next.js 启动端口。
|
||||||
8. 执行 `pnpm run build:deploy`。
|
8. 根据分支导出 Docker Compose 所需变量,包括镜像名、容器名、env 文件和端口。
|
||||||
9. 如果构建失败,写入失败日志并中止,不会重启服务。
|
9. 执行 `docker compose build web` 构建环境专属镜像。
|
||||||
10. 如果构建成功,查找并强杀当前端口上的旧 Next.js 进程。
|
10. 如果镜像构建失败,写入失败日志并中止,旧容器继续运行。
|
||||||
11. 等待端口释放,并重试确认。
|
11. 如果镜像构建成功,执行 `docker compose up -d --remove-orphans web` 重建并启动容器。
|
||||||
12. 端口释放成功后,执行 `nohup pnpm run start` 后台启动新服务。
|
12. 第一次从裸机 `next start` 迁移到容器时,如果目标端口上没有同名容器但存在旧进程,会清理旧进程后再启动容器。
|
||||||
|
|
||||||
## 分支、环境变量、端口映射
|
## 分支、环境变量、端口映射
|
||||||
|
|
||||||
`post-receive` 当前按服务端工作树所在分支判断环境:
|
`post-receive` 当前按服务端工作树所在分支判断环境:
|
||||||
|
|
||||||
| 服务端分支 | 环境变量复制 | 启动端口 |
|
| 服务端分支 | 环境变量复制 | 宿主机端口 | 容器名 | 镜像标签 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` |
|
| `main` | `env-example/.env.production.example` → `.env.production` | `9185` | `cozsweet-web-prod` | `cozsweet-web:prod-<commit>` |
|
||||||
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` |
|
| `test` | `env-example/.env.local.example` → `.env.local` | `9135` | `cozsweet-web-test` | `cozsweet-web:test-<commit>` |
|
||||||
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` |
|
| `dev` | `env-example/.env.development.example` → `.env.local` | `9135` | `cozsweet-web-dev` | `cozsweet-web:dev-<commit>` |
|
||||||
| 其他分支 | 跳过 env 复制 | `9135` |
|
| 其他分支 | 使用默认 `.env.local` | `9135` | `cozsweet-web-<branch>` | `cozsweet-web:<branch>-<commit>` |
|
||||||
|
|
||||||
本地生产发布脚本和服务器端 `post-receive` 已统一使用 `.env.production`。Next.js 在生产构建时会读取 `.env.production`;测试和开发发布仍使用 `.env.local`。
|
本地生产发布脚本和服务器端 `post-receive` 已统一使用 `.env.production`。Next.js 在生产构建时会读取 `.env.production`;测试和开发发布仍使用 `.env.local`。
|
||||||
|
|
||||||
本地部署脚本清除 Cloudflare CDN 缓存时,也会按当前分支读取 env:`main` 优先读取 `.env.production`,其他分支优先读取 `.env.local`。如果优先文件不存在或缺少 `CF_ZONE_ID` / `CF_API_TOKEN`,会尝试读取另一个文件作为兜底。
|
本地部署脚本清除 Cloudflare CDN 缓存时,也会按当前分支读取 env:`main` 优先读取 `.env.production`,其他分支优先读取 `.env.local`。如果优先文件不存在或缺少 `CF_ZONE_ID` / `CF_API_TOKEN`,会尝试读取另一个文件作为兜底。
|
||||||
|
|
||||||
|
## 容器构建机制
|
||||||
|
|
||||||
|
Docker 构建使用 Next.js standalone 输出:
|
||||||
|
|
||||||
|
1. `next.config.ts` 设置 `output: "standalone"`。
|
||||||
|
2. `Dockerfile` 使用 multi-stage build。
|
||||||
|
3. build 阶段通过 Docker BuildKit secret 挂载 `.env.local` 或 `.env.production`。
|
||||||
|
4. build 完成后删除临时 env 文件,避免 env 文件被复制到最终镜像。
|
||||||
|
5. runtime 阶段只复制 `.next/standalone`、`.next/static` 和 `public`。
|
||||||
|
6. 容器通过 `node server.js` 启动,不再依赖完整 `node_modules` 或 `next start`。
|
||||||
|
|
||||||
|
注意:`NEXT_PUBLIC_*` 变量会在 `next build` 时固化到前端 bundle,因此测试环境和生产环境必须分别构建镜像,不能共用同一个镜像。
|
||||||
|
|
||||||
## 自动重启机制
|
## 自动重启机制
|
||||||
|
|
||||||
重启由 `post-receive` 中的端口进程管理实现:
|
重启由 Docker Compose 实现:
|
||||||
|
|
||||||
1. 使用 `ss -tulnp` 查询占用目标端口的进程。
|
1. `post-receive` 先执行 `docker compose build web`。
|
||||||
2. 如果找到旧进程,执行 `kill -9` 强制停止。
|
2. 构建失败时直接中止,旧容器继续运行。
|
||||||
3. 默认等待 `3` 秒。
|
3. 构建成功后执行:
|
||||||
4. 最多重试 `3` 次确认端口已经释放。
|
|
||||||
5. 如果端口仍未释放,中止启动,避免新服务出现 `EADDRINUSE`。
|
|
||||||
6. 如果端口释放成功,执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PORT="$START_PORT" nohup pnpm run start >> "$LOG_FILE" 2>&1 &
|
docker compose up -d --remove-orphans web
|
||||||
```
|
```
|
||||||
|
|
||||||
`package.json` 中的 `start` 脚本会优先使用传入的 `PORT`,因此测试环境固定使用 `9135`,生产环境固定使用 `9185`。
|
4. Compose 会使用新镜像重建服务容器。
|
||||||
|
5. 容器配置了 `restart: unless-stopped`,容器异常退出后由 Docker 自动拉起。
|
||||||
|
6. 容器内固定监听 `3000`,宿主机端口按分支映射为 `9135` 或 `9185`。
|
||||||
|
|
||||||
|
首次迁移时,如果宿主机端口仍被旧的裸机 `next start` 进程占用,hook 会在构建成功后、启动容器前清理旧进程。后续如果同名容器已经存在,则不再手动清理端口,由 Docker Compose 管理容器替换。
|
||||||
|
|
||||||
## 日志与排查
|
## 日志与排查
|
||||||
|
|
||||||
@@ -127,7 +141,14 @@ logs/post-receive.log
|
|||||||
# 查看最近一次部署日志
|
# 查看最近一次部署日志
|
||||||
tail -n 200 logs/post-receive.log
|
tail -n 200 logs/post-receive.log
|
||||||
|
|
||||||
# 查看端口占用
|
# 查看容器状态
|
||||||
|
docker ps --filter 'name=cozsweet-web'
|
||||||
|
|
||||||
|
# 查看容器日志
|
||||||
|
docker logs --tail=200 cozsweet-web-test
|
||||||
|
docker logs --tail=200 cozsweet-web-prod
|
||||||
|
|
||||||
|
# 查看宿主机端口占用
|
||||||
ss -tulnp | grep ':9135'
|
ss -tulnp | grep ':9135'
|
||||||
ss -tulnp | grep ':9185'
|
ss -tulnp | grep ':9185'
|
||||||
|
|
||||||
@@ -136,22 +157,23 @@ git branch --show-current
|
|||||||
git rev-parse --short HEAD
|
git rev-parse --short HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
构建失败时,日志中会出现:
|
镜像构建失败时,日志中会出现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
build EXIT_CODE=<非 0>
|
docker build EXIT_CODE=<非 0>
|
||||||
ABORTED ... (build failed, start skipped)
|
ABORTED ... (docker build failed, old container kept running)
|
||||||
```
|
```
|
||||||
|
|
||||||
这种情况下不会停止旧服务,也不会启动新服务。
|
这种情况下不会停止旧容器,也不会启动新容器。
|
||||||
|
|
||||||
端口释放失败时,日志中会出现:
|
容器启动失败时,日志中会出现:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
stop FAILED: port <端口> still occupied
|
docker compose up EXIT_CODE=<非 0>
|
||||||
|
ABORTED ... (docker compose up failed)
|
||||||
```
|
```
|
||||||
|
|
||||||
这种情况下不会启动新服务,需要人工检查占用该端口的进程。
|
这种情况下需要人工查看 `docker compose ps`、`docker logs <container>` 和宿主机端口占用。
|
||||||
|
|
||||||
## 关键约束
|
## 关键约束
|
||||||
|
|
||||||
@@ -159,12 +181,13 @@ stop FAILED: port <端口> still occupied
|
|||||||
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
2. 自动构建和自动重启只发生在服务器端 `post-receive` hook。
|
||||||
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
3. `main` 分支对应生产环境,`test` 分支对应测试环境。
|
||||||
4. 生产推送当前使用 `git push --force production main`。
|
4. 生产推送当前使用 `git push --force production main`。
|
||||||
5. 构建失败时不会启动新服务,避免用旧 `.next` 跑新代码。
|
5. 构建失败时不会重启容器,旧容器继续运行。
|
||||||
6. 服务启动使用 `nohup` 后台进程,不依赖 PM2 或 systemd。
|
6. 服务启动由 Docker Compose 管理,不再使用 `nohup pnpm run start`。
|
||||||
|
7. 服务器需要安装 Docker,并支持 `docker compose` 或旧版 `docker-compose` 命令。
|
||||||
|
|
||||||
## 后续优化建议
|
## 后续优化建议
|
||||||
|
|
||||||
1. 将服务进程交给 PM2 或 systemd 管理,提升崩溃自动恢复能力。
|
1. 保留最近 N 次部署日志,而不是只保留最后一次日志,方便排查历史问题。
|
||||||
2. 保留最近 N 次部署日志,而不是只保留最后一次日志,方便排查历史问题。
|
2. 将镜像推送到私有镜像仓库,服务器只负责拉取和启动镜像,减少服务器构建压力。
|
||||||
3. 构建命令可以保留完整输出到单独日志文件,当前 `pnpm run build:deploy` 输出被丢弃,只记录退出码。
|
3. 增加部署后健康检查,确认新容器真正可访问后再判定部署成功。
|
||||||
4. 可以在启动后增加健康检查,确认新服务真正可访问后再判定部署成功。
|
4. 增加旧镜像清理策略,避免服务器磁盘被历史镜像占满。
|
||||||
|
|||||||
+4
-5
@@ -7,13 +7,12 @@ import { withSerwist } from "@serwist/turbopack";
|
|||||||
* 标记根 layout 不参与静态预渲染(global-error 会在请求时渲染)
|
* 标记根 layout 不参与静态预渲染(global-error 会在请求时渲染)
|
||||||
* https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
|
* https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
|
||||||
*
|
*
|
||||||
* 默认部署模型:`.next/` + `public/` + `next start`(或 Vercel 零配置)
|
* 容器化部署模型:`output: "standalone"` + Docker Compose。
|
||||||
* - `pnpm build` 产出 .next/(含 build manifest / chunks / 路由表 / 静态预渲染)
|
* - `pnpm build` 产出 .next/standalone、.next/static、public。
|
||||||
* - 脚本侧:scripts/deploy/_deploy_lib.sh 同步 .next/ + public/
|
* - Dockerfile 运行 `node server.js`,不再依赖 `next start` 和完整 node_modules。
|
||||||
* - 服务器启动:`cd <path> && next start -H 0.0.0.0 -p 3000`
|
|
||||||
* 或用 PM2:`pm2 start "next start" --name cozsweet`
|
|
||||||
*/
|
*/
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
experimental: {
|
experimental: {
|
||||||
// 关闭静态优化以规避 16.2.7 global-error prerender bug
|
// 关闭静态优化以规避 16.2.7 global-error prerender bug
|
||||||
staticGenerationRetryCount: 0,
|
staticGenerationRetryCount: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user