diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b86bcea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# ===== 版本控制 ===== +.git +.gitignore + +# ===== 文档(不参与编译)===== +*.md +docs/ +design/ + +# ===== 本地运行产物(日志)===== +storage/ + +# ===== 配置与密钥:配置在运行时挂载,禁止进入构建上下文与镜像层 ===== +configs/ + +# ===== 其他与编译无关资产 ===== +scripts/ +Dockerfile +.dockerignore diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..4d71ccb --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,123 @@ +name: 构建并部署 + +on: + push: + tags: ['v*'] + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +env: + REGISTRY: git.lzcwy.cn + IMAGE: git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 确认 docker 可用 + run: | + docker version --format 'Server: {{.Server.Version}}' + + - name: 计算镜像标签 + id: meta + run: | + case "$GITEA_REF" in + refs/tags/*) TAG="${GITEA_REF#refs/tags/}" ;; + *) TAG="sha-$(echo "$GITEA_SHA" | cut -c1-7)" ;; + esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "镜像标签: $TAG" + + - name: 登录 Gitea 镜像仓库 + env: + REGISTRY_USER: ${{ secrets.ZT_CLOSET_MANAGE_REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.ZT_CLOSET_MANAGE_REGISTRY_TOKEN }} + run: | + echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + + - name: 构建并推送 + run: | + # 生产机若是 ARM(如甲骨文 ARM 机),去掉下一行注释并改成 linux/arm64 + # PLATFORM="--platform linux/arm64" + docker build \ + --build-arg GOPROXY=https://goproxy.cn,direct \ + -f scripts/dockerfile/Dockerfile \ + -t "$IMAGE:${{ steps.meta.outputs.tag }}" \ + -t "$IMAGE:latest" \ + . + docker push "$IMAGE:${{ steps.meta.outputs.tag }}" + docker push "$IMAGE:latest" + + - name: SSH 部署到生产机 + env: + SSH_KEY: ${{ secrets.ZT_CLOSET_MANAGE_PROD_SSH_KEY }} + SSH_HOST: ${{ vars.ZT_CLOSET_MANAGE_PROD_SSH_HOST }} + SSH_USER: ${{ vars.ZT_CLOSET_MANAGE_PROD_SSH_USER }} + run: | + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + SSHOPT="-i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + + ssh $SSHOPT "$SSH_USER@$SSH_HOST" 'bash -s' <<'REMOTE' + set -e + APP=zt-closet-manage-backend + IMAGE=git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend + DIR=/opt/zt-closet-manage + + mkdir -p "$DIR/configs" "$DIR/storage/logs" + # 容器内进程是 uid 10001,日志目录要给它写权限 + chown -R 10001:10001 "$DIR/storage" + + # 记住旧镜像,用于失败回滚 + OLD_IMAGE=$(docker inspect -f '{{.Image}}' $APP 2>/dev/null || echo "") + + docker pull $IMAGE:latest + docker rm -f $APP 2>/dev/null || true + + docker run -d \ + --name $APP \ + --network host \ + --restart unless-stopped \ + -v $DIR/configs:/app/configs:ro \ + -v $DIR/storage/logs:/app/storage/logs \ + -e TZ=Asia/Shanghai \ + --log-opt max-size=50m \ + --log-opt max-file=5 \ + $IMAGE:latest + + sleep 5 + # 健康检查端口须与生产 configs/config.yaml 的 Server.HttpPort 一致(当前 8081) + if (command -v curl >/dev/null && curl -fsS http://127.0.0.1:8081/ping) \ + || (command -v wget >/dev/null && wget -qO- http://127.0.0.1:8081/ping); then + echo " 健康检查通过" + docker image prune -f + else + echo " 健康检查失败,回滚" + docker logs --tail 50 $APP + docker rm -f $APP + if [ -n "$OLD_IMAGE" ]; then + docker run -d \ + --name $APP \ + --network host \ + --restart unless-stopped \ + -v $DIR/configs:/app/configs:ro \ + -v $DIR/storage/logs:/app/storage/logs \ + -e TZ=Asia/Shanghai \ + --log-opt max-size=50m \ + --log-opt max-file=5 \ + "$OLD_IMAGE" + fi + exit 1 + fi + REMOTE + + - name: 部署结果 + if: always() + run: echo "镜像 ${{ env.IMAGE }}:${{ steps.meta.outputs.tag }} 部署流程结束" diff --git a/README.md b/README.md index 0850262..7c02523 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ go run . 验证健康检查: ```bash -curl http://localhost:8080/ping +curl http://localhost:8081/ping # {"message":"pong"} ``` @@ -231,6 +231,38 @@ curl http://localhost:8080/ping - 登录接口对「账号不存在」与「密码错误」返回同一错误码,避免账号枚举攻击。 - 生产环境请将 `Server.RunMode` 设为 `release`,并通过反向代理对外提供 HTTPS。 +## CI/CD 与部署 + +### 流水线(Gitea Actions) + +`.gitea/workflows/deploy.yaml` 提供「构建并部署」流水线: + +| 触发方式 | 说明 | +| --- | --- | +| 推送 `v*` 标签 | 如 `git tag v1.0.0 && git push origin v1.0.0` | +| 手动触发 | Gitea 仓库页面 Actions 标签页 → 选择「构建并部署」→ Run workflow | + +流程:拉取代码 → 构建镜像(多阶段 Dockerfile,静态编译)→ 推送 `git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend`(tag + latest)→ SSH 部署生产机 → `/ping` 健康检查;**检查失败自动输出日志并回滚到旧镜像**。 + +> 管理后台无数据库迁移命令(`admin_users` 表由服务启动时 GORM AutoMigrate 幂等创建),因此没有业务服务那样的迁移流水线。 + +### Gitea 仓库 Secrets / Variables(需自行创建) + +| 类型 | 名称 | 说明 | +| --- | --- | --- | +| Secret | `ZT_CLOSET_MANAGE_REGISTRY_USER` | Gitea 镜像仓库登录用户名 | +| Secret | `ZT_CLOSET_MANAGE_REGISTRY_TOKEN` | Gitea 镜像仓库登录令牌 | +| Secret | `ZT_CLOSET_MANAGE_PROD_SSH_KEY` | 生产机部署 SSH 私钥 | +| Variable | `ZT_CLOSET_MANAGE_PROD_SSH_HOST` | 生产机 IP/域名 | +| Variable | `ZT_CLOSET_MANAGE_PROD_SSH_USER` | 生产机 SSH 用户 | + +### 生产机准备 + +- 部署目录:`/opt/zt-closet-manage`(流水线会自动创建 `configs/` 与 `storage/logs/`)。 +- 手动准备 `configs/config.yaml`(见上文配置说明);其 `Server.HttpPort` 必须为 `8081`(与流水线健康检查端口一致)。 +- 容器以非 root 用户(uid 10001)运行,日志目录权限由流水线自动设置。 +- 本地构建验证:`docker build --build-arg GOPROXY=https://goproxy.cn,direct -f scripts/dockerfile/Dockerfile -t zt-closet-manage-backend:test .` + ## 部署提示 - **工作目录**:启动时必须位于项目根目录(配置路径固定为 `configs/config.yaml`)。 diff --git a/configs/config.example.yaml b/configs/config.example.yaml index d0f6e80..13740e8 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -1,7 +1,7 @@ Server: Domain: admin.example.com #服务域名,用于 CORS 允许来源(http/https 均放行);置 * 或不填则允许所有来源 RunMode: release #gin运行模式,debug 和 release - HttpPort: 8080 + HttpPort: 8081 ReadTimeout: 60 WriteTimeout: 60 Log: diff --git a/scripts/dockerfile/Dockerfile b/scripts/dockerfile/Dockerfile new file mode 100644 index 0000000..a9ae6e0 --- /dev/null +++ b/scripts/dockerfile/Dockerfile @@ -0,0 +1,61 @@ +ARG GO_VERSION=1.26 +ARG ALPINE_VERSION=3.22 + +# ============================== 构建阶段 ============================== +FROM golang:${GO_VERSION}-alpine AS builder + +# TARGETOS/TARGETARCH 由 BuildKit 在 --platform 下自动注入,此处默认值保证 +# 未启用 buildx 时也能正常构建(默认 linux/amd64)。 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +# Go 模块代理:默认官方代理;国内或受限网络可覆盖: +# --build-arg GOPROXY=https://goproxy.cn,direct +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} + +WORKDIR /src + +# 先只复制依赖清单,源码未变动时复用 go mod 下载缓存,加快重建。 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# 静态编译:CGO_ENABLED=0 使二进制不依赖 libc,可运行在任意精简基础镜像; +# -trimpath 去除本地绝对路径保证构建可复现,-s -w 去除符号表与调试信息以减小体积。 +ENV CGO_ENABLED=0 +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /out/zt-closet-manage-backend . + +# ============================== 运行阶段 ============================== +FROM alpine:${ALPINE_VERSION} + +# ca-certificates:访问 HTTPS 端点需要根证书; +# tzdata:业务按本地时区计算「每日 0 点」相关展示,需与 TZ 配合使用; +# 运行进程使用非 root 用户 app(uid/gid 10001)。 +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -g 10001 -S app \ + && adduser -u 10001 -S -G app app + +WORKDIR /app + +COPY --from=builder /out/zt-closet-manage-backend /app/zt-closet-manage-backend + +# configs/ :配置文件挂载点(config.yaml 含密钥,不打入镜像,必须运行时挂载) +# storage/logs:日志目录(对应配置 Log.LogSavePath) +RUN mkdir -p /app/configs /app/storage/logs \ + && chown -R app:app /app + +ENV TZ=Asia/Shanghai +# 仅用于 EXPOSE 与健康检查:真实监听端口由 configs/config.yaml 的 Server.HttpPort 决定,两者需保持一致(当前 8081)。 +ENV APP_PORT=8081 + +USER app + +EXPOSE ${APP_PORT} + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget -q -O - "http://127.0.0.1:${APP_PORT}/ping" || exit 1 + +# 启动服务(admin_users 表由服务启动时 AutoMigrate 幂等创建,无独立迁移命令) +CMD ["/app/zt-closet-manage-backend"]