diff --git a/deploy/README.md b/deploy/README.md index c46e8e4..237d0b7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,5 +1,7 @@ # KRA CI/CD 部署 +sw2.fuhua91.com 在 226 服务器的 Docker 部署见 [Docker 部署说明](docker/README.md)。下面保留的是通用 Kubernetes/Jenkins 模板说明。 + deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的新项目可以整体复制后只改一个参数块。 > 完整的环境配置、凭据来源与排障说明见 [DEPLOYMENT.md](DEPLOYMENT.md)。 diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 0000000..fa58983 --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,40 @@ +# sw2 Docker 部署 + +沿用 226 服务器 `/www/wwwroot/live.fuhua91.com` 的 Docker 网络、持久化目录和宝塔反向代理结构。使用独立的 `sw2-backend`、`sw2-frontend`、`sw2-net`,兼容服务器现有 Docker 18.06,不依赖 Compose。 + +## 目录与端口 + +部署根目录为 `/www/wwwroot/sw2.fuhua91.com`: + +- `config/config.yaml`:后端运行配置,挂载到 `/data/conf`,UID 1000 可写。 +- `runtime.env`:SDK 兼容参数,通过 Docker `--env-file` 加载,权限 600。 +- `log/`、`uploads/`:日志与文件持久化目录。 +- `releases//`:已校验的发布包;`current-release` 记录当前版本。 +- 后端仅绑定 `127.0.0.1:9602`,前端仅绑定 `127.0.0.1:9601`。 +- 宝塔 `sw2.fuhua91.com` 代理至 9601,前端容器将 `/api/` 转发至后端并去除前缀,支持 WebSocket、SSE 和文件上传。 + +数据库沿用已迁移并验证的 `sw9`,平台别名保留 `lx/hw/yq/oa`,Redis 沿用当前关闭状态。生产配置使用 `admin.app.env: production`,关闭开发 Swagger。五条常规商务任务启用,两个历史回填停用。**使用同一 sw9 时只运行一个调度实例,切换到服务器前停止本机的后端服务。** + +## 构建与发布 + +Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行: + +```powershell +.\deploy\docker\build-package.ps1 +``` + +输出位于 `.cache/deploy/sw2-.tar.gz` 及对应 SHA-256 文件。后端交叉编译为 Linux amd64,前端执行生产构建;包内只含二进制、前端产物、Dockerfile 和部署配置,真实运行配置及环境变量不进入镜像或 Git。 + +通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases//`。首次部署另行准备上述私有配置文件,保持 `admin.router_prefix` 为空,并确认主库为 sw9。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。 + +```bash +bash /www/wwwroot/sw2.fuhua91.com/releases//deploy.sh --build-only +# 构建和容器配置检查通过后,停止连接同一 sw9 的本机后端。 +bash /www/wwwroot/sw2.fuhua91.com/releases//deploy.sh +``` + +脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。健康检查失败时恢复前一组容器。配置、上传文件和数据库不随镜像替换;索引迁移由后端启动按版本执行。 + +首次接入域名时,备份宝塔原站点配置,再安装 `bt-nginx-sw2.fuhua91.com.conf` 至 `/www/server/panel/vhost/nginx/`,执行 `/www/server/nginx/sbin/nginx -t` 通过后重载。模板保留证书验证目录,当前监听 HTTP;后续由宝塔为 sw2 单独配置匹配域名的证书。 + +验证首页、静态资源、`/api/health`、登录状态、商务接口和任务启停/执行日志。回退版本可执行 `bash /www/wwwroot/sw2.fuhua91.com/deploy.sh <旧commit>`;数据库迁移及业务数据不由镜像回退脚本撤销。 diff --git a/deploy/docker/backend.Dockerfile b/deploy/docker/backend.Dockerfile new file mode 100644 index 0000000..cddf2c1 --- /dev/null +++ b/deploy/docker/backend.Dockerfile @@ -0,0 +1,19 @@ +FROM docker.m.daocloud.io/alpine:3.22@sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S -g 1000 sw2 \ + && adduser -S -D -H -u 1000 -G sw2 sw2 \ + && mkdir -p /app/logs /app/uploads/file /app/uploads/chunks /data/conf \ + && chown -R sw2:sw2 /app /data/conf + +ARG VERSION +LABEL org.opencontainers.image.revision=$VERSION +ENV TZ=Asia/Shanghai +COPY --chown=sw2:sw2 kratos-admin /app/kratos-admin +RUN chmod 0755 /app/kratos-admin +WORKDIR /app +USER sw2 +EXPOSE 8000 +HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8000/health || exit 1 +CMD ["/app/kratos-admin", "-conf", "/data/conf"] diff --git a/deploy/docker/bt-nginx-sw2.fuhua91.com.conf b/deploy/docker/bt-nginx-sw2.fuhua91.com.conf new file mode 100644 index 0000000..b0caff9 --- /dev/null +++ b/deploy/docker/bt-nginx-sw2.fuhua91.com.conf @@ -0,0 +1,41 @@ +map $http_upgrade $sw2_site_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name sw2.fuhua91.com; + root /www/wwwroot/sw2.fuhua91.com; + server_tokens off; + client_max_body_size 1024m; + + # 保留宝塔证书验证目录,配置、日志和发布包不作为静态站点提供。 + include /www/server/panel/vhost/nginx/well-known/sw2.fuhua91.com.conf; + include /www/server/panel/vhost/nginx/extension/sw2.fuhua91.com/*.conf; + location ^~ /.well-known/ { + try_files $uri =404; + } + + location ~ /\. { + return 404; + } + + location / { + proxy_pass http://127.0.0.1:9601; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $sw2_site_connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + access_log /www/wwwlogs/sw2.fuhua91.com.log; + error_log /www/wwwlogs/sw2.fuhua91.com.error.log; +} diff --git a/deploy/docker/build-package.ps1 b/deploy/docker/build-package.ps1 new file mode 100644 index 0000000..b6288b6 --- /dev/null +++ b/deploy/docker/build-package.ps1 @@ -0,0 +1,56 @@ +param([string]$Tag = '') + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Push-Location $repoRoot +try { + if (-not $Tag) { $Tag = (& git rev-parse --short=12 HEAD).Trim() } + if ($Tag -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$') { throw 'Invalid release tag' } + & git diff --quiet -- . ':!configs/config.yaml' + if ($LASTEXITCODE -ne 0) { throw 'Commit source changes before packaging' } + & git diff --cached --quiet + if ($LASTEXITCODE -ne 0) { throw 'Commit staged changes before packaging' } + + $outputRoot = Join-Path $repoRoot '.cache\deploy' + $stage = Join-Path $outputRoot $Tag + $archive = Join-Path $outputRoot "sw2-$Tag.tar.gz" + if (Test-Path -LiteralPath $archive) { throw "Release archive already exists: $archive" } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'backend'), (Join-Path $stage 'frontend') | Out-Null + $previous = @{} + try { + foreach ($name in @('GOOS', 'GOARCH', 'CGO_ENABLED')) { + $previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + } + $env:GOOS = 'linux' + $env:GOARCH = 'amd64' + $env:CGO_ENABLED = '0' + & go build -trimpath -ldflags "-s -w -X main.Version=$Tag" -o (Join-Path $stage 'backend\kratos-admin') ./cmd + if ($LASTEXITCODE -ne 0) { throw 'Linux backend build failed' } + } finally { + foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') } + } + + Push-Location (Join-Path $repoRoot 'web') + try { + & pnpm build + if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' } + } finally { Pop-Location } + + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'backend.Dockerfile') -Destination (Join-Path $stage 'backend\Dockerfile') + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'frontend.Dockerfile') -Destination (Join-Path $stage 'frontend\Dockerfile') + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'nginx.conf') -Destination (Join-Path $stage 'frontend\nginx.conf') + $frontendDist = Join-Path $stage 'frontend\dist' + New-Item -ItemType Directory -Force -Path $frontendDist | Out-Null + Get-ChildItem -LiteralPath (Join-Path $repoRoot 'web\dist') | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $frontendDist -Recurse -Force + } + foreach ($name in @('deploy.sh', 'bt-nginx-sw2.fuhua91.com.conf')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name) + } + & tar -czf $archive -C $stage . + if ($LASTEXITCODE -ne 0) { throw 'Release archive failed' } + $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() + [IO.File]::WriteAllText("$archive.sha256", "$hash $([IO.Path]::GetFileName($archive))`n", [Text.UTF8Encoding]::new($false)) + Write-Output "Release: $archive" + Write-Output "SHA256: $hash" +} finally { Pop-Location } diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh new file mode 100644 index 0000000..2ee0a67 --- /dev/null +++ b/deploy/docker/deploy.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +TAG="${1:?Usage: deploy.sh TAG [--build-only]}" +MODE="${2:-}" +[[ "$TAG" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$ ]] || { echo 'Invalid release tag' >&2; exit 1; } +[[ -z "$MODE" || "$MODE" == '--build-only' ]] || { echo 'Unknown mode' >&2; exit 1; } +BASE_DIR=/www/wwwroot/sw2.fuhua91.com +RELEASE="$BASE_DIR/releases/$TAG" +BACKEND_IMAGE="sw2/backend:$TAG" +FRONTEND_IMAGE="sw2/frontend:$TAG" +NETWORK=sw2-net +NAMES=(sw2-backend sw2-frontend) + +command -v docker >/dev/null +command -v curl >/dev/null +test -f "$RELEASE/backend/Dockerfile" +test -f "$RELEASE/frontend/Dockerfile" + +docker build --build-arg "VERSION=$TAG" -t "$BACKEND_IMAGE" "$RELEASE/backend" +docker build --build-arg "VERSION=$TAG" -t "$FRONTEND_IMAGE" "$RELEASE/frontend" +docker run --rm "$BACKEND_IMAGE" /app/kratos-admin -h >/dev/null +docker run --rm --add-host sw2-backend:127.0.0.1 "$FRONTEND_IMAGE" nginx -t +if [[ "$MODE" == '--build-only' ]]; then exit 0; fi + +test -s "$BASE_DIR/config/config.yaml" +test -f "$BASE_DIR/runtime.env" +install -d -o 1000 -g 1000 -m 0750 "$BASE_DIR/config" "$BASE_DIR/log" "$BASE_DIR/uploads" +chown 1000:1000 "$BASE_DIR/config/config.yaml" +chmod 0600 "$BASE_DIR/config/config.yaml" "$BASE_DIR/runtime.env" +if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then + docker network create --label com.sw2.application=sw2 "$NETWORK" +fi + +managed() { + [[ "$(docker inspect --format '{{index .Config.Labels "com.sw2.application"}}' "$1")" == sw2 ]] +} +exists() { docker inspect "$1" >/dev/null 2>&1; } +for name in "${NAMES[@]}"; do + for candidate in "$name" "$name-previous"; do + if exists "$candidate"; then + managed "$candidate" || { echo "Refusing to replace unmanaged container: $candidate" >&2; exit 1; } + fi + done +done + +moved=() +created=() +rollback() { + result=$? + trap - ERR + set +eu + for name in "${created[@]}"; do docker rm -f "$name" >/dev/null 2>&1; done + for name in "${moved[@]}"; do + if exists "$name-previous"; then docker rename "$name-previous" "$name"; fi + docker start "$name" + done + echo 'Deployment failed; previous containers restored where present.' >&2 + exit "$result" +} +trap rollback ERR +for name in "${NAMES[@]}"; do + if exists "$name-previous"; then docker rm "$name-previous" >/dev/null; fi + if exists "$name"; then + moved+=("$name") + docker stop -t 30 "$name" >/dev/null + docker rename "$name" "$name-previous" + fi +done + +created+=(sw2-backend) +docker run -d --name sw2-backend --label com.sw2.application=sw2 \ + --restart unless-stopped --network "$NETWORK" \ + --log-opt max-size=20m --log-opt max-file=3 \ + -p 127.0.0.1:9602:8000 --env-file "$BASE_DIR/runtime.env" \ + -v "$BASE_DIR/config:/data/conf" -v "$BASE_DIR/log:/app/logs" -v "$BASE_DIR/uploads:/app/uploads" \ + "$BACKEND_IMAGE" + +ready=false +for attempt in $(seq 1 45); do + if curl -fsS --max-time 3 http://127.0.0.1:9602/health >/dev/null 2>&1; then ready=true; break; fi + sleep 2 +done +[[ "$ready" == true ]] || { echo 'Backend health check failed' >&2; false; } + +created+=(sw2-frontend) +docker run -d --name sw2-frontend --label com.sw2.application=sw2 \ + --restart unless-stopped --network "$NETWORK" \ + --log-opt max-size=20m --log-opt max-file=3 \ + -p 127.0.0.1:9601:80 "$FRONTEND_IMAGE" + +ready=false +for attempt in $(seq 1 15); do + if curl -fsS --max-time 3 http://127.0.0.1:9601/api/health >/dev/null 2>&1; then ready=true; break; fi + sleep 2 +done +[[ "$ready" == true ]] || { echo 'Frontend API proxy health check failed' >&2; false; } +trap - ERR +printf '%s\n' "$TAG" > "$BASE_DIR/current-release" +install -m 0750 "$RELEASE/deploy.sh" "$BASE_DIR/deploy.sh" +docker ps --filter label=com.sw2.application=sw2 --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}' diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile new file mode 100644 index 0000000..5dd8b11 --- /dev/null +++ b/deploy/docker/frontend.Dockerfile @@ -0,0 +1,8 @@ +FROM docker.m.daocloud.io/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236 + +ARG VERSION +LABEL org.opencontainers.image.revision=$VERSION +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY dist/ /usr/share/nginx/html/ +HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 diff --git a/deploy/docker/nginx.conf b/deploy/docker/nginx.conf new file mode 100644 index 0000000..62f24d2 --- /dev/null +++ b/deploy/docker/nginx.conf @@ -0,0 +1,53 @@ +map $http_upgrade $sw2_connection_upgrade { + default upgrade; + '' close; +} + +map $http_x_forwarded_proto $sw2_forwarded_proto { + default $scheme; + http http; + https https; +} + +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + client_max_body_size 1024m; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 'ok'; + } + + location = /api { + return 308 /api/; + } + + location ^~ /api/ { + proxy_pass http://sw2-backend:8000/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $http_x_real_ip; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $sw2_forwarded_proto; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $sw2_connection_upgrade; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location ~ /\. { + return 404; + } + + location / { + add_header Cache-Control 'no-cache'; + try_files $uri $uri/ /index.html; + } +}