部署优化
This commit is contained in:
@@ -11,6 +11,11 @@ node_modules
|
|||||||
coverage
|
coverage
|
||||||
.turbo
|
.turbo
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
|
release
|
||||||
|
backups
|
||||||
|
.deploy
|
||||||
|
thebet365-images*.tar
|
||||||
|
thebet365-images*.manifest.txt
|
||||||
apps/player/dist
|
apps/player/dist
|
||||||
apps/admin/dist
|
apps/admin/dist
|
||||||
apps/api/dist
|
apps/api/dist
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# PostgreSQL(生产务必修改)
|
# PostgreSQL(生产务必修改)
|
||||||
POSTGRES_PASSWORD=thebet365
|
POSTGRES_PASSWORD=thebet365
|
||||||
|
|
||||||
|
# 发布镜像版本;部署脚本使用 --tag 时会写回服务器上的 .env.docker
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
# JWT(生产务必修改)
|
# JWT(生产务必修改)
|
||||||
JWT_SECRET=change-me-in-production-use-long-random-string
|
JWT_SECRET=change-me-in-production-use-long-random-string
|
||||||
JWT_PLAYER_EXPIRES=24h
|
JWT_PLAYER_EXPIRES=24h
|
||||||
@@ -12,13 +15,19 @@ JWT_AGENT_EXPIRES=8h
|
|||||||
|
|
||||||
# 首次部署如需写入默认数据,可临时改为 true;灌完后改回 false 并重启 api
|
# 首次部署如需写入默认数据,可临时改为 true;灌完后改回 false 并重启 api
|
||||||
SEED_DATABASE=false
|
SEED_DATABASE=false
|
||||||
|
# 迁移由 deploy-first/deploy-update 脚本执行;仅应急兼容时改为 true
|
||||||
|
RUN_MIGRATIONS_ON_START=false
|
||||||
# 可选:覆盖 admin 初始密码(仅 seed/重置时生效)
|
# 可选:覆盖 admin 初始密码(仅 seed/重置时生效)
|
||||||
# ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere
|
# ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere
|
||||||
|
|
||||||
# 对外端口(宝塔/Nginx 反代推荐只暴露前端,API 经反向代理访问)
|
# 对外端口(宝塔/Nginx 反代推荐只暴露前端,API 经反向代理访问)
|
||||||
|
BIND_ADDR=127.0.0.1
|
||||||
PLAYER_PORT=8082
|
PLAYER_PORT=8082
|
||||||
ADMIN_PORT=8081
|
ADMIN_PORT=8081
|
||||||
|
|
||||||
|
# 备份保留天数;留空表示不自动清理
|
||||||
|
BACKUP_RETENTION_DAYS=
|
||||||
|
|
||||||
# API 安全开关
|
# API 安全开关
|
||||||
# CORS_ORIGINS=https://player.example.com,https://admin.example.com
|
# CORS_ORIGINS=https://player.example.com,https://admin.example.com
|
||||||
ENABLE_SWAGGER=false
|
ENABLE_SWAGGER=false
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,8 +3,11 @@ dist/
|
|||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
release/
|
release/
|
||||||
backups/
|
backups/
|
||||||
|
.deploy/
|
||||||
docker-build.log
|
docker-build.log
|
||||||
thebet365-images.tar
|
thebet365-images.tar
|
||||||
|
thebet365-images-*.tar
|
||||||
|
thebet365-images-*.manifest.txt
|
||||||
.claude/
|
.claude/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { OperationsModule } from './domains/operations/operations.module';
|
|||||||
import { AdminModule } from './applications/admin/admin.module';
|
import { AdminModule } from './applications/admin/admin.module';
|
||||||
import { PlayerModule } from './applications/player/player.module';
|
import { PlayerModule } from './applications/player/player.module';
|
||||||
import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
||||||
|
import { HealthModule } from './applications/health/health.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -36,6 +37,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
PlayerModule,
|
PlayerModule,
|
||||||
AgentPortalModule,
|
AgentPortalModule,
|
||||||
|
HealthModule,
|
||||||
],
|
],
|
||||||
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
|
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() {
|
function closeDrawer() {
|
||||||
|
slip.closeDrawer();
|
||||||
show.value = false;
|
show.value = false;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
success.value = '';
|
success.value = '';
|
||||||
@@ -305,7 +306,7 @@ watch(
|
|||||||
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
|
<p class="drawer-kicker">{{ t('bet.bet_slip') }}</p>
|
||||||
<h3>{{ t('bet.slip_review_title') }}</h3>
|
<h3>{{ t('bet.slip_review_title') }}</h3>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,6 +474,9 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-head {
|
.drawer-head {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -497,6 +501,9 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
flex-shrink: 0;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -504,6 +511,8 @@ watch(
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-bar {
|
.balance-bar {
|
||||||
|
|||||||
@@ -36,19 +36,21 @@ services:
|
|||||||
- thebet365
|
- thebet365
|
||||||
|
|
||||||
api:
|
api:
|
||||||
image: thebet365-api:latest
|
image: thebet365-api:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/api/Dockerfile
|
dockerfile: docker/api/Dockerfile
|
||||||
container_name: thebet365-api
|
container_name: thebet365-api
|
||||||
env_file:
|
env_file:
|
||||||
- .env.docker
|
- path: .env.docker
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365
|
DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
|
RUN_MIGRATIONS_ON_START: ${RUN_MIGRATIONS_ON_START:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -58,34 +60,58 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
expose:
|
expose:
|
||||||
- '3000'
|
- '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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- thebet365
|
- thebet365
|
||||||
|
|
||||||
player:
|
player:
|
||||||
image: thebet365-player:latest
|
image: thebet365-player:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/player/Dockerfile
|
dockerfile: docker/player/Dockerfile
|
||||||
container_name: thebet365-player
|
container_name: thebet365-player
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- thebet365
|
- thebet365
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
image: thebet365-admin:latest
|
image: thebet365-admin:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/admin/Dockerfile
|
dockerfile: docker/admin/Dockerfile
|
||||||
container_name: thebet365-admin
|
container_name: thebet365-admin
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- thebet365
|
- thebet365
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
|||||||
COPY apps/player/package.json apps/player/
|
COPY apps/player/package.json apps/player/
|
||||||
COPY apps/admin/package.json apps/admin/
|
COPY apps/admin/package.json apps/admin/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
RUN pnpm install --no-frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
|||||||
COPY apps/player/package.json apps/player/
|
COPY apps/player/package.json apps/player/
|
||||||
COPY apps/admin/package.json apps/admin/
|
COPY apps/admin/package.json apps/admin/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
RUN pnpm install --no-frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ set -e
|
|||||||
|
|
||||||
cd /app/apps/api
|
cd /app/apps/api
|
||||||
|
|
||||||
echo "[api] running migrations..."
|
if [ "${RUN_MIGRATIONS_ON_START:-false}" = "true" ]; then
|
||||||
until npx prisma migrate deploy; do
|
echo "[api] running migrations..."
|
||||||
echo "[api] waiting for database..."
|
until npx prisma migrate deploy; do
|
||||||
sleep 2
|
echo "[api] waiting for database..."
|
||||||
done
|
sleep 2
|
||||||
npx prisma generate
|
done
|
||||||
|
npx prisma generate
|
||||||
|
else
|
||||||
|
echo "[api] skipping startup migrations (RUN_MIGRATIONS_ON_START=false)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$SEED_DATABASE" = "true" ]; then
|
if [ "$SEED_DATABASE" = "true" ]; then
|
||||||
echo "[api] seeding database..."
|
echo "[api] seeding database..."
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY apps/api/package.json apps/api/
|
|||||||
COPY apps/player/package.json apps/player/
|
COPY apps/player/package.json apps/player/
|
||||||
COPY apps/admin/package.json apps/admin/
|
COPY apps/admin/package.json apps/admin/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
RUN pnpm install --no-frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
浏览器
|
浏览器
|
||||||
├─ :8082 player (Nginx) ── /api、/uploads ──► api (NestJS :3000)
|
└─ 宝塔/Nginx HTTPS 反代
|
||||||
└─ :8081 admin (Nginx) ── /api ────────────► api (NestJS :3000)
|
├─ 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
|
api ──► postgres:5432
|
||||||
└──► redis:6379
|
└──► redis:6379
|
||||||
@@ -19,9 +20,9 @@ api ──► postgres:5432
|
|||||||
|
|
||||||
| 容器 | 默认端口 | 说明 |
|
| 容器 | 默认端口 | 说明 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| `thebet365-player` | 8082 | 玩家 H5 前台 |
|
| `thebet365-player` | 127.0.0.1:8082 | 玩家 H5 前台 |
|
||||||
| `thebet365-admin` | 8081 | 管理后台(平台 + 代理) |
|
| `thebet365-admin` | 127.0.0.1:8081 | 管理后台(平台 + 代理) |
|
||||||
| `thebet365-api` | 3000 | NestJS API / Swagger |
|
| `thebet365-api` | 不对外暴露 | NestJS API / Swagger / 健康检查 |
|
||||||
| `thebet365-postgres` | 不对外暴露 | PostgreSQL 16 |
|
| `thebet365-postgres` | 不对外暴露 | PostgreSQL 16 |
|
||||||
| `thebet365-redis` | 不对外暴露 | Redis 7 |
|
| `thebet365-redis` | 不对外暴露 | Redis 7 |
|
||||||
|
|
||||||
@@ -58,6 +59,10 @@ cp .env.docker.example .env.docker
|
|||||||
|
|
||||||
- `POSTGRES_PASSWORD` — 数据库密码
|
- `POSTGRES_PASSWORD` — 数据库密码
|
||||||
- `JWT_SECRET` — 足够长的随机字符串
|
- `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
|
- `SEED_DATABASE=false` — 保持默认即可;首次部署脚本会在没有 `admin` 时一次性执行生产 seed
|
||||||
|
|
||||||
### 3. 首次部署
|
### 3. 首次部署
|
||||||
@@ -81,12 +86,14 @@ chmod +x scripts/*.sh
|
|||||||
./scripts/deploy-first.sh --no-build
|
./scripts/deploy-first.sh --no-build
|
||||||
```
|
```
|
||||||
|
|
||||||
如果上传的是 `thebet365-images.tar`,可让脚本自动加载镜像:
|
如果上传的是版本化镜像包,可让脚本自动加载镜像并记录发布 tag:
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
@@ -104,10 +111,10 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api
|
|||||||
|
|
||||||
| 服务 | 地址 |
|
| 服务 | 地址 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 玩家前台 | http://服务器IP:8082 |
|
| 玩家前台 | 宝塔绑定的玩家域名,或服务器本机 `http://127.0.0.1:8082` |
|
||||||
| 管理后台 | http://服务器IP:8081 |
|
| 管理后台 | 宝塔绑定的管理域名,或服务器本机 `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
|
./scripts/backup-db.sh
|
||||||
|
|
||||||
|
# 完整生产备份:数据库 + uploads
|
||||||
|
./scripts/backup-prod.sh --prefix pre-release
|
||||||
|
|
||||||
# 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/)
|
# 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/)
|
||||||
CONFIRM=YES ./scripts/prod-init-db.sh
|
CONFIRM=YES ./scripts/prod-init-db.sh
|
||||||
# Windows PowerShell:
|
# Windows PowerShell:
|
||||||
@@ -178,6 +188,9 @@ CONFIRM=YES ./scripts/prod-init-db.sh
|
|||||||
|
|
||||||
# 查看 API 日志
|
# 查看 API 日志
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f 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`):
|
根目录 `package.json` 快捷脚本(需已存在 `.env.docker`):
|
||||||
@@ -197,21 +210,27 @@ pnpm docker:ps
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `postgres_data` | 数据库 |
|
| `postgres_data` | 数据库 |
|
||||||
| `redis_data` | Redis |
|
| `redis_data` | Redis |
|
||||||
| `uploads_data` | Banner 等用户上传文件 |
|
| `uploads_data` | Banner、充值截图、支付二维码等用户上传文件 |
|
||||||
|
|
||||||
备份 PostgreSQL 示例:
|
备份示例:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 仅数据库:生成 backups/thebet365-db-*.sql.gz 和 .sha256
|
||||||
./scripts/backup-db.sh
|
./scripts/backup-db.sh
|
||||||
|
|
||||||
|
# 数据库 + uploads:生成 .sql.gz、.tar.gz 和对应 .sha256
|
||||||
|
./scripts/backup-prod.sh --prefix manual
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`BACKUP_RETENTION_DAYS` 留空时不自动删除历史备份;设置为数字时,部署和备份脚本会清理更早的备份文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、后续更新部署
|
## 八、后续更新部署
|
||||||
|
|
||||||
**推荐:先删旧代码再解压新 zip**(避免 `packages/shared/public/球员` 等中文目录残留导致 Vite 构建失败)。
|
**推荐:先删旧代码再解压新 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:服务器直接拉代码并构建
|
### 方式 A:服务器直接拉代码并构建
|
||||||
|
|
||||||
@@ -233,19 +252,30 @@ cd /www/wwwroot/thebet365
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/thebet365
|
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 镜像执行 `prisma migrate deploy`
|
||||||
- 启动/替换 API、玩家端、管理端容器
|
- 启动/替换 API、玩家端、管理端容器
|
||||||
|
- 等待 API、玩家端、管理端健康检查通过
|
||||||
- 执行 `prisma migrate status` 检查数据库迁移状态
|
- 执行 `prisma migrate status` 检查数据库迁移状态
|
||||||
|
- 将当前发布写入 `.deploy/current-release.env`,并保留上一次发布到 `.deploy/previous-release.env`
|
||||||
|
|
||||||
除非已经手工确认有其他备份,否则不要使用 `--no-backup`。
|
除非已经手工确认有其他备份,否则不要使用 `--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
|
docker logs thebet365-api
|
||||||
```
|
```
|
||||||
|
|
||||||
常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致。
|
常见原因:数据库未就绪(稍等重试)、`DATABASE_URL` 密码与 `POSTGRES_PASSWORD` 不一致、`/api/health/ready` 检查 DB/Redis 失败。
|
||||||
|
|
||||||
### 2. 前端 502 / 接口失败
|
### 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 内存;可在低峰期构建。
|
首次 `docker compose build` 会安装 pnpm 依赖并编译三端,建议服务器 ≥ 2 GB 内存;可在低峰期构建。
|
||||||
|
|
||||||
### 4. 端口被占用
|
### 5. 端口被占用
|
||||||
|
|
||||||
修改 `.env.docker` 中的 `PLAYER_PORT` / `ADMIN_PORT` 后重新部署。API 不对公网映射端口。
|
修改 `.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/` 的新代码包,并:
|
旧版中文目录 `球员` 在 Linux 上编码异常。确认已使用含 `packages/shared/public/players/` 的新代码包,并:
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# 构建 api / player / admin 生产镜像并导出为 tar
|
# 构建 api / player / admin 生产镜像并导出为 tar
|
||||||
# 用法(在项目根目录或本目录执行均可):
|
# 用法(在项目根目录或本目录执行均可):
|
||||||
# .\docs\docker\build-and-export-images.ps1
|
# .\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 -UseCache
|
||||||
# .\build-and-export-images.ps1 -ExportOnly
|
# .\build-and-export-images.ps1 -ExportOnly
|
||||||
|
|
||||||
param(
|
param(
|
||||||
|
[string]$Tag = $env:IMAGE_TAG,
|
||||||
[switch]$UseCache,
|
[switch]$UseCache,
|
||||||
[switch]$ExportOnly,
|
[switch]$ExportOnly,
|
||||||
[string]$Output = "thebet365-images.tar"
|
[string]$Output = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -18,6 +20,21 @@ $ComposeFile = "docker-compose.prod.yml"
|
|||||||
$EnvFile = ".env.docker"
|
$EnvFile = ".env.docker"
|
||||||
$Services = @("api", "player", "admin")
|
$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)) {
|
if (-not (Test-Path $ComposeFile)) {
|
||||||
throw "未找到 $ComposeFile(当前目录: $Root)"
|
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) {
|
if (-not $ExportOnly) {
|
||||||
Write-Host "==> 构建镜像: $($Services -join ', ')"
|
Write-Host "==> 构建镜像: $($Services -join ', ') (tag: $Tag)"
|
||||||
$buildArgs = @(
|
$buildArgs = @(
|
||||||
"compose", "-f", $ComposeFile, "--env-file", $EnvFile, "build"
|
"compose", "-f", $ComposeFile, "--env-file", $EnvFile, "build"
|
||||||
)
|
)
|
||||||
@@ -40,28 +65,72 @@ if (-not $ExportOnly) {
|
|||||||
$buildArgs += "--no-cache"
|
$buildArgs += "--no-cache"
|
||||||
}
|
}
|
||||||
$buildArgs += $Services
|
$buildArgs += $Services
|
||||||
& docker @buildArgs
|
$oldImageTag = $env:IMAGE_TAG
|
||||||
if ($LASTEXITCODE -ne 0) { throw "docker build 失败,退出码 $LASTEXITCODE" }
|
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 }
|
$OutputPath = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $Root $Output }
|
||||||
|
|
||||||
Write-Host "==> 导出镜像 -> $OutputPath"
|
Write-Host "==> 导出镜像 -> $OutputPath"
|
||||||
& docker save `
|
& docker save `
|
||||||
thebet365-api:latest `
|
"thebet365-api:${Tag}" `
|
||||||
thebet365-player:latest `
|
"thebet365-player:${Tag}" `
|
||||||
thebet365-admin:latest `
|
"thebet365-admin:${Tag}" `
|
||||||
-o $OutputPath
|
-o $OutputPath
|
||||||
if ($LASTEXITCODE -ne 0) { throw "docker save 失败,退出码 $LASTEXITCODE" }
|
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)
|
$sizeMb = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
|
||||||
Write-Host "完成: $OutputPath (${sizeMb} MB)"
|
Write-Host "完成: $OutputPath (${sizeMb} MB)"
|
||||||
|
Write-Host "Manifest: $manifestPath"
|
||||||
|
|
||||||
Write-Host @"
|
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
|
# 构建 api / player / admin 生产镜像并导出为 tar
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./docs/docker/build-and-export-images.sh
|
# ./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 --use-cache
|
||||||
# ./build-and-export-images.sh --export-only
|
# ./build-and-export-images.sh --export-only
|
||||||
|
|
||||||
@@ -13,25 +14,44 @@ cd "$ROOT"
|
|||||||
|
|
||||||
COMPOSE_FILE="docker-compose.prod.yml"
|
COMPOSE_FILE="docker-compose.prod.yml"
|
||||||
ENV_FILE=".env.docker"
|
ENV_FILE=".env.docker"
|
||||||
OUTPUT="thebet365-images.tar"
|
OUTPUT=""
|
||||||
SERVICES=(api player admin)
|
SERVICES=(api player admin)
|
||||||
NO_CACHE=1
|
NO_CACHE=1
|
||||||
EXPORT_ONLY=0
|
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() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
用法: docs/docker/build-and-export-images.sh [选项]
|
用法: docs/docker/build-and-export-images.sh [选项]
|
||||||
|
|
||||||
选项:
|
选项:
|
||||||
|
--tag TAG 镜像 tag(默认当前 git 短提交号;非 git 目录则用时间戳)
|
||||||
--use-cache 构建时使用 Docker 缓存(默认 --no-cache)
|
--use-cache 构建时使用 Docker 缓存(默认 --no-cache)
|
||||||
--export-only 跳过构建,仅导出已有 latest 镜像
|
--export-only 跳过构建,仅导出已有指定 tag 镜像
|
||||||
--output PATH 导出文件路径(默认项目根目录 thebet365-images.tar)
|
--output PATH 导出文件路径(默认项目根目录 thebet365-images-<tag>.tar)
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
--tag)
|
||||||
|
TAG="${2:?缺少 --tag 参数值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--use-cache) NO_CACHE=0; shift ;;
|
--use-cache) NO_CACHE=0; shift ;;
|
||||||
--export-only) EXPORT_ONLY=1; shift ;;
|
--export-only) EXPORT_ONLY=1; shift ;;
|
||||||
--output)
|
--output)
|
||||||
@@ -44,28 +64,32 @@ while [[ $# -gt 0 ]]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||||
echo "错误: 未找到 $COMPOSE_FILE(目录: $ROOT)" >&2
|
echo "错误: 未找到 ${COMPOSE_FILE}(目录: ${ROOT})" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
if [[ -f ".env.docker.example" ]]; 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"
|
ENV_FILE=".env.docker.example"
|
||||||
else
|
else
|
||||||
echo "错误: 未找到 $ENV_FILE 或 .env.docker.example" >&2
|
echo "错误: 未找到 ${ENV_FILE} 或 .env.docker.example" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
TAG="${TAG:-$(default_tag)}"
|
||||||
|
validate_tag "$TAG"
|
||||||
|
OUTPUT="${OUTPUT:-thebet365-images-$TAG.tar}"
|
||||||
|
|
||||||
if [[ "$EXPORT_ONLY" -eq 0 ]]; then
|
if [[ "$EXPORT_ONLY" -eq 0 ]]; then
|
||||||
echo "==> 构建镜像: ${SERVICES[*]}"
|
echo "==> 构建镜像: ${SERVICES[*]} (tag: $TAG)"
|
||||||
BUILD_ARGS=(compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build)
|
BUILD_ARGS=(compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build)
|
||||||
if [[ "$NO_CACHE" -eq 1 ]]; then
|
if [[ "$NO_CACHE" -eq 1 ]]; then
|
||||||
BUILD_ARGS+=(--no-cache)
|
BUILD_ARGS+=(--no-cache)
|
||||||
fi
|
fi
|
||||||
BUILD_ARGS+=("${SERVICES[@]}")
|
BUILD_ARGS+=("${SERVICES[@]}")
|
||||||
docker "${BUILD_ARGS[@]}"
|
IMAGE_TAG="$TAG" docker "${BUILD_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
OUTPUT_PATH="$OUTPUT"
|
OUTPUT_PATH="$OUTPUT"
|
||||||
@@ -75,23 +99,59 @@ fi
|
|||||||
|
|
||||||
echo "==> 导出镜像 -> $OUTPUT_PATH"
|
echo "==> 导出镜像 -> $OUTPUT_PATH"
|
||||||
docker save \
|
docker save \
|
||||||
thebet365-api:latest \
|
"thebet365-api:$TAG" \
|
||||||
thebet365-player:latest \
|
"thebet365-player:$TAG" \
|
||||||
thebet365-admin:latest \
|
"thebet365-admin:$TAG" \
|
||||||
-o "$OUTPUT_PATH"
|
-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
|
if command -v du >/dev/null 2>&1; then
|
||||||
SIZE="$(du -h "$OUTPUT_PATH" | awk '{print $1}')"
|
SIZE="$(du -h "$OUTPUT_PATH" | awk '{print $1}')"
|
||||||
echo "完成: $OUTPUT_PATH ($SIZE)"
|
echo "完成: $OUTPUT_PATH ($SIZE)"
|
||||||
else
|
else
|
||||||
echo "完成: $OUTPUT_PATH"
|
echo "完成: $OUTPUT_PATH"
|
||||||
fi
|
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
|
EOF
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| `docs/docker/build-and-export-images.ps1` | Windows(PowerShell) |
|
| `docs/docker/build-and-export-images.ps1` | Windows(PowerShell) |
|
||||||
| `docs/docker/build-and-export-images.sh` | Linux / macOS / Git Bash |
|
| `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` 中配置:
|
生产环境务必在 `.env.docker` 中配置:
|
||||||
|
|
||||||
- `POSTGRES_PASSWORD`、`JWT_SECRET`
|
- `POSTGRES_PASSWORD`、`JWT_SECRET`
|
||||||
|
- `IMAGE_TAG`、`BIND_ADDR`、`RUN_MIGRATIONS_ON_START` 保持 `.env.docker.example` 默认即可,部署脚本会按 `--tag` 写回真实版本
|
||||||
- `CHUANGLAN_ACCOUNT`、`CHUANGLAN_PASSWORD`(短信注册)
|
- `CHUANGLAN_ACCOUNT`、`CHUANGLAN_PASSWORD`(短信注册)
|
||||||
- `SEED_DATABASE=false`(生产建议保持 false,由部署脚本按需一次性 seed)
|
- `SEED_DATABASE=false`(生产建议保持 false,由部署脚本按需一次性 seed)
|
||||||
|
|
||||||
@@ -58,9 +59,10 @@ chmod +x docs/docker/build-and-export-images.sh
|
|||||||
|
|
||||||
| PowerShell | Bash | 说明 |
|
| PowerShell | Bash | 说明 |
|
||||||
|------------|------|------|
|
|------------|------|------|
|
||||||
|
| `-Tag v1.2.3` | `--tag v1.2.3` | 指定镜像 tag;未指定时默认当前 Git 短提交号 |
|
||||||
| (默认) | (默认) | `--no-cache` 全量构建,适合发版 |
|
| (默认) | (默认) | `--no-cache` 全量构建,适合发版 |
|
||||||
| `-UseCache` | `--use-cache` | 使用 Docker 缓存,构建更快 |
|
| `-UseCache` | `--use-cache` | 使用 Docker 缓存,构建更快 |
|
||||||
| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有 `latest` 镜像 |
|
| `-ExportOnly` | `--export-only` | 跳过构建,仅导出已有指定 tag 镜像 |
|
||||||
| `-Output my.tar` | `--output my.tar` | 自定义导出文件名 |
|
| `-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-api:<tag>` | NestJS API(迁移由部署脚本执行) |
|
||||||
| `thebet365-player:latest` | 玩家前台(Nginx 静态资源) |
|
| `thebet365-player:<tag>` | 玩家前台(Nginx 静态资源) |
|
||||||
| `thebet365-admin:latest` | 管理后台(Nginx 静态资源) |
|
| `thebet365-admin:<tag>` | 管理后台(Nginx 静态资源) |
|
||||||
|
|
||||||
导出文件默认路径:
|
导出文件默认路径:
|
||||||
|
|
||||||
```text
|
```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`):
|
将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`):
|
||||||
|
|
||||||
- `thebet365-images.tar`
|
- `thebet365-images-<tag>.tar`
|
||||||
|
- `thebet365-images-<tag>.manifest.txt`
|
||||||
- `docker-compose.prod.yml`
|
- `docker-compose.prod.yml`
|
||||||
- `.env.docker`(或服务器上已有配置)
|
- `.env.docker`(或服务器上已有配置)
|
||||||
- `docker/nginx/` 等 compose 依赖目录(若仅 load 镜像、不 rebuild,compose 文件仍需要)
|
- `docker/nginx/` 等 compose 依赖目录(若仅 load 镜像、不 rebuild,compose 文件仍需要)
|
||||||
@@ -111,16 +115,16 @@ chmod +x docs/docker/build-and-export-images.sh
|
|||||||
```bash
|
```bash
|
||||||
cd /www/wwwroot/thebet365
|
cd /www/wwwroot/thebet365
|
||||||
chmod +x scripts/*.sh
|
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
|
```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. 验证
|
### 3. 验证
|
||||||
|
|
||||||
@@ -131,8 +135,8 @@ docker logs thebet365-api --tail 50
|
|||||||
|
|
||||||
浏览器访问(端口以 `.env.docker` 为准):
|
浏览器访问(端口以 `.env.docker` 为准):
|
||||||
|
|
||||||
- 玩家端:`http://服务器IP:8082`
|
- 玩家端:经宝塔反代访问,或服务器本机 `http://127.0.0.1:8082`
|
||||||
- 管理端:`http://服务器IP:8081`
|
- 管理端:经宝塔反代访问,或服务器本机 `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 | 首次/全量构建慢,小内存机器易失败 |
|
| **服务器 `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 {} +
|
! -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 启动后不断重启
|
### 4. API 启动后不断重启
|
||||||
|
|
||||||
@@ -182,7 +186,8 @@ docker logs thebet365-api
|
|||||||
thebet365/
|
thebet365/
|
||||||
├── docker-compose.prod.yml
|
├── docker-compose.prod.yml
|
||||||
├── .env.docker.example
|
├── .env.docker.example
|
||||||
├── thebet365-images.tar # 导出产物(默认,已 gitignore)
|
├── thebet365-images-<tag>.tar # 导出产物(默认,已 gitignore)
|
||||||
|
├── thebet365-images-<tag>.manifest.txt
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── api/Dockerfile
|
│ ├── api/Dockerfile
|
||||||
│ ├── player/Dockerfile
|
│ ├── player/Dockerfile
|
||||||
|
|||||||
@@ -387,7 +387,8 @@ cd /www/wwwroot/thebet365
|
|||||||
./scripts/deploy-update.sh --pull
|
./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;服务器上单独保留
|
- **不要**把 `.env.docker` 提交到 Git;服务器上单独保留
|
||||||
- `release/*.zip` 为旧打包方式,Git 同步后不必再生成上传
|
- `release/*.zip` 为旧打包方式,Git 同步后不必再生成上传
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ pnpm db:seed
|
|||||||
CONFIRM=YES ./scripts/prod-init-db.sh
|
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
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
|
||||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
$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 ..."
|
Write-Host "Backing up $DbName to $outFile ..."
|
||||||
$env:PGPASSWORD = $env:THEBET365_DB_PASSWORD
|
$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."
|
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 "Done: $outFile"
|
||||||
|
Write-Host "Checksum: $outFile.sha256"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 备份生产 Docker PostgreSQL 到 ./backups
|
# 备份生产 Docker PostgreSQL 到 ./backups,输出 gzip 与 sha256 校验文件
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -44,3 +44,4 @@ require_docker
|
|||||||
ensure_env_file || exit 1
|
ensure_env_file || exit 1
|
||||||
start_infra
|
start_infra
|
||||||
backup_database "$PREFIX"
|
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 不构建镜像,直接使用服务器已有镜像
|
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||||
|
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||||
--no-seed 不执行生产 seed
|
--no-seed 不执行生产 seed
|
||||||
--init-db 调用 scripts/prod-init-db.sh 清空业务数据并初始化生产数据,仅限全新库或明确重置
|
--init-db 调用 scripts/prod-init-db.sh 清空业务数据并初始化生产数据,仅限全新库或明确重置
|
||||||
--skip-backup 与 --init-db 一起使用,跳过 prod-init-db 的备份
|
--skip-backup 与 --init-db 一起使用,跳过 prod-init-db 的备份
|
||||||
@@ -37,7 +38,7 @@ usage() {
|
|||||||
|
|
||||||
示例:
|
示例:
|
||||||
./scripts/deploy-first.sh
|
./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 --no-build
|
||||||
./scripts/deploy-first.sh --init-db
|
./scripts/deploy-first.sh --init-db
|
||||||
EOF
|
EOF
|
||||||
@@ -54,6 +55,10 @@ while [ $# -gt 0 ]; do
|
|||||||
NO_BUILD=true
|
NO_BUILD=true
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--tag)
|
||||||
|
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--no-seed)
|
--no-seed)
|
||||||
RUN_SEED=false
|
RUN_SEED=false
|
||||||
shift
|
shift
|
||||||
@@ -86,6 +91,14 @@ require_docker
|
|||||||
ensure_env_file || exit 1
|
ensure_env_file || exit 1
|
||||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
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
|
if [ -n "$IMAGE_TAR" ]; then
|
||||||
load_image_tar "$IMAGE_TAR"
|
load_image_tar "$IMAGE_TAR"
|
||||||
fi
|
fi
|
||||||
@@ -96,6 +109,7 @@ if [ "$NO_BUILD" = false ]; then
|
|||||||
build_app_images
|
build_app_images
|
||||||
else
|
else
|
||||||
log "跳过镜像构建,使用服务器已有镜像"
|
log "跳过镜像构建,使用服务器已有镜像"
|
||||||
|
require_images_for_current_tag
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_prisma_migrations
|
run_prisma_migrations
|
||||||
@@ -106,7 +120,7 @@ fi
|
|||||||
|
|
||||||
log "启动 api / player / admin"
|
log "启动 api / player / admin"
|
||||||
compose up -d api player admin
|
compose up -d api player admin
|
||||||
wait_for_service_running api 120
|
wait_for_stack_ready
|
||||||
|
|
||||||
if [ "$INIT_DB" = true ]; then
|
if [ "$INIT_DB" = true ]; then
|
||||||
init_args=()
|
init_args=()
|
||||||
@@ -116,9 +130,11 @@ if [ "$INIT_DB" = true ]; then
|
|||||||
log "执行生产初始化:这会清空业务数据"
|
log "执行生产初始化:这会清空业务数据"
|
||||||
CONFIRM=YES "$ROOT/scripts/prod-init-db.sh" "${init_args[@]}"
|
CONFIRM=YES "$ROOT/scripts/prod-init-db.sh" "${init_args[@]}"
|
||||||
compose restart api
|
compose restart api
|
||||||
wait_for_service_running api 120
|
wait_for_service_health api 180
|
||||||
fi
|
fi
|
||||||
|
|
||||||
show_prisma_status
|
show_prisma_status
|
||||||
compose ps
|
compose ps
|
||||||
|
persist_image_tag
|
||||||
|
record_release_state "first-deploy"
|
||||||
print_stack_urls
|
print_stack_urls
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ COMPOSE_FILE="$ROOT/docker-compose.prod.yml"
|
|||||||
ENV_FILE="$ROOT/.env.docker"
|
ENV_FILE="$ROOT/.env.docker"
|
||||||
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
|
ENV_EXAMPLE_FILE="$ROOT/.env.docker.example"
|
||||||
BACKUP_DIR="$ROOT/backups"
|
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() {
|
log() {
|
||||||
printf '%s\n' "[$(basename "$0")] $*"
|
printf '%s\n' "[$(basename "$0")] $*"
|
||||||
@@ -29,8 +37,36 @@ require_docker() {
|
|||||||
docker compose version >/dev/null 2>&1 || die "未找到 Docker Compose v2,请确认可执行 docker compose"
|
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() {
|
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() {
|
ensure_env_file() {
|
||||||
@@ -61,13 +97,61 @@ env_value() {
|
|||||||
printf '%s' "$line"
|
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() {
|
validate_prod_env() {
|
||||||
local allow_defaults="${1:-false}"
|
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)"
|
postgres_password="$(env_value POSTGRES_PASSWORD)"
|
||||||
jwt_secret="$(env_value JWT_SECRET)"
|
jwt_secret="$(env_value JWT_SECRET)"
|
||||||
seed_database="$(env_value SEED_DATABASE)"
|
seed_database="$(env_value SEED_DATABASE)"
|
||||||
|
bind_addr="$(env_value BIND_ADDR)"
|
||||||
|
|
||||||
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
|
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
|
||||||
[ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET"
|
[ -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"
|
warn ".env.docker 中 SEED_DATABASE=true 会让 api 每次启动都执行 seed;生产建议设为 false,首次部署脚本会按需一次性 seed"
|
||||||
fi
|
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
|
if [ "$(env_value CHUANGLAN_ACCOUNT)" = "your_account" ] || [ "$(env_value CHUANGLAN_PASSWORD)" = "your_password" ]; then
|
||||||
warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置"
|
warn "创蓝短信账号仍是示例值,短信验证码功能上线前需要改为真实配置"
|
||||||
fi
|
fi
|
||||||
@@ -103,35 +191,17 @@ wait_for_service_health() {
|
|||||||
|
|
||||||
now="$(date +%s)"
|
now="$(date +%s)"
|
||||||
if [ $((now - start)) -ge "$timeout" ]; then
|
if [ $((now - start)) -ge "$timeout" ]; then
|
||||||
compose logs --tail=80 "$service" || true
|
compose logs --tail=120 "$service" || true
|
||||||
die "$service 在 ${timeout}s 内未就绪"
|
die "$service 在 ${timeout}s 内未就绪"
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_service_running() {
|
wait_for_stack_ready() {
|
||||||
local service="$1"
|
wait_for_service_health api 180
|
||||||
local timeout="${2:-120}"
|
wait_for_service_health player 120
|
||||||
local start now container_id running
|
wait_for_service_health admin 120
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start_infra() {
|
start_infra() {
|
||||||
@@ -142,7 +212,7 @@ start_infra() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_app_images() {
|
build_app_images() {
|
||||||
log "构建 api / player / admin 镜像"
|
log "构建 api / player / admin 镜像: $(current_image_tag)"
|
||||||
compose build api player admin
|
compose build api player admin
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +223,14 @@ load_image_tar() {
|
|||||||
docker load -i "$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() {
|
run_prisma_migrations() {
|
||||||
log "执行 Prisma 迁移"
|
log "执行 Prisma 迁移"
|
||||||
compose run --rm --no-deps --entrypoint sh api -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate'
|
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'
|
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() {
|
backup_database() {
|
||||||
local prefix="${1:-manual}"
|
local prefix="${1:-manual}"
|
||||||
local stamp backup_file
|
local stamp backup_file
|
||||||
|
|
||||||
|
require_command gzip
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
prefix="$(safe_backup_prefix "$prefix")"
|
||||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
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"
|
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"
|
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() {
|
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:]'
|
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
|
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() {
|
print_stack_urls() {
|
||||||
local player_port admin_port
|
local player_port admin_port bind_addr tag
|
||||||
player_port="$(env_value PLAYER_PORT)"
|
player_port="$(env_value PLAYER_PORT)"
|
||||||
admin_port="$(env_value ADMIN_PORT)"
|
admin_port="$(env_value ADMIN_PORT)"
|
||||||
|
bind_addr="$(env_value BIND_ADDR)"
|
||||||
|
tag="$(current_image_tag)"
|
||||||
player_port="${player_port:-8082}"
|
player_port="${player_port:-8082}"
|
||||||
admin_port="${admin_port:-8081}"
|
admin_port="${admin_port:-8081}"
|
||||||
|
bind_addr="${bind_addr:-127.0.0.1}"
|
||||||
|
|
||||||
printf '\n'
|
printf '\n'
|
||||||
printf '%s\n' "部署完成:"
|
printf '%s\n' "部署完成:"
|
||||||
printf '%s\n' " 玩家端: http://服务器IP:${player_port}"
|
printf '%s\n' " 镜像 tag: ${tag}"
|
||||||
printf '%s\n' " 管理端: http://服务器IP:${admin_port}"
|
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' " API: 仅在 Docker 网络内暴露,由 player/admin 容器和宝塔反代链路访问"
|
||||||
printf '%s\n' " 状态: docker compose -f docker-compose.prod.yml --env-file .env.docker ps"
|
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
|
#!/usr/bin/env bash
|
||||||
# 后续更新:可选拉代码/加载镜像,备份数据库,构建或使用新镜像,执行迁移并滚动到新容器。
|
# 后续更新:可选拉代码/加载镜像,备份数据,构建或使用新镜像,执行迁移并滚动到新容器。
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ usage() {
|
|||||||
默认流程:
|
默认流程:
|
||||||
1. 检查 .env.docker
|
1. 检查 .env.docker
|
||||||
2. 启动并等待 postgres / redis
|
2. 启动并等待 postgres / redis
|
||||||
3. 备份 PostgreSQL 到 ./backups
|
3. 备份 PostgreSQL 与 uploads 到 ./backups
|
||||||
4. 构建 api / player / admin 镜像
|
4. 构建 api / player / admin 镜像
|
||||||
5. 使用新 api 镜像执行 prisma migrate deploy
|
5. 使用新 api 镜像执行 prisma migrate deploy
|
||||||
6. 启动/替换 api、player、admin 容器
|
6. 启动/替换 api、player、admin 容器
|
||||||
@@ -29,14 +29,15 @@ usage() {
|
|||||||
选项:
|
选项:
|
||||||
--pull 先执行 git pull --ff-only
|
--pull 先执行 git pull --ff-only
|
||||||
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
--images PATH 先 docker load 指定镜像 tar,并自动跳过本机构建
|
||||||
|
--tag TAG 使用指定镜像 tag,并写入 .env.docker 的 IMAGE_TAG
|
||||||
--no-build 不构建镜像,直接使用服务器已有镜像
|
--no-build 不构建镜像,直接使用服务器已有镜像
|
||||||
--no-backup 跳过数据库备份
|
--no-backup 跳过 PostgreSQL 与 uploads 备份
|
||||||
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
--allow-default-secrets 允许 .env.docker 使用示例密钥,仅测试环境使用
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
|
|
||||||
示例:
|
示例:
|
||||||
./scripts/deploy-update.sh --pull
|
./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
|
./scripts/deploy-update.sh --no-build
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,10 @@ while [ $# -gt 0 ]; do
|
|||||||
NO_BUILD=true
|
NO_BUILD=true
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--tag)
|
||||||
|
set_deploy_image_tag "${2:?缺少 --tag 参数值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--no-build)
|
--no-build)
|
||||||
NO_BUILD=true
|
NO_BUILD=true
|
||||||
shift
|
shift
|
||||||
@@ -79,6 +84,14 @@ require_docker
|
|||||||
ensure_env_file || exit 1
|
ensure_env_file || exit 1
|
||||||
validate_prod_env "$ALLOW_DEFAULT_SECRETS"
|
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
|
if [ "$PULL_CODE" = true ]; then
|
||||||
require_command git
|
require_command git
|
||||||
log "拉取代码: git pull --ff-only"
|
log "拉取代码: git pull --ff-only"
|
||||||
@@ -93,21 +106,26 @@ start_infra
|
|||||||
|
|
||||||
if [ "$SKIP_BACKUP" = false ]; then
|
if [ "$SKIP_BACKUP" = false ]; then
|
||||||
backup_database "pre-update"
|
backup_database "pre-update"
|
||||||
|
backup_uploads "pre-update"
|
||||||
|
prune_old_backups
|
||||||
else
|
else
|
||||||
warn "已跳过数据库备份"
|
warn "已跳过 PostgreSQL 与 uploads 备份"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$NO_BUILD" = false ]; then
|
if [ "$NO_BUILD" = false ]; then
|
||||||
build_app_images
|
build_app_images
|
||||||
else
|
else
|
||||||
log "跳过镜像构建,使用服务器已有镜像"
|
log "跳过镜像构建,使用服务器已有镜像"
|
||||||
|
require_images_for_current_tag
|
||||||
fi
|
fi
|
||||||
|
|
||||||
run_prisma_migrations
|
run_prisma_migrations
|
||||||
|
|
||||||
log "启动/更新 api / player / admin"
|
log "启动/更新 api / player / admin"
|
||||||
compose up -d api player admin
|
compose up -d api player admin
|
||||||
wait_for_service_running api 120
|
wait_for_stack_ready
|
||||||
show_prisma_status
|
show_prisma_status
|
||||||
compose ps
|
compose ps
|
||||||
|
persist_image_tag
|
||||||
|
record_release_state "update"
|
||||||
print_stack_urls
|
print_stack_urls
|
||||||
|
|||||||
@@ -38,9 +38,28 @@ if (-not $SkipBackup) {
|
|||||||
$backupDir = Join-Path $Root "backups"
|
$backupDir = Join-Path $Root "backups"
|
||||||
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
|
||||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
$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"
|
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 {
|
} else {
|
||||||
Write-Host "[prod-init-db] 已跳过备份"
|
Write-Host "[prod-init-db] 已跳过备份"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,9 +49,16 @@ if [ "$SKIP_BACKUP" = false ]; then
|
|||||||
BACKUP_DIR="$ROOT/backups"
|
BACKUP_DIR="$ROOT/backups"
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
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"
|
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] 备份完成"
|
echo "[prod-init-db] 备份完成"
|
||||||
else
|
else
|
||||||
echo "[prod-init-db] 已跳过备份 (--skip-backup)"
|
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