部署优化
This commit is contained in:
@@ -11,6 +11,11 @@ node_modules
|
||||
coverage
|
||||
.turbo
|
||||
**/*.tsbuildinfo
|
||||
release
|
||||
backups
|
||||
.deploy
|
||||
thebet365-images*.tar
|
||||
thebet365-images*.manifest.txt
|
||||
apps/player/dist
|
||||
apps/admin/dist
|
||||
apps/api/dist
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
# PostgreSQL(生产务必修改)
|
||||
POSTGRES_PASSWORD=thebet365
|
||||
|
||||
# 发布镜像版本;部署脚本使用 --tag 时会写回服务器上的 .env.docker
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# JWT(生产务必修改)
|
||||
JWT_SECRET=change-me-in-production-use-long-random-string
|
||||
JWT_PLAYER_EXPIRES=24h
|
||||
@@ -12,13 +15,19 @@ JWT_AGENT_EXPIRES=8h
|
||||
|
||||
# 首次部署如需写入默认数据,可临时改为 true;灌完后改回 false 并重启 api
|
||||
SEED_DATABASE=false
|
||||
# 迁移由 deploy-first/deploy-update 脚本执行;仅应急兼容时改为 true
|
||||
RUN_MIGRATIONS_ON_START=false
|
||||
# 可选:覆盖 admin 初始密码(仅 seed/重置时生效)
|
||||
# ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere
|
||||
|
||||
# 对外端口(宝塔/Nginx 反代推荐只暴露前端,API 经反向代理访问)
|
||||
BIND_ADDR=127.0.0.1
|
||||
PLAYER_PORT=8082
|
||||
ADMIN_PORT=8081
|
||||
|
||||
# 备份保留天数;留空表示不自动清理
|
||||
BACKUP_RETENTION_DAYS=
|
||||
|
||||
# API 安全开关
|
||||
# CORS_ORIGINS=https://player.example.com,https://admin.example.com
|
||||
ENABLE_SWAGGER=false
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,8 +3,11 @@ dist/
|
||||
.pnpm-store/
|
||||
release/
|
||||
backups/
|
||||
.deploy/
|
||||
docker-build.log
|
||||
thebet365-images.tar
|
||||
thebet365-images-*.tar
|
||||
thebet365-images-*.manifest.txt
|
||||
.claude/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -17,6 +17,7 @@ import { OperationsModule } from './domains/operations/operations.module';
|
||||
import { AdminModule } from './applications/admin/admin.module';
|
||||
import { PlayerModule } from './applications/player/player.module';
|
||||
import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
||||
import { HealthModule } from './applications/health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,6 +37,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
||||
AdminModule,
|
||||
PlayerModule,
|
||||
AgentPortalModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
|
||||
})
|
||||
|
||||
42
apps/api/src/applications/health/health.controller.spec.ts
Normal file
42
apps/api/src/applications/health/health.controller.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
describe('HealthController', () => {
|
||||
function createController(options?: { database?: boolean; redis?: boolean }) {
|
||||
const database = options?.database ?? true;
|
||||
const redis = options?.redis ?? true;
|
||||
const prisma = {
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
if (!database) throw new Error('database unavailable');
|
||||
return Promise.resolve([{ ok: 1 }]);
|
||||
}),
|
||||
};
|
||||
const redisService = {
|
||||
raw: {
|
||||
ping: jest.fn().mockImplementation(() => {
|
||||
if (!redis) throw new Error('redis unavailable');
|
||||
return Promise.resolve('PONG');
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return new HealthController(prisma as never, redisService as never);
|
||||
}
|
||||
|
||||
it('reports liveness without external checks', () => {
|
||||
expect(createController().live()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('reports readiness when database and redis respond', async () => {
|
||||
await expect(createController().ready()).resolves.toEqual({
|
||||
status: 'ok',
|
||||
checks: { database: 'ok', redis: 'ok' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails readiness when a dependency is unavailable', async () => {
|
||||
await expect(createController({ redis: false }).ready()).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
});
|
||||
60
apps/api/src/applications/health/health.controller.ts
Normal file
60
apps/api/src/applications/health/health.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Public } from '../../shared/common/decorators';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { RedisService } from '../../shared/redis/redis.service';
|
||||
|
||||
type CheckStatus = 'ok' | 'error';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get('live')
|
||||
live() {
|
||||
return { status: 'ok' as const };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('ready')
|
||||
async ready() {
|
||||
const checks: Record<'database' | 'redis', CheckStatus> = {
|
||||
database: 'ok',
|
||||
redis: 'ok',
|
||||
};
|
||||
|
||||
const [databaseReady, redisReady] = await Promise.all([
|
||||
this.checkDatabase(),
|
||||
this.checkRedis(),
|
||||
]);
|
||||
|
||||
if (!databaseReady) checks.database = 'error';
|
||||
if (!redisReady) checks.redis = 'error';
|
||||
|
||||
if (!databaseReady || !redisReady) {
|
||||
throw new ServiceUnavailableException({ status: 'error', checks });
|
||||
}
|
||||
|
||||
return { status: 'ok' as const, checks };
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRedis(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.redis.raw.ping()) === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/api/src/applications/health/health.module.ts
Normal file
7
apps/api/src/applications/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -95,6 +95,7 @@ function selectTab(tab: SlipMode) {
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
slip.closeDrawer();
|
||||
show.value = false;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
@@ -305,7 +306,7 @@ watch(
|
||||
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
|
||||
<h3>{{ t('bet.slip_review_title') }}</h3>
|
||||
</div>
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click="closeDrawer">
|
||||
<button type="button" class="close-btn" :aria-label="t('bet.cancel')" @click.stop="closeDrawer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -473,6 +474,9 @@ watch(
|
||||
}
|
||||
|
||||
.drawer-head {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
@@ -497,6 +501,9 @@ watch(
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
@@ -504,6 +511,8 @@ watch(
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.balance-bar {
|
||||
|
||||
@@ -36,19 +36,21 @@ services:
|
||||
- thebet365
|
||||
|
||||
api:
|
||||
image: thebet365-api:latest
|
||||
image: thebet365-api:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/api/Dockerfile
|
||||
container_name: thebet365-api
|
||||
env_file:
|
||||
- .env.docker
|
||||
- path: .env.docker
|
||||
required: false
|
||||
environment:
|
||||
DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365
|
||||
REDIS_URL: redis://redis:6379
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
UPLOAD_DIR: /app/uploads
|
||||
RUN_MIGRATIONS_ON_START: ${RUN_MIGRATIONS_ON_START:-false}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
@@ -58,34 +60,58 @@ services:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- '3000'
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
"node -e \"fetch('http://127.0.0.1:3000/api/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- thebet365
|
||||
|
||||
player:
|
||||
image: thebet365-player:latest
|
||||
image: thebet365-player:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/player/Dockerfile
|
||||
container_name: thebet365-player
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '${PLAYER_PORT:-8082}:80'
|
||||
- '${BIND_ADDR:-127.0.0.1}:${PLAYER_PORT:-8082}:80'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- thebet365
|
||||
|
||||
admin:
|
||||
image: thebet365-admin:latest
|
||||
image: thebet365-admin:${IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/admin/Dockerfile
|
||||
container_name: thebet365-admin
|
||||
depends_on:
|
||||
- api
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- '${ADMIN_PORT:-8081}:80'
|
||||
- '${BIND_ADDR:-127.0.0.1}:${ADMIN_PORT:-8081}:80'
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- thebet365
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
||||
COPY apps/player/package.json apps/player/
|
||||
COPY apps/admin/package.json apps/admin/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
||||
COPY apps/player/package.json apps/player/
|
||||
COPY apps/admin/package.json apps/admin/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
@@ -3,12 +3,16 @@ set -e
|
||||
|
||||
cd /app/apps/api
|
||||
|
||||
echo "[api] running migrations..."
|
||||
until npx prisma migrate deploy; do
|
||||
if [ "${RUN_MIGRATIONS_ON_START:-false}" = "true" ]; then
|
||||
echo "[api] running migrations..."
|
||||
until npx prisma migrate deploy; do
|
||||
echo "[api] waiting for database..."
|
||||
sleep 2
|
||||
done
|
||||
npx prisma generate
|
||||
done
|
||||
npx prisma generate
|
||||
else
|
||||
echo "[api] skipping startup migrations (RUN_MIGRATIONS_ON_START=false)"
|
||||
fi
|
||||
|
||||
if [ "$SEED_DATABASE" = "true" ]; then
|
||||
echo "[api] seeding database..."
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
||||
COPY apps/player/package.json apps/player/
|
||||
COPY apps/admin/package.json apps/admin/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
|
||||
```text
|
||||
浏览器
|
||||
├─ :8082 player (Nginx) ── /api、/uploads ──► api (NestJS :3000)
|
||||
└─ :8081 admin (Nginx) ── /api ────────────► api (NestJS :3000)
|
||||
└─ 宝塔/Nginx HTTPS 反代
|
||||
├─ 127.0.0.1:8082 player (Nginx) ── /api、/uploads ──► api (NestJS :3000)
|
||||
└─ 127.0.0.1:8081 admin (Nginx) ── /api、/uploads ──► api (NestJS :3000)
|
||||
|
||||
api ──► postgres:5432
|
||||
└──► redis:6379
|
||||
@@ -19,9 +20,9 @@ api ──► postgres:5432
|
||||
|
||||
| 容器 | 默认端口 | 说明 |
|
||||
|------|----------|------|
|
||||
| `thebet365-player` | 8082 | 玩家 H5 前台 |
|
||||
| `thebet365-admin` | 8081 | 管理后台(平台 + 代理) |
|
||||
| `thebet365-api` | 3000 | NestJS API / Swagger |
|
||||
| `thebet365-player` | 127.0.0.1:8082 | 玩家 H5 前台 |
|
||||
| `thebet365-admin` | 127.0.0.1:8081 | 管理后台(平台 + 代理) |
|
||||
| `thebet365-api` | 不对外暴露 | NestJS API / Swagger / 健康检查 |
|
||||
| `thebet365-postgres` | 不对外暴露 | PostgreSQL 16 |
|
||||
| `thebet365-redis` | 不对外暴露 | Redis 7 |
|
||||
|
||||
@@ -58,6 +59,10 @@ cp .env.docker.example .env.docker
|
||||
|
||||
- `POSTGRES_PASSWORD` — 数据库密码
|
||||
- `JWT_SECRET` — 足够长的随机字符串
|
||||
- `IMAGE_TAG=latest` — 首次部署可保留;后续用 `--tag` 发布时脚本会写回真实版本
|
||||
- `BIND_ADDR=127.0.0.1` — 默认只允许宝塔本机反代访问 player/admin 端口
|
||||
- `RUN_MIGRATIONS_ON_START=false` — 迁移由部署脚本执行,API 容器启动时不重复跑迁移
|
||||
- `BACKUP_RETENTION_DAYS=` — 留空表示不自动清理备份;填数字时脚本会清理更旧备份
|
||||
- `SEED_DATABASE=false` — 保持默认即可;首次部署脚本会在没有 `admin` 时一次性执行生产 seed
|
||||
|
||||
### 3. 首次部署
|
||||
@@ -81,12 +86,14 @@ chmod +x scripts/*.sh
|
||||
./scripts/deploy-first.sh --no-build
|
||||
```
|
||||
|
||||
如果上传的是 `thebet365-images.tar`,可让脚本自动加载镜像:
|
||||
如果上传的是版本化镜像包,可让脚本自动加载镜像并记录发布 tag:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-first.sh --images thebet365-images.tar
|
||||
./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
```
|
||||
|
||||
镜像包文件名符合 `thebet365-images-<tag>.tar` 时,脚本也会自动推断 tag;显式传 `--tag` 更清晰。
|
||||
|
||||
如需全量初始化生产数据(会清空业务表,仅限全新库或明确重置):
|
||||
|
||||
```bash
|
||||
@@ -104,10 +111,10 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 玩家前台 | http://服务器IP:8082 |
|
||||
| 管理后台 | http://服务器IP:8081 |
|
||||
| 玩家前台 | 宝塔绑定的玩家域名,或服务器本机 `http://127.0.0.1:8082` |
|
||||
| 管理后台 | 宝塔绑定的管理域名,或服务器本机 `http://127.0.0.1:8081` |
|
||||
|
||||
API 只在 Docker 网络内暴露,前端容器通过 `/api` 代理到 API。外层域名反代由宝塔网站配置处理。
|
||||
API 只在 Docker 网络内暴露,前端容器通过 `/api` 代理到 API。player/admin 默认只绑定 `127.0.0.1`,外层域名和 HTTPS 由宝塔网站反代处理。临时需要公网 IP 直连时,才在 `.env.docker` 中设置 `BIND_ADDR=0.0.0.0`。
|
||||
|
||||
---
|
||||
|
||||
@@ -171,6 +178,9 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build a
|
||||
# 手动备份数据库
|
||||
./scripts/backup-db.sh
|
||||
|
||||
# 完整生产备份:数据库 + uploads
|
||||
./scripts/backup-prod.sh --prefix pre-release
|
||||
|
||||
# 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/)
|
||||
CONFIRM=YES ./scripts/prod-init-db.sh
|
||||
# Windows PowerShell:
|
||||
@@ -178,6 +188,9 @@ CONFIRM=YES ./scripts/prod-init-db.sh
|
||||
|
||||
# 查看 API 日志
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api
|
||||
|
||||
# 回滚应用镜像到指定 tag(不自动回滚数据库)
|
||||
./scripts/rollback.sh --to v1.2.2
|
||||
```
|
||||
|
||||
根目录 `package.json` 快捷脚本(需已存在 `.env.docker`):
|
||||
@@ -197,21 +210,27 @@ pnpm docker:ps
|
||||
|------|------|
|
||||
| `postgres_data` | 数据库 |
|
||||
| `redis_data` | Redis |
|
||||
| `uploads_data` | Banner 等用户上传文件 |
|
||||
| `uploads_data` | Banner、充值截图、支付二维码等用户上传文件 |
|
||||
|
||||
备份 PostgreSQL 示例:
|
||||
备份示例:
|
||||
|
||||
```bash
|
||||
# 仅数据库:生成 backups/thebet365-db-*.sql.gz 和 .sha256
|
||||
./scripts/backup-db.sh
|
||||
|
||||
# 数据库 + uploads:生成 .sql.gz、.tar.gz 和对应 .sha256
|
||||
./scripts/backup-prod.sh --prefix manual
|
||||
```
|
||||
|
||||
`BACKUP_RETENTION_DAYS` 留空时不自动删除历史备份;设置为数字时,部署和备份脚本会清理更早的备份文件。
|
||||
|
||||
---
|
||||
|
||||
## 八、后续更新部署
|
||||
|
||||
**推荐:先删旧代码再解压新 zip**(避免 `packages/shared/public/球员` 等中文目录残留导致 Vite 构建失败)。
|
||||
|
||||
本地构建并导出镜像的详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。
|
||||
推荐主流程是:本地或构建机生成版本化镜像包 → 上传 tar 与 manifest → 服务器执行 `deploy-update.sh --images ... --tag ...`。详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。
|
||||
|
||||
### 方式 A:服务器直接拉代码并构建
|
||||
|
||||
@@ -233,19 +252,30 @@ cd /www/wwwroot/thebet365
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/thebet365
|
||||
./scripts/deploy-update.sh --images thebet365-images.tar
|
||||
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
```
|
||||
|
||||
更新脚本默认会:
|
||||
|
||||
- 先备份 PostgreSQL 到 `./backups/`
|
||||
- 构建或加载新镜像
|
||||
- 先备份 PostgreSQL 与 uploads 到 `./backups/`,并生成 `.sha256`
|
||||
- 构建或加载指定 tag 的新镜像
|
||||
- 使用新 API 镜像执行 `prisma migrate deploy`
|
||||
- 启动/替换 API、玩家端、管理端容器
|
||||
- 等待 API、玩家端、管理端健康检查通过
|
||||
- 执行 `prisma migrate status` 检查数据库迁移状态
|
||||
- 将当前发布写入 `.deploy/current-release.env`,并保留上一次发布到 `.deploy/previous-release.env`
|
||||
|
||||
除非已经手工确认有其他备份,否则不要使用 `--no-backup`。
|
||||
|
||||
### 回滚应用镜像
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/thebet365
|
||||
./scripts/rollback.sh --to v1.2.2
|
||||
```
|
||||
|
||||
回滚脚本只切换 `api` / `player` / `admin` 镜像 tag,不自动恢复数据库。若新版本包含不可逆迁移或已写入不兼容数据,需要先按 `backups/` 中的 `.sql.gz` 备份手工恢复 PostgreSQL,再执行镜像回滚。
|
||||
|
||||
---
|
||||
|
||||
## 九、故障排查
|
||||
@@ -256,21 +286,36 @@ cd /www/wwwroot/thebet365
|
||||
docker logs thebet365-api
|
||||
```
|
||||
|
||||
常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致。
|
||||
常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致、`/api/health/ready` 检查 DB/Redis 失败。
|
||||
|
||||
### 2. 前端 502 / 接口失败
|
||||
|
||||
确认 `thebet365-api` 为 running,且 player/admin 容器能解析主机名 `api`(同一 compose 网络)。
|
||||
确认 `thebet365-api` 为 healthy,且 player/admin 容器能解析主机名 `api`(同一 compose 网络)。
|
||||
|
||||
### 3. 构建慢或内存不足
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.docker ps
|
||||
docker compose -f docker-compose.prod.yml --env-file .env.docker logs --tail=120 api
|
||||
```
|
||||
|
||||
### 3. player/admin 端口无法从公网 IP 直连
|
||||
|
||||
生产默认只绑定 `127.0.0.1`,需要通过宝塔网站反代访问。临时调试公网 IP 直连时,在 `.env.docker` 中设置:
|
||||
|
||||
```bash
|
||||
BIND_ADDR=0.0.0.0
|
||||
```
|
||||
|
||||
然后重新执行部署脚本。
|
||||
|
||||
### 4. 构建慢或内存不足
|
||||
|
||||
首次 `docker compose build` 会安装 pnpm 依赖并编译三端,建议服务器 ≥ 2 GB 内存;可在低峰期构建。
|
||||
|
||||
### 4. 端口被占用
|
||||
### 5. 端口被占用
|
||||
|
||||
修改 `.env.docker` 中的 `PLAYER_PORT` / `ADMIN_PORT` 后重新部署。API 不对公网映射端口。
|
||||
|
||||
### 5. player/admin 构建报错 `ENOENT ... packages/shared/public/球员`
|
||||
### 6. player/admin 构建报错 `ENOENT ... packages/shared/public/球员`
|
||||
|
||||
旧版中文目录 `球员` 在 Linux 上编码异常。确认已使用含 `packages/shared/public/players/` 的新代码包,并:
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# 构建 api / player / admin 生产镜像并导出为 tar
|
||||
# 用法(在项目根目录或本目录执行均可):
|
||||
# .\docs\docker\build-and-export-images.ps1
|
||||
# .\docs\docker\build-and-export-images.ps1 -Tag v1.2.3
|
||||
# .\build-and-export-images.ps1 -UseCache
|
||||
# .\build-and-export-images.ps1 -ExportOnly
|
||||
|
||||
param(
|
||||
[string]$Tag = $env:IMAGE_TAG,
|
||||
[switch]$UseCache,
|
||||
[switch]$ExportOnly,
|
||||
[string]$Output = "thebet365-images.tar"
|
||||
[string]$Output = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -18,6 +20,21 @@ $ComposeFile = "docker-compose.prod.yml"
|
||||
$EnvFile = ".env.docker"
|
||||
$Services = @("api", "player", "admin")
|
||||
|
||||
function Get-DefaultTag {
|
||||
try {
|
||||
$short = (& git rev-parse --short HEAD 2>$null).Trim()
|
||||
if ($short) { return $short }
|
||||
} catch {}
|
||||
return Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
}
|
||||
|
||||
function Assert-ImageTag {
|
||||
param([string]$Value)
|
||||
if ($Value -notmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') {
|
||||
throw "镜像 tag 不合法: $Value"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ComposeFile)) {
|
||||
throw "未找到 $ComposeFile(当前目录: $Root)"
|
||||
}
|
||||
@@ -31,8 +48,16 @@ if (-not (Test-Path $EnvFile)) {
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Tag)) {
|
||||
$Tag = Get-DefaultTag
|
||||
}
|
||||
Assert-ImageTag $Tag
|
||||
if ([string]::IsNullOrWhiteSpace($Output)) {
|
||||
$Output = "thebet365-images-$Tag.tar"
|
||||
}
|
||||
|
||||
if (-not $ExportOnly) {
|
||||
Write-Host "==> 构建镜像: $($Services -join ', ')"
|
||||
Write-Host "==> 构建镜像: $($Services -join ', ') (tag: $Tag)"
|
||||
$buildArgs = @(
|
||||
"compose", "-f", $ComposeFile, "--env-file", $EnvFile, "build"
|
||||
)
|
||||
@@ -40,28 +65,72 @@ if (-not $ExportOnly) {
|
||||
$buildArgs += "--no-cache"
|
||||
}
|
||||
$buildArgs += $Services
|
||||
$oldImageTag = $env:IMAGE_TAG
|
||||
try {
|
||||
$env:IMAGE_TAG = $Tag
|
||||
& docker @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker build 失败,退出码 $LASTEXITCODE" }
|
||||
} finally {
|
||||
$env:IMAGE_TAG = $oldImageTag
|
||||
}
|
||||
}
|
||||
|
||||
$OutputPath = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $Root $Output }
|
||||
|
||||
Write-Host "==> 导出镜像 -> $OutputPath"
|
||||
& docker save `
|
||||
thebet365-api:latest `
|
||||
thebet365-player:latest `
|
||||
thebet365-admin:latest `
|
||||
"thebet365-api:${Tag}" `
|
||||
"thebet365-player:${Tag}" `
|
||||
"thebet365-admin:${Tag}" `
|
||||
-o $OutputPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker save 失败,退出码 $LASTEXITCODE" }
|
||||
|
||||
$manifestPath = if ($OutputPath.EndsWith(".tar")) {
|
||||
$OutputPath.Substring(0, $OutputPath.Length - 4) + ".manifest.txt"
|
||||
} else {
|
||||
"$OutputPath.manifest.txt"
|
||||
}
|
||||
|
||||
$gitCommit = "unknown"
|
||||
$gitDirty = "unknown"
|
||||
try {
|
||||
$gitCommit = (& git rev-parse HEAD 2>$null).Trim()
|
||||
& git diff --quiet
|
||||
$diffExit = $LASTEXITCODE
|
||||
& git diff --cached --quiet
|
||||
$cachedExit = $LASTEXITCODE
|
||||
$gitDirty = if ($diffExit -eq 0 -and $cachedExit -eq 0) { "false" } else { "true" }
|
||||
} catch {}
|
||||
|
||||
$tarHash = (Get-FileHash -Algorithm SHA256 $OutputPath).Hash.ToLowerInvariant()
|
||||
$apiImageId = (& docker image inspect "thebet365-api:${Tag}" --format "{{.Id}}").Trim()
|
||||
$playerImageId = (& docker image inspect "thebet365-player:${Tag}" --format "{{.Id}}").Trim()
|
||||
$adminImageId = (& docker image inspect "thebet365-admin:${Tag}" --format "{{.Id}}").Trim()
|
||||
|
||||
@(
|
||||
"tag=$Tag"
|
||||
"built_at=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))"
|
||||
"git_commit=$gitCommit"
|
||||
"git_dirty=$gitDirty"
|
||||
"tar=$([System.IO.Path]::GetFileName($OutputPath))"
|
||||
"tar_sha256=$tarHash"
|
||||
"api_image=thebet365-api:${Tag}"
|
||||
"api_image_id=$apiImageId"
|
||||
"player_image=thebet365-player:${Tag}"
|
||||
"player_image_id=$playerImageId"
|
||||
"admin_image=thebet365-admin:${Tag}"
|
||||
"admin_image_id=$adminImageId"
|
||||
) | Set-Content -Encoding UTF8 $manifestPath
|
||||
|
||||
$sizeMb = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
|
||||
Write-Host "完成: $OutputPath (${sizeMb} MB)"
|
||||
Write-Host "Manifest: $manifestPath"
|
||||
|
||||
Write-Host @"
|
||||
|
||||
服务器首次部署:
|
||||
./scripts/deploy-first.sh --images thebet365-images.tar
|
||||
./scripts/deploy-first.sh --images $([System.IO.Path]::GetFileName($OutputPath)) --tag $Tag
|
||||
|
||||
服务器后续更新:
|
||||
./scripts/deploy-update.sh --images thebet365-images.tar
|
||||
./scripts/deploy-update.sh --images $([System.IO.Path]::GetFileName($OutputPath)) --tag $Tag
|
||||
"@
|
||||
|
||||
88
docs/docker/build-and-export-images.sh
Normal file → Executable file
88
docs/docker/build-and-export-images.sh
Normal file → Executable file
@@ -2,6 +2,7 @@
|
||||
# 构建 api / player / admin 生产镜像并导出为 tar
|
||||
# 用法:
|
||||
# ./docs/docker/build-and-export-images.sh
|
||||
# ./docs/docker/build-and-export-images.sh --tag v1.2.3
|
||||
# ./build-and-export-images.sh --use-cache
|
||||
# ./build-and-export-images.sh --export-only
|
||||
|
||||
@@ -13,25 +14,44 @@ cd "$ROOT"
|
||||
|
||||
COMPOSE_FILE="docker-compose.prod.yml"
|
||||
ENV_FILE=".env.docker"
|
||||
OUTPUT="thebet365-images.tar"
|
||||
OUTPUT=""
|
||||
SERVICES=(api player admin)
|
||||
NO_CACHE=1
|
||||
EXPORT_ONLY=0
|
||||
TAG="${IMAGE_TAG:-}"
|
||||
|
||||
default_tag() {
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --short HEAD >/dev/null 2>&1; then
|
||||
git rev-parse --short HEAD
|
||||
else
|
||||
date +%Y%m%d-%H%M%S
|
||||
fi
|
||||
}
|
||||
|
||||
validate_tag() {
|
||||
printf '%s' "$1" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' ||
|
||||
{ echo "错误: 镜像 tag 不合法: $1" >&2; exit 1; }
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: docs/docker/build-and-export-images.sh [选项]
|
||||
|
||||
选项:
|
||||
--tag TAG 镜像 tag(默认当前 git 短提交号;非 git 目录则用时间戳)
|
||||
--use-cache 构建时使用 Docker 缓存(默认 --no-cache)
|
||||
--export-only 跳过构建,仅导出已有 latest 镜像
|
||||
--output PATH 导出文件路径(默认项目根目录 thebet365-images.tar)
|
||||
--export-only 跳过构建,仅导出已有指定 tag 镜像
|
||||
--output PATH 导出文件路径(默认项目根目录 thebet365-images-<tag>.tar)
|
||||
-h, --help 显示帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tag)
|
||||
TAG="${2:?缺少 --tag 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--use-cache) NO_CACHE=0; shift ;;
|
||||
--export-only) EXPORT_ONLY=1; shift ;;
|
||||
--output)
|
||||
@@ -44,28 +64,32 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||
echo "错误: 未找到 $COMPOSE_FILE(目录: $ROOT)" >&2
|
||||
echo "错误: 未找到 ${COMPOSE_FILE}(目录: ${ROOT})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
if [[ -f ".env.docker.example" ]]; then
|
||||
echo "警告: 未找到 $ENV_FILE,将使用 .env.docker.example(生产部署请复制为 .env.docker 并修改密钥)"
|
||||
echo "警告: 未找到 ${ENV_FILE},将使用 .env.docker.example(生产部署请复制为 .env.docker 并修改密钥)"
|
||||
ENV_FILE=".env.docker.example"
|
||||
else
|
||||
echo "错误: 未找到 $ENV_FILE 或 .env.docker.example" >&2
|
||||
echo "错误: 未找到 ${ENV_FILE} 或 .env.docker.example" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
TAG="${TAG:-$(default_tag)}"
|
||||
validate_tag "$TAG"
|
||||
OUTPUT="${OUTPUT:-thebet365-images-$TAG.tar}"
|
||||
|
||||
if [[ "$EXPORT_ONLY" -eq 0 ]]; then
|
||||
echo "==> 构建镜像: ${SERVICES[*]}"
|
||||
echo "==> 构建镜像: ${SERVICES[*]} (tag: $TAG)"
|
||||
BUILD_ARGS=(compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build)
|
||||
if [[ "$NO_CACHE" -eq 1 ]]; then
|
||||
BUILD_ARGS+=(--no-cache)
|
||||
fi
|
||||
BUILD_ARGS+=("${SERVICES[@]}")
|
||||
docker "${BUILD_ARGS[@]}"
|
||||
IMAGE_TAG="$TAG" docker "${BUILD_ARGS[@]}"
|
||||
fi
|
||||
|
||||
OUTPUT_PATH="$OUTPUT"
|
||||
@@ -75,23 +99,59 @@ fi
|
||||
|
||||
echo "==> 导出镜像 -> $OUTPUT_PATH"
|
||||
docker save \
|
||||
thebet365-api:latest \
|
||||
thebet365-player:latest \
|
||||
thebet365-admin:latest \
|
||||
"thebet365-api:$TAG" \
|
||||
"thebet365-player:$TAG" \
|
||||
"thebet365-admin:$TAG" \
|
||||
-o "$OUTPUT_PATH"
|
||||
|
||||
MANIFEST_PATH="${OUTPUT_PATH%.tar}.manifest.txt"
|
||||
BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
GIT_COMMIT="unknown"
|
||||
GIT_DIRTY="unknown"
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse HEAD >/dev/null 2>&1; then
|
||||
GIT_COMMIT="$(git rev-parse HEAD)"
|
||||
if git diff --quiet && git diff --cached --quiet; then
|
||||
GIT_DIRTY="false"
|
||||
else
|
||||
GIT_DIRTY="true"
|
||||
fi
|
||||
fi
|
||||
|
||||
CHECKSUM="unavailable"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
CHECKSUM="$(sha256sum "$OUTPUT_PATH" | awk '{print $1}')"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
CHECKSUM="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tag=$TAG"
|
||||
echo "built_at=$BUILT_AT"
|
||||
echo "git_commit=$GIT_COMMIT"
|
||||
echo "git_dirty=$GIT_DIRTY"
|
||||
echo "tar=$(basename "$OUTPUT_PATH")"
|
||||
echo "tar_sha256=$CHECKSUM"
|
||||
echo "api_image=thebet365-api:$TAG"
|
||||
echo "api_image_id=$(docker image inspect "thebet365-api:$TAG" --format '{{.Id}}')"
|
||||
echo "player_image=thebet365-player:$TAG"
|
||||
echo "player_image_id=$(docker image inspect "thebet365-player:$TAG" --format '{{.Id}}')"
|
||||
echo "admin_image=thebet365-admin:$TAG"
|
||||
echo "admin_image_id=$(docker image inspect "thebet365-admin:$TAG" --format '{{.Id}}')"
|
||||
} > "$MANIFEST_PATH"
|
||||
|
||||
if command -v du >/dev/null 2>&1; then
|
||||
SIZE="$(du -h "$OUTPUT_PATH" | awk '{print $1}')"
|
||||
echo "完成: $OUTPUT_PATH ($SIZE)"
|
||||
else
|
||||
echo "完成: $OUTPUT_PATH"
|
||||
fi
|
||||
echo "Manifest: $MANIFEST_PATH"
|
||||
|
||||
cat <<'EOF'
|
||||
cat <<EOF
|
||||
|
||||
服务器首次部署:
|
||||
./scripts/deploy-first.sh --images thebet365-images.tar
|
||||
./scripts/deploy-first.sh --images $(basename "$OUTPUT_PATH") --tag $TAG
|
||||
|
||||
服务器后续更新:
|
||||
./scripts/deploy-update.sh --images thebet365-images.tar
|
||||
./scripts/deploy-update.sh --images $(basename "$OUTPUT_PATH") --tag $TAG
|
||||
EOF
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `docs/docker/build-and-export-images.ps1` | Windows(PowerShell) |
|
||||
| `docs/docker/build-and-export-images.sh` | Linux / macOS / Git Bash |
|
||||
|
||||
两个脚本行为一致:在**项目根目录**执行 compose 构建 → 导出为根目录下的 `thebet365-images.tar`(默认)。
|
||||
两个脚本行为一致:在**项目根目录**执行 compose 构建 → 导出为根目录下的 `thebet365-images-<tag>.tar`(默认),并生成同名 `.manifest.txt`。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
生产环境务必在 `.env.docker` 中配置:
|
||||
|
||||
- `POSTGRES_PASSWORD`、`JWT_SECRET`
|
||||
- `IMAGE_TAG`、`BIND_ADDR`、`RUN_MIGRATIONS_ON_START` 保持 `.env.docker.example` 默认即可,部署脚本会按 `--tag` 写回真实版本
|
||||
- `CHUANGLAN_ACCOUNT`、`CHUANGLAN_PASSWORD`(短信注册)
|
||||
- `SEED_DATABASE=false`(生产建议保持 false,由部署脚本按需一次性 seed)
|
||||
|
||||
@@ -58,9 +59,10 @@ chmod +x docs/docker/build-and-export-images.sh
|
||||
|
||||
| PowerShell | Bash | 说明 |
|
||||
|------------|------|------|
|
||||
| `-Tag v1.2.3` | `--tag v1.2.3` | 指定镜像 tag;未指定时默认当前 Git 短提交号 |
|
||||
| (默认) | (默认) | `--no-cache` 全量构建,适合发版 |
|
||||
| `-UseCache` | `--use-cache` | 使用 Docker 缓存,构建更快 |
|
||||
| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有 `latest` 镜像 |
|
||||
| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有指定 tag 镜像 |
|
||||
| `-Output my.tar` | `--output my.tar` | 自定义导出文件名 |
|
||||
|
||||
示例:仅重新导出已有镜像
|
||||
@@ -79,17 +81,18 @@ chmod +x docs/docker/build-and-export-images.sh
|
||||
|
||||
| 镜像名 | 说明 |
|
||||
|--------|------|
|
||||
| `thebet365-api:latest` | NestJS API(含 Prisma 迁移入口) |
|
||||
| `thebet365-player:latest` | 玩家前台(Nginx 静态资源) |
|
||||
| `thebet365-admin:latest` | 管理后台(Nginx 静态资源) |
|
||||
| `thebet365-api:<tag>` | NestJS API(迁移由部署脚本执行) |
|
||||
| `thebet365-player:<tag>` | 玩家前台(Nginx 静态资源) |
|
||||
| `thebet365-admin:<tag>` | 管理后台(Nginx 静态资源) |
|
||||
|
||||
导出文件默认路径:
|
||||
|
||||
```text
|
||||
<项目根目录>/thebet365-images.tar
|
||||
<项目根目录>/thebet365-images-<tag>.tar
|
||||
<项目根目录>/thebet365-images-<tag>.manifest.txt
|
||||
```
|
||||
|
||||
该文件已加入 `.gitignore`,**请勿提交到 Git**。
|
||||
这些文件已加入 `.gitignore`,**请勿提交到 Git**。manifest 会记录 tag、构建时间、Git commit、镜像 ID 和 tar 的 SHA-256,便于服务器核对发布包。
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +102,8 @@ chmod +x docs/docker/build-and-export-images.sh
|
||||
|
||||
将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`):
|
||||
|
||||
- `thebet365-images.tar`
|
||||
- `thebet365-images-<tag>.tar`
|
||||
- `thebet365-images-<tag>.manifest.txt`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.env.docker`(或服务器上已有配置)
|
||||
- `docker/nginx/` 等 compose 依赖目录(若仅 load 镜像、不 rebuild,compose 文件仍需要)
|
||||
@@ -111,16 +115,16 @@ chmod +x docs/docker/build-and-export-images.sh
|
||||
```bash
|
||||
cd /www/wwwroot/thebet365
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/deploy-first.sh --images thebet365-images.tar
|
||||
./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
```
|
||||
|
||||
后续更新同一个服务器时:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-update.sh --images thebet365-images.tar
|
||||
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
```
|
||||
|
||||
更新脚本会先备份数据库,再用新 API 镜像执行 `prisma migrate deploy`,最后替换运行中的容器。
|
||||
更新脚本会先备份数据库与 uploads,再用新 API 镜像执行 `prisma migrate deploy`,最后替换运行中的容器并等待健康检查通过。
|
||||
|
||||
### 3. 验证
|
||||
|
||||
@@ -131,8 +135,8 @@ docker logs thebet365-api --tail 50
|
||||
|
||||
浏览器访问(端口以 `.env.docker` 为准):
|
||||
|
||||
- 玩家端:`http://服务器IP:8082`
|
||||
- 管理端:`http://服务器IP:8081`
|
||||
- 玩家端:经宝塔反代访问,或服务器本机 `http://127.0.0.1:8082`
|
||||
- 管理端:经宝塔反代访问,或服务器本机 `http://127.0.0.1:8081`
|
||||
|
||||
---
|
||||
|
||||
@@ -140,10 +144,10 @@ docker logs thebet365-api --tail 50
|
||||
|
||||
| 方式 | 优点 | 缺点 |
|
||||
|------|------|------|
|
||||
| **本地 build + 导出 tar** | 不占用服务器 CPU/内存;可重复部署同一包 | 需上传较大 tar(约 200–300 MB) |
|
||||
| **本地 build + 导出 tar** | 不占用服务器 CPU/内存;有 tag 与 manifest,可重复部署同一包 | 需上传较大 tar(约 200–300 MB) |
|
||||
| **服务器 `docker compose build`** | 无需传 tar | 首次/全量构建慢,小内存机器易失败 |
|
||||
|
||||
发版推荐流程:**本地或构建机执行脚本 → 上传 tar → 服务器执行 `deploy-update.sh --images thebet365-images.tar`**。
|
||||
发版推荐流程:**本地或构建机执行脚本 → 上传 tar + manifest → 服务器执行 `deploy-update.sh --images thebet365-images-<tag>.tar --tag <tag>`**。
|
||||
|
||||
---
|
||||
|
||||
@@ -162,9 +166,9 @@ find packages/shared/public -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name flags ! -name players -exec rm -rf {} +
|
||||
```
|
||||
|
||||
### 3. `docker load` 后 `up -d` 仍拉取或重建镜像
|
||||
### 3. `docker load` 后部署仍找不到镜像
|
||||
|
||||
确保 compose 中 `image` 与 load 的 tag 一致(`thebet365-api:latest` 等),且使用同一 `docker-compose.prod.yml`。
|
||||
确保上传的 tar 中包含 `thebet365-api:<tag>`、`thebet365-player:<tag>`、`thebet365-admin:<tag>`,并且服务器执行部署时传入同一个 `--tag <tag>`。
|
||||
|
||||
### 4. API 启动后不断重启
|
||||
|
||||
@@ -182,7 +186,8 @@ docker logs thebet365-api
|
||||
thebet365/
|
||||
├── docker-compose.prod.yml
|
||||
├── .env.docker.example
|
||||
├── thebet365-images.tar # 导出产物(默认,已 gitignore)
|
||||
├── thebet365-images-<tag>.tar # 导出产物(默认,已 gitignore)
|
||||
├── thebet365-images-<tag>.manifest.txt
|
||||
├── docker/
|
||||
│ ├── api/Dockerfile
|
||||
│ ├── player/Dockerfile
|
||||
|
||||
@@ -387,7 +387,8 @@ cd /www/wwwroot/thebet365
|
||||
./scripts/deploy-update.sh --pull
|
||||
```
|
||||
|
||||
- 更新脚本会先备份数据库,再构建新镜像、执行 Prisma 迁移并替换容器
|
||||
- 更新脚本会先备份数据库与 uploads,再构建新镜像、执行 Prisma 迁移、替换容器并等待健康检查
|
||||
- 使用离线镜像包时推荐 `./scripts/deploy-update.sh --images thebet365-images-<tag>.tar --tag <tag>`,发布状态会记录到 `.deploy/current-release.env`
|
||||
- **不要**把 `.env.docker` 提交到 Git;服务器上单独保留
|
||||
- `release/*.zip` 为旧打包方式,Git 同步后不必再生成上传
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ pnpm db:seed
|
||||
CONFIRM=YES ./scripts/prod-init-db.sh
|
||||
```
|
||||
|
||||
该脚本会先备份 PostgreSQL,再执行 Prisma 迁移,然后以 production 模式清空业务表并写入 `admin`、WC2026 小组赛 72 场和 48 强优胜盘。
|
||||
该脚本会先备份 PostgreSQL 到 `backups/*.sql.gz` 并生成 `.sha256`,再执行 Prisma 迁移,然后以 production 模式清空业务表并写入 `admin`、WC2026 小组赛 72 场和 48 强优胜盘。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ $ErrorActionPreference = "Stop"
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$outFile = Join-Path $OutDir "thebet365-$stamp.sql"
|
||||
$tempFile = Join-Path $OutDir "thebet365-db-manual-$stamp.sql"
|
||||
$outFile = "$tempFile.gz"
|
||||
|
||||
Write-Host "Backing up $DbName to $outFile ..."
|
||||
$env:PGPASSWORD = $env:THEBET365_DB_PASSWORD
|
||||
@@ -19,5 +20,24 @@ if (-not $env:PGPASSWORD) {
|
||||
Write-Warning "Set THEBET365_DB_PASSWORD if your Postgres requires a password."
|
||||
}
|
||||
|
||||
pg_dump -h $DbHost -p $DbPort -U $DbUser -d $DbName -F p -f $outFile
|
||||
pg_dump -h $DbHost -p $DbPort -U $DbUser -d $DbName -F p -f $tempFile
|
||||
|
||||
$inputStream = [System.IO.File]::OpenRead($tempFile)
|
||||
$outputStream = [System.IO.File]::Create($outFile)
|
||||
try {
|
||||
$gzipStream = [System.IO.Compression.GzipStream]::new($outputStream, [System.IO.Compression.CompressionMode]::Compress)
|
||||
try {
|
||||
$inputStream.CopyTo($gzipStream)
|
||||
} finally {
|
||||
$gzipStream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$inputStream.Dispose()
|
||||
$outputStream.Dispose()
|
||||
}
|
||||
Remove-Item $tempFile -Force
|
||||
|
||||
$hash = (Get-FileHash -Algorithm SHA256 $outFile).Hash.ToLowerInvariant()
|
||||
"$hash $(Split-Path -Leaf $outFile)" | Set-Content -Encoding UTF8 "$outFile.sha256"
|
||||
Write-Host "Done: $outFile"
|
||||
Write-Host "Checksum: $outFile.sha256"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 备份生产 Docker PostgreSQL 到 ./backups
|
||||
# 备份生产 Docker PostgreSQL 到 ./backups,输出 gzip 与 sha256 校验文件
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -44,3 +44,4 @@ require_docker
|
||||
ensure_env_file || exit 1
|
||||
start_infra
|
||||
backup_database "$PREFIX"
|
||||
prune_old_backups
|
||||
|
||||
69
scripts/backup-prod.sh
Executable file
69
scripts/backup-prod.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# 完整生产备份:PostgreSQL + uploads 卷,输出 gzip/tar.gz 与 sha256 校验文件。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/deploy-lib.sh
|
||||
source "$SCRIPT_DIR/deploy-lib.sh"
|
||||
|
||||
PREFIX="manual"
|
||||
SKIP_UPLOADS=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: scripts/backup-prod.sh [选项]
|
||||
|
||||
默认流程:
|
||||
1. 检查 .env.docker
|
||||
2. 启动并等待 PostgreSQL / Redis
|
||||
3. 备份 PostgreSQL 到 ./backups/*.sql.gz
|
||||
4. 通过正在运行的 api 容器备份 uploads 到 ./backups/*.tar.gz
|
||||
5. 为备份文件生成 .sha256 校验文件
|
||||
|
||||
选项:
|
||||
--prefix NAME 备份文件名前缀,默认 manual
|
||||
--skip-uploads 只备份数据库,不备份 uploads
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./scripts/backup-prod.sh
|
||||
./scripts/backup-prod.sh --prefix pre-release
|
||||
./scripts/backup-prod.sh --skip-uploads
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--prefix)
|
||||
PREFIX="${2:?缺少 --prefix 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-uploads)
|
||||
SKIP_UPLOADS=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "未知参数: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$ROOT"
|
||||
require_docker
|
||||
ensure_env_file || exit 1
|
||||
start_infra
|
||||
backup_database "$PREFIX"
|
||||
|
||||
if [ "$SKIP_UPLOADS" = false ]; then
|
||||
backup_uploads "$PREFIX"
|
||||
else
|
||||
warn "已跳过 uploads 备份"
|
||||
fi
|
||||
|
||||
prune_old_backups
|
||||
log "生产备份完成"
|
||||
@@ -29,6 +29,7 @@ usage() {
|
||||
选项:
|
||||
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||
--no-seed 不执行生产 seed
|
||||
--init-db 调用 scripts/prod-init-db.sh 清空业务数据并初始化生产数据,仅限全新库或明确重置
|
||||
--skip-backup 与 --init-db 一起使用,跳过 prod-init-db 的备份
|
||||
@@ -37,7 +38,7 @@ usage() {
|
||||
|
||||
示例:
|
||||
./scripts/deploy-first.sh
|
||||
./scripts/deploy-first.sh --images thebet365-images.tar
|
||||
./scripts/deploy-first.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
./scripts/deploy-first.sh --no-build
|
||||
./scripts/deploy-first.sh --init-db
|
||||
EOF
|
||||
@@ -54,6 +55,10 @@ while [ $# -gt 0 ]; do
|
||||
NO_BUILD=true
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--no-seed)
|
||||
RUN_SEED=false
|
||||
shift
|
||||
@@ -86,6 +91,14 @@ require_docker
|
||||
ensure_env_file || exit 1
|
||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
||||
|
||||
if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then
|
||||
inferred_tag="$(infer_image_tag_from_tar "$IMAGE_TAR")"
|
||||
if [ -n "$inferred_tag" ]; then
|
||||
set_deploy_image_tag "$inferred_tag"
|
||||
log "从镜像包文件名推断 tag: $inferred_tag"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$IMAGE_TAR" ]; then
|
||||
load_image_tar "$IMAGE_TAR"
|
||||
fi
|
||||
@@ -96,6 +109,7 @@ if [ "$NO_BUILD" = false ]; then
|
||||
build_app_images
|
||||
else
|
||||
log "跳过镜像构建,使用服务器已有镜像"
|
||||
require_images_for_current_tag
|
||||
fi
|
||||
|
||||
run_prisma_migrations
|
||||
@@ -106,7 +120,7 @@ fi
|
||||
|
||||
log "启动 api / player / admin"
|
||||
compose up -d api player admin
|
||||
wait_for_service_running api 120
|
||||
wait_for_stack_ready
|
||||
|
||||
if [ "$INIT_DB" = true ]; then
|
||||
init_args=()
|
||||
@@ -116,9 +130,11 @@ if [ "$INIT_DB" = true ]; then
|
||||
log "执行生产初始化:这会清空业务数据"
|
||||
CONFIRM=YES "$ROOT/scripts/prod-init-db.sh" "${init_args[@]}"
|
||||
compose restart api
|
||||
wait_for_service_running api 120
|
||||
wait_for_service_health api 180
|
||||
fi
|
||||
|
||||
show_prisma_status
|
||||
compose ps
|
||||
persist_image_tag
|
||||
record_release_state "first-deploy"
|
||||
print_stack_urls
|
||||
|
||||
@@ -6,6 +6,14 @@ COMPOSE_FILE="$ROOT/docker-compose.prod.yml"
|
||||
ENV_FILE="$ROOT/.env.docker"
|
||||
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
|
||||
BACKUP_DIR="$ROOT/backups"
|
||||
DEPLOY_STATE_DIR="$ROOT/.deploy"
|
||||
RELEASES_DIR="$DEPLOY_STATE_DIR/releases"
|
||||
CURRENT_RELEASE_FILE="$DEPLOY_STATE_DIR/current-release.env"
|
||||
PREVIOUS_RELEASE_FILE="$DEPLOY_STATE_DIR/previous-release.env"
|
||||
|
||||
DEPLOY_IMAGE_TAG="${DEPLOY_IMAGE_TAG:-}"
|
||||
LAST_DB_BACKUP=""
|
||||
LAST_UPLOADS_BACKUP=""
|
||||
|
||||
log() {
|
||||
printf '%s\n' "[$(basename "$0")] $*"
|
||||
@@ -29,8 +37,36 @@ require_docker() {
|
||||
docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2,请确认可执行 docker compose"
|
||||
}
|
||||
|
||||
validate_image_tag() {
|
||||
local tag="$1"
|
||||
printf '%s' "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' ||
|
||||
die "镜像 tag 不合法: $tag"
|
||||
}
|
||||
|
||||
set_deploy_image_tag() {
|
||||
local tag="$1"
|
||||
[ -n "$tag" ] || die "镜像 tag 不能为空"
|
||||
validate_image_tag "$tag"
|
||||
DEPLOY_IMAGE_TAG="$tag"
|
||||
}
|
||||
|
||||
infer_image_tag_from_tar() {
|
||||
local image_tar="$1"
|
||||
local base tag
|
||||
|
||||
base="$(basename "$image_tar")"
|
||||
tag="${base#thebet365-images-}"
|
||||
tag="${tag%.tar}"
|
||||
|
||||
if [ "$tag" != "$base" ] && [ -n "$tag" ]; then
|
||||
printf '%s' "$tag"
|
||||
fi
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
|
||||
local tag
|
||||
tag="$(current_image_tag)"
|
||||
IMAGE_TAG="$tag" docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
|
||||
}
|
||||
|
||||
ensure_env_file() {
|
||||
@@ -61,13 +97,61 @@ env_value() {
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
set_env_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local tmp
|
||||
|
||||
tmp="$(mktemp)"
|
||||
if grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { done = 0 }
|
||||
$0 ~ "^[[:space:]]*" key "=" {
|
||||
if (!done) {
|
||||
print key "=" value
|
||||
done = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END {
|
||||
if (!done) print key "=" value
|
||||
}
|
||||
' "$ENV_FILE" > "$tmp"
|
||||
else
|
||||
cp "$ENV_FILE" "$tmp"
|
||||
printf '\n%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
fi
|
||||
mv "$tmp" "$ENV_FILE"
|
||||
}
|
||||
|
||||
current_image_tag() {
|
||||
local tag="${DEPLOY_IMAGE_TAG:-}"
|
||||
|
||||
if [ -z "$tag" ] && [ -f "$ENV_FILE" ]; then
|
||||
tag="$(env_value IMAGE_TAG)"
|
||||
fi
|
||||
|
||||
tag="${tag:-latest}"
|
||||
validate_image_tag "$tag"
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
persist_image_tag() {
|
||||
local tag
|
||||
tag="$(current_image_tag)"
|
||||
set_env_value IMAGE_TAG "$tag"
|
||||
log "当前发布镜像 tag: $tag"
|
||||
}
|
||||
|
||||
validate_prod_env() {
|
||||
local allow_defaults="${1:-false}"
|
||||
local postgres_password jwt_secret seed_database
|
||||
local postgres_password jwt_secret seed_database bind_addr
|
||||
|
||||
postgres_password="$(env_value POSTGRES_PASSWORD)"
|
||||
jwt_secret="$(env_value JWT_SECRET)"
|
||||
seed_database="$(env_value SEED_DATABASE)"
|
||||
bind_addr="$(env_value BIND_ADDR)"
|
||||
|
||||
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
|
||||
[ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET"
|
||||
@@ -81,6 +165,10 @@ validate_prod_env() {
|
||||
warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed;生产建议设为 false,首次部署脚本会按需一次性 seed"
|
||||
fi
|
||||
|
||||
if [ "${bind_addr:-127.0.0.1}" = "0.0.0.0" ]; then
|
||||
warn "BIND_ADDR=0.0.0.0 会让 player/admin 端口直接对外监听;生产建议经宝塔反代访问"
|
||||
fi
|
||||
|
||||
if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then
|
||||
warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置"
|
||||
fi
|
||||
@@ -103,35 +191,17 @@ wait_for_service_health() {
|
||||
|
||||
now="$(date +%s)"
|
||||
if [ $((now - start)) -ge "$timeout" ]; then
|
||||
compose logs --tail=80 "$service" || true
|
||||
compose logs --tail=120 "$service" || true
|
||||
die "$service 在 ${timeout}s 内未就绪"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_service_running() {
|
||||
local service="$1"
|
||||
local timeout="${2:-120}"
|
||||
local start now container_id running
|
||||
|
||||
start="$(date +%s)"
|
||||
while true; do
|
||||
container_id="$(compose ps -q "$service" 2>/dev/null || true)"
|
||||
if [ -n "$container_id" ]; then
|
||||
running="$(docker inspect -f '{{.State.Running}}' "$container_id" 2>/dev/null || true)"
|
||||
if [ "$running" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
if [ $((now - start)) -ge "$timeout" ]; then
|
||||
compose logs --tail=120 "$service" || true
|
||||
die "$service 在 ${timeout}s 内未保持运行"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
wait_for_stack_ready() {
|
||||
wait_for_service_health api 180
|
||||
wait_for_service_health player 120
|
||||
wait_for_service_health admin 120
|
||||
}
|
||||
|
||||
start_infra() {
|
||||
@@ -142,7 +212,7 @@ start_infra() {
|
||||
}
|
||||
|
||||
build_app_images() {
|
||||
log "构建 api / player / admin 镜像"
|
||||
log "构建 api / player / admin 镜像: $(current_image_tag)"
|
||||
compose build api player admin
|
||||
}
|
||||
|
||||
@@ -153,6 +223,14 @@ load_image_tar() {
|
||||
docker load -i "$image_tar"
|
||||
}
|
||||
|
||||
require_images_for_current_tag() {
|
||||
local tag
|
||||
tag="$(current_image_tag)"
|
||||
docker image inspect "thebet365-api:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-api:$tag"
|
||||
docker image inspect "thebet365-player:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-player:$tag"
|
||||
docker image inspect "thebet365-admin:$tag" >/dev/null 2>&1 || die "缺少镜像: thebet365-admin:$tag"
|
||||
}
|
||||
|
||||
run_prisma_migrations() {
|
||||
log "执行 Prisma 迁移"
|
||||
compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate'
|
||||
@@ -163,19 +241,81 @@ show_prisma_status() {
|
||||
compose exec -T api sh -c 'cd /app/apps/api && npx prisma migrate status'
|
||||
}
|
||||
|
||||
safe_backup_prefix() {
|
||||
local prefix="${1:-manual}"
|
||||
prefix="$(printf '%s' "$prefix" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
prefix="${prefix:-manual}"
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
write_checksum() {
|
||||
local file="$1"
|
||||
local dir base
|
||||
|
||||
dir="$(dirname "$file")"
|
||||
base="$(basename "$file")"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$dir" && sha256sum "$base" > "$base.sha256")
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$dir" && shasum -a 256 "$base" > "$base.sha256")
|
||||
else
|
||||
warn "未找到 sha256sum 或 shasum,跳过校验文件: $file"
|
||||
return 0
|
||||
fi
|
||||
log "校验文件完成: $file.sha256"
|
||||
}
|
||||
|
||||
backup_database() {
|
||||
local prefix="${1:-manual}"
|
||||
local stamp backup_file
|
||||
|
||||
require_command gzip
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
prefix="$(safe_backup_prefix "$prefix")"
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
backup_file="$BACKUP_DIR/thebet365-${prefix}-${stamp}.sql"
|
||||
backup_file="$BACKUP_DIR/thebet365-db-${prefix}-${stamp}.sql.gz"
|
||||
|
||||
log "备份 PostgreSQL -> $backup_file"
|
||||
compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$backup_file"
|
||||
compose exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$backup_file"
|
||||
write_checksum "$backup_file"
|
||||
LAST_DB_BACKUP="$backup_file"
|
||||
log "数据库备份完成: $backup_file"
|
||||
}
|
||||
|
||||
backup_uploads() {
|
||||
local prefix="${1:-manual}"
|
||||
local stamp backup_file
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
prefix="$(safe_backup_prefix "$prefix")"
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
backup_file="$BACKUP_DIR/thebet365-uploads-${prefix}-${stamp}.tar.gz"
|
||||
|
||||
log "备份 uploads 卷 -> $backup_file"
|
||||
compose exec -T api sh -c 'cd /app && mkdir -p uploads && tar -czf - uploads' > "$backup_file"
|
||||
write_checksum "$backup_file"
|
||||
LAST_UPLOADS_BACKUP="$backup_file"
|
||||
log "uploads 备份完成: $backup_file"
|
||||
}
|
||||
|
||||
prune_old_backups() {
|
||||
local days
|
||||
days="$(env_value BACKUP_RETENTION_DAYS)"
|
||||
[ -n "$days" ] || return 0
|
||||
|
||||
case "$days" in
|
||||
*[!0-9]*)
|
||||
warn "BACKUP_RETENTION_DAYS 不是数字,跳过自动清理: $days"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ "$days" -gt 0 ] || return 0
|
||||
[ -d "$BACKUP_DIR" ] || return 0
|
||||
log "清理 ${days} 天前的备份"
|
||||
find "$BACKUP_DIR" -type f \( -name '*.sql.gz' -o -name '*.tar.gz' -o -name '*.sha256' \) -mtime +"$days" -delete
|
||||
}
|
||||
|
||||
admin_user_count() {
|
||||
compose exec -T postgres psql -U thebet365 -d thebet365 -tAc "select count(*) from users where username = 'admin';" 2>/dev/null | tr -d '[:space:]'
|
||||
}
|
||||
@@ -193,17 +333,59 @@ seed_production_if_missing_admin() {
|
||||
fi
|
||||
}
|
||||
|
||||
current_release_tag() {
|
||||
if [ -f "$CURRENT_RELEASE_FILE" ]; then
|
||||
grep -E '^tag=' "$CURRENT_RELEASE_FILE" | tail -n 1 | cut -d= -f2- || true
|
||||
fi
|
||||
}
|
||||
|
||||
record_release_state() {
|
||||
local source="${1:-deploy}"
|
||||
local tag stamp safe_tag manifest previous_tag
|
||||
|
||||
tag="$(current_image_tag)"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
safe_tag="$(printf '%s' "$tag" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||
manifest="$RELEASES_DIR/${stamp}-${safe_tag}.env"
|
||||
previous_tag="$(current_release_tag)"
|
||||
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
if [ -f "$CURRENT_RELEASE_FILE" ]; then
|
||||
cp "$CURRENT_RELEASE_FILE" "$PREVIOUS_RELEASE_FILE"
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'tag=%s\n' "$tag"
|
||||
printf 'previous_tag=%s\n' "$previous_tag"
|
||||
printf 'deployed_at=%s\n' "$stamp"
|
||||
printf 'source=%s\n' "$source"
|
||||
printf 'api_image=thebet365-api:%s\n' "$tag"
|
||||
printf 'player_image=thebet365-player:%s\n' "$tag"
|
||||
printf 'admin_image=thebet365-admin:%s\n' "$tag"
|
||||
[ -n "$LAST_DB_BACKUP" ] && printf 'db_backup=%s\n' "$LAST_DB_BACKUP"
|
||||
[ -n "$LAST_UPLOADS_BACKUP" ] && printf 'uploads_backup=%s\n' "$LAST_UPLOADS_BACKUP"
|
||||
} > "$manifest"
|
||||
|
||||
cp "$manifest" "$CURRENT_RELEASE_FILE"
|
||||
log "发布状态已记录: $manifest"
|
||||
}
|
||||
|
||||
print_stack_urls() {
|
||||
local player_port admin_port
|
||||
local player_port admin_port bind_addr tag
|
||||
player_port="$(env_value PLAYER_PORT)"
|
||||
admin_port="$(env_value ADMIN_PORT)"
|
||||
bind_addr="$(env_value BIND_ADDR)"
|
||||
tag="$(current_image_tag)"
|
||||
player_port="${player_port:-8082}"
|
||||
admin_port="${admin_port:-8081}"
|
||||
bind_addr="${bind_addr:-127.0.0.1}"
|
||||
|
||||
printf '\n'
|
||||
printf '%s\n' "部署完成:"
|
||||
printf '%s\n' " 玩家端: http://服务器IP:${player_port}"
|
||||
printf '%s\n' " 管理端: http://服务器IP:${admin_port}"
|
||||
printf '%s\n' " 镜像 tag: ${tag}"
|
||||
printf '%s\n' " 玩家端: http://${bind_addr}:${player_port}"
|
||||
printf '%s\n' " 管理端: http://${bind_addr}:${admin_port}"
|
||||
printf '%s\n' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问"
|
||||
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps"
|
||||
printf '%s\n' " 当前发布: .deploy/current-release.env"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 后续更新:可选拉代码/加载镜像,备份数据库,构建或使用新镜像,执行迁移并滚动到新容器。
|
||||
# 后续更新:可选拉代码/加载镜像,备份数据,构建或使用新镜像,执行迁移并滚动到新容器。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -20,7 +20,7 @@ usage() {
|
||||
默认流程:
|
||||
1. 检查 .env.docker
|
||||
2. 启动并等待 postgres / redis
|
||||
3. 备份 PostgreSQL 到 ./backups
|
||||
3. 备份 PostgreSQL 与 uploads 到 ./backups
|
||||
4. 构建 api / player / admin 镜像
|
||||
5. 使用新 api 镜像执行 prisma migrate deploy
|
||||
6. 启动/替换 api、player、admin 容器
|
||||
@@ -29,14 +29,15 @@ usage() {
|
||||
选项:
|
||||
--pull 先执行 git pull --ff-only
|
||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||
--no-backup 跳过数据库备份
|
||||
--no-backup 跳过 PostgreSQL 与 uploads 备份
|
||||
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./scripts/deploy-update.sh --pull
|
||||
./scripts/deploy-update.sh --images thebet365-images.tar
|
||||
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
|
||||
./scripts/deploy-update.sh --no-build
|
||||
EOF
|
||||
}
|
||||
@@ -52,6 +53,10 @@ while [ $# -gt 0 ]; do
|
||||
NO_BUILD=true
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
NO_BUILD=true
|
||||
shift
|
||||
@@ -79,6 +84,14 @@ require_docker
|
||||
ensure_env_file || exit 1
|
||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
||||
|
||||
if [ -n "$IMAGE_TAR" ] && [ -z "${DEPLOY_IMAGE_TAG:-}" ]; then
|
||||
inferred_tag="$(infer_image_tag_from_tar "$IMAGE_TAR")"
|
||||
if [ -n "$inferred_tag" ]; then
|
||||
set_deploy_image_tag "$inferred_tag"
|
||||
log "从镜像包文件名推断 tag: $inferred_tag"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PULL_CODE" = true ]; then
|
||||
require_command git
|
||||
log "拉取代码: git pull --ff-only"
|
||||
@@ -93,21 +106,26 @@ start_infra
|
||||
|
||||
if [ "$SKIP_BACKUP" = false ]; then
|
||||
backup_database "pre-update"
|
||||
backup_uploads "pre-update"
|
||||
prune_old_backups
|
||||
else
|
||||
warn "已跳过数据库备份"
|
||||
warn "已跳过 PostgreSQL 与 uploads 备份"
|
||||
fi
|
||||
|
||||
if [ "$NO_BUILD" = false ]; then
|
||||
build_app_images
|
||||
else
|
||||
log "跳过镜像构建,使用服务器已有镜像"
|
||||
require_images_for_current_tag
|
||||
fi
|
||||
|
||||
run_prisma_migrations
|
||||
|
||||
log "启动/更新 api / player / admin"
|
||||
compose up -d api player admin
|
||||
wait_for_service_running api 120
|
||||
wait_for_stack_ready
|
||||
show_prisma_status
|
||||
compose ps
|
||||
persist_image_tag
|
||||
record_release_state "update"
|
||||
print_stack_urls
|
||||
|
||||
@@ -38,9 +38,28 @@ if (-not $SkipBackup) {
|
||||
$backupDir = Join-Path $Root "backups"
|
||||
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$backupFile = Join-Path $backupDir "thebet365-$stamp.sql"
|
||||
$tempFile = Join-Path $backupDir "thebet365-db-prod-init-$stamp.sql"
|
||||
$backupFile = "$tempFile.gz"
|
||||
Write-Host "[prod-init-db] 备份 → $backupFile"
|
||||
docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $backupFile
|
||||
docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $tempFile
|
||||
|
||||
$inputStream = [System.IO.File]::OpenRead($tempFile)
|
||||
$outputStream = [System.IO.File]::Create($backupFile)
|
||||
try {
|
||||
$gzipStream = [System.IO.Compression.GzipStream]::new($outputStream, [System.IO.Compression.CompressionMode]::Compress)
|
||||
try {
|
||||
$inputStream.CopyTo($gzipStream)
|
||||
} finally {
|
||||
$gzipStream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
$inputStream.Dispose()
|
||||
$outputStream.Dispose()
|
||||
}
|
||||
Remove-Item $tempFile -Force
|
||||
|
||||
$hash = (Get-FileHash -Algorithm SHA256 $backupFile).Hash.ToLowerInvariant()
|
||||
"$hash $(Split-Path -Leaf $backupFile)" | Set-Content -Encoding UTF8 "$backupFile.sha256"
|
||||
} else {
|
||||
Write-Host "[prod-init-db] 已跳过备份"
|
||||
}
|
||||
|
||||
@@ -49,9 +49,16 @@ if [ "$SKIP_BACKUP" = false ]; then
|
||||
BACKUP_DIR="$ROOT/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP_FILE="$BACKUP_DIR/thebet365-$STAMP.sql"
|
||||
BACKUP_FILE="$BACKUP_DIR/thebet365-db-prod-init-$STAMP.sql.gz"
|
||||
echo "[prod-init-db] 备份 PostgreSQL → $BACKUP_FILE"
|
||||
"${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$BACKUP_FILE"
|
||||
"${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | gzip -c > "$BACKUP_FILE"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
(cd "$BACKUP_DIR" && sha256sum "$(basename "$BACKUP_FILE")" > "$(basename "$BACKUP_FILE").sha256")
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
(cd "$BACKUP_DIR" && shasum -a 256 "$(basename "$BACKUP_FILE")" > "$(basename "$BACKUP_FILE").sha256")
|
||||
else
|
||||
echo "[prod-init-db] 警告:未找到 sha256sum 或 shasum,跳过校验文件"
|
||||
fi
|
||||
echo "[prod-init-db] 备份完成"
|
||||
else
|
||||
echo "[prod-init-db] 已跳过备份 (--skip-backup)"
|
||||
|
||||
91
scripts/rollback.sh
Executable file
91
scripts/rollback.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# 只回滚 api / player / admin 镜像 tag,不自动恢复数据库。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=scripts/deploy-lib.sh
|
||||
source "$SCRIPT_DIR/deploy-lib.sh"
|
||||
|
||||
TARGET_TAG=""
|
||||
SKIP_BACKUP=false
|
||||
ALLOW_DEFAULT_SECRETS=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: scripts/rollback.sh --to TAG [选项]
|
||||
|
||||
默认流程:
|
||||
1. 检查 .env.docker
|
||||
2. 确认本机存在 thebet365-api/player/admin:TAG
|
||||
3. 备份 PostgreSQL 与 uploads 到 ./backups
|
||||
4. 将 api / player / admin 切回指定 TAG
|
||||
5. 等待健康检查并记录发布状态
|
||||
|
||||
注意:
|
||||
本脚本只回滚镜像,不回滚数据库结构或数据。
|
||||
如果目标版本涉及不可逆迁移,必须先按备份文件手工恢复数据库。
|
||||
|
||||
选项:
|
||||
--to TAG 目标镜像 tag,必填
|
||||
--no-backup 跳过 PostgreSQL 与 uploads 备份
|
||||
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
./scripts/rollback.sh --to v1.2.2
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--to)
|
||||
TARGET_TAG="${2:?缺少 --to 参数值}"
|
||||
shift 2
|
||||
;;
|
||||
--no-backup)
|
||||
SKIP_BACKUP=true
|
||||
shift
|
||||
;;
|
||||
--allow-default-secrets)
|
||||
ALLOW_DEFAULT_SECRETS=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "未知参数: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET_TAG" ] || die "缺少 --to TAG"
|
||||
set_deploy_image_tag "$TARGET_TAG"
|
||||
|
||||
cd "$ROOT"
|
||||
require_docker
|
||||
ensure_env_file || exit 1
|
||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
||||
require_images_for_current_tag
|
||||
|
||||
start_infra
|
||||
|
||||
if [ "$SKIP_BACKUP" = false ]; then
|
||||
backup_database "pre-rollback"
|
||||
backup_uploads "pre-rollback"
|
||||
prune_old_backups
|
||||
else
|
||||
warn "已跳过 PostgreSQL 与 uploads 备份"
|
||||
fi
|
||||
|
||||
log "回滚 api / player / admin 到 tag: $(current_image_tag)"
|
||||
compose up -d api player admin
|
||||
wait_for_stack_ready
|
||||
show_prisma_status
|
||||
compose ps
|
||||
persist_image_tag
|
||||
record_release_state "rollback"
|
||||
warn "镜像已回滚;数据库未自动恢复。若此次回滚涉及不可逆迁移,请按备份文件手工恢复数据库。"
|
||||
print_stack_urls
|
||||
Reference in New Issue
Block a user