建立开发机镜像构建与标准发布流程 (#1)
This commit is contained in:
parent
7dbcacc3cb
commit
57fcd366f3
@ -1,4 +1,8 @@
|
||||
.env
|
||||
*.pem
|
||||
*.tar
|
||||
artifacts
|
||||
runtime
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
@ -11,6 +15,8 @@ storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
tests
|
||||
test-data
|
||||
docs
|
||||
scripts
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -102,3 +102,7 @@ modules/*
|
||||
!modules/PaypalStandard/**
|
||||
.laravelstatsrc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.tar
|
||||
/artifacts/
|
||||
/runtime/
|
||||
|
||||
50
AGENTS.md
50
AGENTS.md
@ -11,7 +11,7 @@
|
||||
|
||||
## 2. 分支与提交
|
||||
|
||||
- `main` 是稳定主线,禁止直接推送、禁止强推、禁止直接修改。
|
||||
- `main` 是稳定主线,默认禁止直接推送、禁止强推、禁止直接修改。项目负责人 `WuYongJie`,以及得到其明确授权的 Codex,可以直接合并 PR 或直接推送 `main`;任何身份仍禁止强推。
|
||||
- 每项任务都必须从最新 `main` 新建分支;不得长期共用或复用旧任务分支。
|
||||
- 分支命名:新功能用 `feature/用户名-任务名`,修复用 `fix/用户名-问题名`,紧急修复用 `hotfix/用户名-问题名`。
|
||||
- 示例:`feature/dodo-login`、`fix/wangshu-amount-rounding`。
|
||||
@ -27,7 +27,7 @@
|
||||
4. 要求 AI 先说明改动计划、涉及文件、风险和验证步骤;确认后再修改。
|
||||
5. 修改后查看 diff,并要求 AI 用普通语言解释“改了什么、为什么、怎样测试”。
|
||||
6. 在本地或测试环境完成最小有效验证,记录实际结果。
|
||||
7. 推送任务分支并发起 PR;不得自行绕过 PR 合并到 `main`。
|
||||
7. 推送任务分支并发起 PR;新人不得自行绕过 PR 合并到 `main`。项目负责人和其明确授权的 Codex 按上面的例外执行。
|
||||
|
||||
推荐向 AI 提问的模板:
|
||||
|
||||
@ -42,9 +42,9 @@
|
||||
|
||||
## 4. PR 与合并
|
||||
|
||||
- 所有任务分支必须通过 PR 合并到 `main`。
|
||||
- 新人和普通协作者的任务分支必须通过 PR 合并到 `main`;项目负责人和其明确授权的 Codex 可以作为例外直接合并或推送。
|
||||
- PR 作者必须填写:做了什么、Codex 改了哪些文件、怎样测试、是否涉及金额/权限/数据库/配置。
|
||||
- PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。
|
||||
- 新人提交的 PR 至少由另一位成员检查后才能批准;作者不得把“AI 已完成”当作测试结果。项目负责人和其明确授权的 Codex 不受这条审核人数限制,但仍必须完成实际验证并如实记录结果。
|
||||
- 涉及金额计算、权限、导入、数据库迁移、账户或系统配置时,必须在 PR 中明确标注,并由负责人最终确认。
|
||||
- 使用仓库默认的 Squash merge(压缩合并)。合并由负责人或指定合并人执行。
|
||||
- PR 合并后分支自动删除;不要把已合并分支继续用于新任务。
|
||||
@ -70,3 +70,45 @@
|
||||
- 数据库迁移:新人不得自行在共享环境执行;先在 PR 说明影响、验证方式和回滚思路。
|
||||
- 未完成验证时,PR 必须明确写“未验证”及原因,不能写“应该没问题”。
|
||||
|
||||
## 8. 固定构建与部署规则
|
||||
|
||||
### 8.1 职责边界
|
||||
|
||||
- 项目负责人只负责产品方向、验收标准和高风险授权,不负责日常构建、上传、部署命令和服务器维护操作。
|
||||
- 新人负责描述任务、检查结果和发起 PR;Codex 负责按本节流程完成环境检查、构建、部署、验证和收口,不得把一串命令丢给项目负责人执行。
|
||||
- 正式发布只允许使用已合并的 `main`。非 `main` 只能用于明确授权的测试发布。
|
||||
- 生产数据库迁移、数据修复、删除、覆盖配置等高风险动作,仍需项目负责人明确授权。
|
||||
|
||||
### 8.2 服务器红线
|
||||
|
||||
- 当前服务器只有 2GB 内存,只负责运行容器、健康检查和镜像切换。
|
||||
- 严禁在服务器执行 `docker build`、`docker compose build`、`npm install`、`npm run production`、`composer install` 或其他源码构建命令。
|
||||
- `compose.jiangling.yml` 不得出现 `build:`;服务器只能运行开发机已经构建并上传的 `caiwu_akaunting:<commit>` 镜像。
|
||||
- 服务器保留当前镜像和上一个可回滚镜像,其他本项目旧镜像在发布成功后清理。
|
||||
- 服务器发布必须使用互斥锁;同一时间只允许一个人切换版本,禁止并发覆盖。
|
||||
|
||||
### 8.3 开发机固定流程
|
||||
|
||||
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,不得手工拼接构建、打包、上传和启动命令。
|
||||
- 镜像标签使用 Git 提交 SHA;工作区有未提交的受控文件时,脚本必须拒绝发布。
|
||||
- 使用项目专属 BuildKit 构建器 `caiwu-builder`。禁止日常使用 `--no-cache`、`--pull` 或删除全部 Docker 数据。
|
||||
- Dockerfile 必须先复制锁文件再安装依赖:只有 `composer.lock`、`package-lock.json` 或基础镜像定义变化时,才允许对应依赖层失效。
|
||||
- 基础镜像、Composer 下载缓存、npm 下载缓存和未变化的构建层应直接复用,不得每次全量下载。
|
||||
|
||||
### 8.4 磁盘和缓存上限
|
||||
|
||||
- Docker Desktop 为本项目构建至少分配 8GB 内存,虚拟磁盘上限建议设为 60GB;宿主机至少保留 15GB 可用空间。
|
||||
- 发布脚本使用独立缓存并将 BuildKit 缓存控制在 8GB 内,只清理 7 天前的本项目缓存。
|
||||
- 开发机和服务器默认各保留 2 个本项目镜像;上传用临时 `image.tar` 无论成功失败都必须删除。
|
||||
- 禁止脚本自动执行全局 `docker system prune -a`、删除其他项目镜像、删除卷或重置 Docker Desktop。
|
||||
- Docker 虚拟磁盘文件不会因为普通清理立即缩小;日常目标是控制已用空间不再增长,不得让新人自行删除或重建虚拟磁盘。
|
||||
|
||||
### 8.5 发布安全与验收
|
||||
|
||||
- SSH 私钥、`runtime/.env`、镜像临时包、数据库密码不得进入 Git 或 Docker 构建上下文。
|
||||
- 首次服务器初始化由脚本生成数据库密码和 `APP_KEY`,不在终端、聊天或文档中输出。
|
||||
- 数据库迁移默认关闭;检测到新增迁移时发布必须停止,获得负责人授权后才可显式开启。
|
||||
- 新镜像必须通过真实 HTTP 页面健康检查后才算发布成功;失败时自动恢复上一个镜像,并保留日志用于排查。
|
||||
- 发布完成必须报告:分支、提交 SHA、镜像标签、服务器容器状态、真实页面结果、是否执行迁移、清理结果。
|
||||
|
||||
详细 SOP 见 `docs/开发与部署SOP.md`。
|
||||
|
||||
@ -1,22 +1,45 @@
|
||||
# 服务器只运行本机已加载的镜像,禁止在服务器执行 docker compose build。
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: jiangling_akaunting:local
|
||||
container_name: jiangling_app
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
pull_policy: missing
|
||||
container_name: caiwu_db
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
env_file:
|
||||
- ./runtime/.env
|
||||
volumes:
|
||||
- caiwu_database:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
|
||||
app:
|
||||
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
pull_policy: never
|
||||
container_name: caiwu_app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
|
||||
ports:
|
||||
- "80:18088"
|
||||
volumes:
|
||||
- ./runtime/.env:/var/www/html/.env
|
||||
- jiangling_storage:/var/www/html/storage
|
||||
- caiwu_storage:/var/www/html/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/auth/login"]
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:18088/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
|
||||
volumes:
|
||||
jiangling_storage:
|
||||
name: jiangling_storage
|
||||
caiwu_database:
|
||||
name: caiwu_database
|
||||
caiwu_storage:
|
||||
name: caiwu_storage
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
FROM php:8.3-apache-bookworm
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM php:8.3-apache-bookworm AS php-base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@ -28,14 +30,57 @@ RUN apt-get update \
|
||||
&& a2enmod expires headers rewrite \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM php-base AS vendor
|
||||
|
||||
COPY --from=composer:2.8 /usr/bin/composer /usr/local/bin/composer
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
RUN --mount=type=cache,target=/tmp/composer-cache \
|
||||
COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \
|
||||
--prefer-dist \
|
||||
--no-dev \
|
||||
--no-interaction \
|
||||
--no-progress \
|
||||
--no-scripts \
|
||||
--optimize-autoloader
|
||||
|
||||
FROM node:20-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN NODE_OPTIONS=--max-old-space-size=4096 npm run production
|
||||
|
||||
FROM php-base AS runtime
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
LABEL com.caiwu.project="CaiWuXiTong" \
|
||||
org.opencontainers.image.revision="$VCS_REF" \
|
||||
org.opencontainers.image.created="$BUILD_DATE"
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY . /var/www/html
|
||||
COPY --from=vendor /build/vendor /var/www/html/vendor
|
||||
COPY --from=assets /build/public/js /var/www/html/public/js
|
||||
COPY --from=assets /build/public/css /var/www/html/public/css
|
||||
COPY --from=assets /build/public/mix-manifest.json /var/www/html/public/mix-manifest.json
|
||||
COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
|
||||
COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini
|
||||
COPY deploy/entrypoint.sh /usr/local/bin/jiangling-entrypoint
|
||||
COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini
|
||||
COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/jiangling-entrypoint \
|
||||
RUN chmod +x /usr/local/bin/caiwu-entrypoint \
|
||||
&& mkdir -p \
|
||||
storage/app/public \
|
||||
storage/app/temp \
|
||||
@ -47,6 +92,5 @@ RUN chmod +x /usr/local/bin/jiangling-entrypoint \
|
||||
bootstrap/cache \
|
||||
&& chown -R www-data:www-data storage bootstrap/cache
|
||||
|
||||
ENTRYPOINT ["jiangling-entrypoint"]
|
||||
ENTRYPOINT ["caiwu-entrypoint"]
|
||||
CMD ["apache2-foreground"]
|
||||
|
||||
|
||||
94
deploy/activate-release.sh
Normal file
94
deploy/activate-release.sh
Normal file
@ -0,0 +1,94 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
IMAGE_ARCHIVE="${1:?IMAGE_ARCHIVE is required}"
|
||||
IMAGE_TAG="${2:?IMAGE_TAG is required}"
|
||||
REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "activate-release.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
echo "Missing $REMOTE_DIR/runtime/.env; run bootstrap-server.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "Another CaiWuXiTong deployment is already running" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
|
||||
docker load --input "$IMAGE_ARCHIVE"
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
|
||||
LOADED_SHA="$(docker image inspect "$IMAGE_NAME:$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')"
|
||||
if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
echo "Image revision mismatch: expected $DEPLOY_SHA, got $LOADED_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d --remove-orphans
|
||||
|
||||
healthy=0
|
||||
attempt=1
|
||||
while [ "$attempt" -le 30 ]; do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1/; then
|
||||
healthy=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ "$healthy" -ne 1 ]; then
|
||||
docker compose -f compose.jiangling.yml logs --tail 120 app >&2 || true
|
||||
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
"$IMAGE_NAME":*)
|
||||
IMAGE_TAG="${PREVIOUS_IMAGE#*:}"
|
||||
RUN_MIGRATIONS=0
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
docker compose -f compose.jiangling.yml up -d app || true
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Release failed health check; previous image was restored when available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.deployed-sha.tmp"
|
||||
mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha"
|
||||
|
||||
docker image ls "$IMAGE_NAME" --format '{{.Repository}}:{{.Tag}}' \
|
||||
| awk '!seen[$0]++' \
|
||||
| tail -n "+$((KEEP_IMAGES + 1))" \
|
||||
| while IFS= read -r old_image; do
|
||||
[ -n "$old_image" ] && docker image rm "$old_image" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
docker image prune -f --filter 'label=com.caiwu.project=CaiWuXiTong' >/dev/null || true
|
||||
docker compose -f compose.jiangling.yml ps
|
||||
echo "DEPLOYED_SHA=$DEPLOY_SHA"
|
||||
|
||||
# Keep the variable for rollback diagnostics without printing any secret.
|
||||
: "$PREVIOUS_SHA"
|
||||
99
deploy/bootstrap-server.sh
Normal file
99
deploy/bootstrap-server.sh
Normal file
@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
APP_URL="${1:?APP_URL is required}"
|
||||
REMOTE_DIR="${2:-/opt/caiwuxitong}"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "bootstrap-server.sh must run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl openssl
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [ ! -f /etc/apt/keyrings/docker.asc ]; then
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
|
||||
|
||||
{
|
||||
printf '%s\n' \
|
||||
'Types: deb' \
|
||||
'URIs: https://download.docker.com/linux/ubuntu' \
|
||||
"Suites: $CODENAME" \
|
||||
'Components: stable' \
|
||||
"Architectures: $ARCH" \
|
||||
'Signed-By: /etc/apt/keyrings/docker.asc'
|
||||
} > /etc/apt/sources.list.d/docker.sources
|
||||
|
||||
apt-get update
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
apt-get install -y --no-install-recommends docker-buildx-plugin docker-compose-plugin
|
||||
else
|
||||
apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
systemctl enable --now docker
|
||||
|
||||
if ! swapon --show=NAME --noheadings | grep -q .; then
|
||||
if [ ! -f /swapfile ]; then
|
||||
fallocate -l 2G /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile >/dev/null
|
||||
fi
|
||||
|
||||
swapon /swapfile
|
||||
|
||||
if ! grep -q '^/swapfile ' /etc/fstab; then
|
||||
printf '%s\n' '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||
fi
|
||||
fi
|
||||
|
||||
install -d -m 700 "$REMOTE_DIR/runtime"
|
||||
|
||||
if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then
|
||||
DB_PASSWORD="$(openssl rand -hex 24)"
|
||||
DB_ROOT_PASSWORD="$(openssl rand -hex 24)"
|
||||
APP_KEY="$(openssl rand -base64 32 | tr -d '\n')"
|
||||
|
||||
umask 077
|
||||
{
|
||||
printf '%s\n' \
|
||||
'APP_NAME=CaiWuXiTong' \
|
||||
'APP_ENV=production' \
|
||||
"APP_KEY=base64:$APP_KEY" \
|
||||
'APP_DEBUG=false' \
|
||||
'APP_INSTALLED=false' \
|
||||
"APP_URL=$APP_URL" \
|
||||
'APP_LOCALE=zh-CN' \
|
||||
'APP_FALLBACK_LOCALE=en-GB' \
|
||||
'APP_TIMEZONE=Asia/Shanghai' \
|
||||
'LOG_CHANNEL=stack' \
|
||||
'DB_CONNECTION=mysql' \
|
||||
'DB_HOST=db' \
|
||||
'DB_PORT=3306' \
|
||||
'DB_DATABASE=caiwu' \
|
||||
'DB_USERNAME=caiwu' \
|
||||
"DB_PASSWORD=$DB_PASSWORD" \
|
||||
'DB_PREFIX=ak_' \
|
||||
'CACHE_DRIVER=file' \
|
||||
'SESSION_DRIVER=file' \
|
||||
'QUEUE_CONNECTION=sync' \
|
||||
'MARIADB_DATABASE=caiwu' \
|
||||
'MARIADB_USER=caiwu' \
|
||||
"MARIADB_PASSWORD=$DB_PASSWORD" \
|
||||
"MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \
|
||||
'MARIADB_AUTO_UPGRADE=1'
|
||||
} > "$REMOTE_DIR/runtime/.env"
|
||||
chmod 600 "$REMOTE_DIR/runtime/.env"
|
||||
fi
|
||||
|
||||
docker --version
|
||||
docker compose version
|
||||
echo "Server runtime is ready: $REMOTE_DIR"
|
||||
@ -14,16 +14,23 @@ mkdir -p \
|
||||
chown -R www-data:www-data storage bootstrap/cache
|
||||
|
||||
if [ -f .env ]; then
|
||||
chown root:www-data .env
|
||||
chmod 640 .env
|
||||
chown www-data:www-data .env
|
||||
chmod 660 .env
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "apache2-foreground" ]; then
|
||||
sed -ri 's/^Listen 80$/Listen 18088/' /etc/apache2/ports.conf
|
||||
php artisan optimize:clear
|
||||
php artisan migrate --force
|
||||
php artisan config:cache
|
||||
php artisan view:cache
|
||||
php artisan package:discover --ansi
|
||||
|
||||
if grep -Eq '^APP_INSTALLED=(true|1)$' .env; then
|
||||
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
|
||||
php artisan migrate --force
|
||||
fi
|
||||
|
||||
php artisan config:cache
|
||||
php artisan view:cache
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
135
docs/开发与部署SOP.md
Normal file
135
docs/开发与部署SOP.md
Normal file
@ -0,0 +1,135 @@
|
||||
# CaiWuXiTong 开发与部署 SOP
|
||||
|
||||
## 1. 这套流程解决什么问题
|
||||
|
||||
本项目由 6 位新人使用 Codex 协作开发。项目负责人只确认方向、验收结果和高风险动作,不负责日常命令操作。
|
||||
|
||||
固定链路如下:
|
||||
|
||||
```text
|
||||
任务分支开发 -> 本机验证 -> PR -> 合并 main
|
||||
-> 开发机复用缓存构建镜像
|
||||
-> 临时导出并上传镜像
|
||||
-> 2GB 服务器只加载和切换镜像
|
||||
-> 真实页面健康检查
|
||||
-> 保留当前与上一个镜像,清理其余本项目产物
|
||||
```
|
||||
|
||||
服务器永远不构建源码。
|
||||
|
||||
## 2. 每台开发机只做一次的设置
|
||||
|
||||
1. 安装并启动 Docker Desktop,使用 WSL2 后端。
|
||||
2. 在 Docker Desktop 的 `Settings -> Resources -> Advanced` 中:
|
||||
- 构建可用内存至少 8GB;
|
||||
- Docker 虚拟磁盘上限建议 60GB;
|
||||
- Windows 磁盘至少保留 15GB 可用空间。
|
||||
3. SSH 私钥放到个人目录,例如 `C:\Users\你的用户名\.ssh\caiwu.pem`,不要放进 Git。
|
||||
4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。
|
||||
|
||||
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
|
||||
|
||||
## 3. 为什么后续不会反复全量下载
|
||||
|
||||
项目使用专属构建器 `caiwu-builder`,缓存与其他项目分开:
|
||||
|
||||
- PHP 扩展层只在 Dockerfile 的基础环境变化时重建;
|
||||
- Composer 只在 `composer.lock` 变化时重新解析依赖,并复用下载缓存;
|
||||
- npm 只在 `package-lock.json` 变化时重新安装依赖,并复用下载缓存;
|
||||
- 业务代码变化只重新执行后面的代码复制和资源构建;
|
||||
- 日常命令不带 `--pull` 和 `--no-cache`。
|
||||
|
||||
首次构建必然较慢;后续构建应大量显示 `CACHED`。
|
||||
|
||||
## 4. 首次初始化服务器
|
||||
|
||||
首次发布由 Codex 或当班开发者在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
|
||||
-BootstrapServer
|
||||
```
|
||||
|
||||
初始化脚本会:
|
||||
|
||||
- 安装并启动 Docker 与 Compose;
|
||||
- 为 2GB 服务器增加 2GB swap(交换空间),仅用于运行时缓冲;
|
||||
- 创建 `/opt/caiwuxitong/runtime/.env`;
|
||||
- 在服务器本地生成数据库密码和 Laravel `APP_KEY`,不会打印出来;
|
||||
- 创建持久化数据库卷和上传文件卷。
|
||||
|
||||
首次打开 `http://118.196.84.79` 后,由负责人或指定人员在网页安装向导中设置管理员账号和密码。
|
||||
|
||||
## 5. 日常正式发布
|
||||
|
||||
前提:PR 已审核并合并,当前为干净的 `main`。
|
||||
|
||||
```powershell
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem"
|
||||
```
|
||||
|
||||
脚本自动完成:
|
||||
|
||||
1. 检查分支和未提交改动;
|
||||
2. 使用提交 SHA 构建镜像;
|
||||
3. 复用项目专属缓存;
|
||||
4. 导出临时镜像包并上传;
|
||||
5. 服务器加载镜像并启动;
|
||||
6. 检查真实 HTTP 页面;
|
||||
7. 失败时恢复上一个镜像;
|
||||
8. 使用服务器互斥锁阻止两个人同时切换版本;
|
||||
9. 删除本地和服务器临时镜像包;
|
||||
10. 两端仅保留当前和上一个本项目镜像;
|
||||
11. 清理 7 天前的本项目构建缓存,并将总量压到最多 8GB。
|
||||
|
||||
## 6. 数据库迁移
|
||||
|
||||
默认禁止自动迁移。脚本发现上次部署后新增了 `database/migrations` 文件时会停止。
|
||||
|
||||
只有负责人明确批准、PR 已写清影响与回滚思路后,才允许:
|
||||
|
||||
```powershell
|
||||
.\scripts\deploy-from-workstation.ps1 `
|
||||
-Server 118.196.84.79 `
|
||||
-KeyPath "C:\Users\你的用户名\.ssh\caiwu.pem" `
|
||||
-AllowMigrations
|
||||
```
|
||||
|
||||
镜像回滚不能自动撤销已经执行的数据库结构变更,因此迁移授权必须单独处理。
|
||||
|
||||
## 7. 测试分支发布
|
||||
|
||||
正式服务器默认只允许 `main`。确需临时测试时,必须得到明确授权,并添加:
|
||||
|
||||
```powershell
|
||||
-AllowNonMain
|
||||
```
|
||||
|
||||
测试完成后仍要回到 `main` 再正式发布,不能把测试分支当长期生产版本。
|
||||
|
||||
## 8. 禁止事项
|
||||
|
||||
- 禁止登录服务器运行 `docker build` 或 `docker compose build`;
|
||||
- 禁止在服务器运行 Composer、npm 或前端打包;
|
||||
- 禁止把 `.pem`、`.env`、密码、数据库文件或 `image.tar` 提交到 Git;
|
||||
- 禁止为了省空间删除 Docker 卷;
|
||||
- 禁止全局清空 Docker,因为同一台电脑可能还有其他项目;
|
||||
- 禁止跳过健康检查后口头说“部署完成”。
|
||||
|
||||
## 9. 发布完成时应报告
|
||||
|
||||
- 发布分支和完整 Git SHA;
|
||||
- 镜像标签;
|
||||
- 服务器 `caiwu_app`、`caiwu_db` 状态;
|
||||
- `http://118.196.84.79` 的实际返回结果;
|
||||
- 是否执行数据库迁移;
|
||||
- 本地临时包是否删除;
|
||||
- 本地/服务器保留了几个本项目镜像;
|
||||
- BuildKit 缓存当前占用。
|
||||
185
scripts/deploy-from-workstation.ps1
Normal file
185
scripts/deploy-from-workstation.ps1
Normal file
@ -0,0 +1,185 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Server,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$KeyPath,
|
||||
|
||||
[string]$SshUser = 'root',
|
||||
[string]$RemotePath = '/opt/caiwuxitong',
|
||||
[string]$AppUrl,
|
||||
[string]$BuilderName = 'caiwu-builder',
|
||||
[int]$KeepImages = 2,
|
||||
[switch]$BootstrapServer,
|
||||
[switch]$AllowNonMain,
|
||||
[switch]$AllowMigrations
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$keyFullPath = (Resolve-Path $KeyPath).Path
|
||||
$sshTarget = "${SshUser}@${Server}"
|
||||
$imageName = 'caiwu_akaunting'
|
||||
$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N'))
|
||||
$localArchive = Join-Path $tempRoot 'image.tar'
|
||||
$remoteArchive = $null
|
||||
$bootstrapRemote = $null
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[scriptblock]$Command,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$FailureMessage
|
||||
)
|
||||
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw $FailureMessage
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OldProjectImages {
|
||||
param([string]$CurrentImage)
|
||||
|
||||
$projectImages = @(
|
||||
docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' |
|
||||
Where-Object { $_ -and $_ -ne '<none>:<none>' } |
|
||||
Select-Object -Unique
|
||||
)
|
||||
|
||||
$keep = @($CurrentImage)
|
||||
foreach ($candidate in $projectImages) {
|
||||
if ($keep.Count -ge $KeepImages) { break }
|
||||
if ($candidate -notin $keep) { $keep += $candidate }
|
||||
}
|
||||
|
||||
foreach ($candidate in $projectImages) {
|
||||
if ($candidate -notin $keep) {
|
||||
docker image rm $candidate 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
$branch = (git branch --show-current).Trim()
|
||||
if (-not $AllowNonMain -and $branch -ne 'main') {
|
||||
throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。"
|
||||
}
|
||||
|
||||
$trackedChanges = git status --porcelain --untracked-files=no
|
||||
if ($trackedChanges) {
|
||||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
}
|
||||
|
||||
$deploySha = (git rev-parse HEAD).Trim()
|
||||
$imageTag = $deploySha.Substring(0, 12)
|
||||
$fullImage = "${imageName}:$imageTag"
|
||||
$remoteArchive = "$RemotePath/image-$imageTag.tar"
|
||||
$buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
|
||||
|
||||
if (-not $AppUrl) {
|
||||
$AppUrl = "http://$Server"
|
||||
}
|
||||
|
||||
Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。'
|
||||
|
||||
docker buildx inspect $BuilderName 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Invoke-Native {
|
||||
docker buildx create --name $BuilderName --driver docker-container --use | Out-Null
|
||||
} '创建项目专属 BuildKit 构建器失败。'
|
||||
}
|
||||
|
||||
Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。'
|
||||
|
||||
Invoke-Native {
|
||||
docker buildx build `
|
||||
--builder $BuilderName `
|
||||
--load `
|
||||
--tag $fullImage `
|
||||
--build-arg "VCS_REF=$deploySha" `
|
||||
--build-arg "BUILD_DATE=$buildDate" `
|
||||
--label 'com.caiwu.project=CaiWuXiTong' `
|
||||
--file deploy/Dockerfile `
|
||||
.
|
||||
} '本机镜像构建失败。服务器未发生任何变更。'
|
||||
|
||||
$previousSha = (& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true").Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法连接服务器。'
|
||||
}
|
||||
|
||||
$previousShaExists = $false
|
||||
if ($previousSha) {
|
||||
git cat-file -e "${previousSha}^{commit}" 2>$null
|
||||
$previousShaExists = $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
|
||||
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。"
|
||||
}
|
||||
|
||||
if ($previousSha -and $previousShaExists) {
|
||||
$migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations
|
||||
if ($migrationChanges -and -not $AllowMigrations) {
|
||||
throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $tempRoot | Out-Null
|
||||
|
||||
if ($BootstrapServer) {
|
||||
$bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh"
|
||||
Invoke-Native {
|
||||
scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote"
|
||||
} '上传服务器初始化脚本失败。'
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'"
|
||||
} '服务器初始化失败。'
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||||
|
||||
Invoke-Native { docker save --output $localArchive $fullImage } '导出镜像失败。'
|
||||
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||||
Invoke-Native { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。'
|
||||
|
||||
$migrationFlag = if ($AllowMigrations) { '1' } else { '0' }
|
||||
Invoke-Native {
|
||||
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||||
} '服务器激活或健康检查失败。'
|
||||
|
||||
Remove-OldProjectImages -CurrentImage $fullImage
|
||||
Write-Host "发布完成:$fullImage -> $AppUrl"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
|
||||
if (Test-Path -LiteralPath $tempRoot) {
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force
|
||||
}
|
||||
|
||||
if ($remoteArchive) {
|
||||
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null
|
||||
}
|
||||
|
||||
if ($bootstrapRemote) {
|
||||
ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null
|
||||
}
|
||||
|
||||
$pruneHelp = docker buildx prune --help 2>$null | Out-String
|
||||
docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null
|
||||
if ($pruneHelp -match '--max-used-space') {
|
||||
docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null
|
||||
}
|
||||
elseif ($pruneHelp -match '--keep-storage') {
|
||||
docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null
|
||||
}
|
||||
|
||||
docker system df
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user