ci: 添加 Gitea Actions CI/CD 和 Knative 部署配置

- 添加 CI workflow(PR 构建检查)
- 添加 Deploy workflow(main 分支自动部署)
- 添加 Web/API 多阶段 Dockerfile
- 添加 Knative Service 配置(自动扩缩容)
- 添加 K8s ConfigMap、Secret、Namespace 配置
- 添加 .dockerignore 优化构建

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-22 17:38:28 +08:00
parent 595d59ab5b
commit 08bd6397c8
11 changed files with 738 additions and 0 deletions

46
.dockerignore Normal file
View File

@@ -0,0 +1,46 @@
# Dependencies
node_modules
**/node_modules
# Build outputs
.next
dist
.turbo
# Development
.env.local
*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
**/Dockerfile*
docker-compose*.yml
# Documentation
docs
*.md
!README.md
# Tests
**/*.test.ts
**/*.spec.ts
coverage
.nyc_output
# Logs
*.log
npm-debug.log*
pnpm-debug.log*

38
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
branches:
- main
env:
PNPM_VERSION: 9
NODE_VERSION: 20
jobs:
build:
name: Build Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma Client
run: pnpm db:generate
- name: Build
run: pnpm build

View File

@@ -0,0 +1,101 @@
name: Deploy
on:
push:
branches:
- main
env:
PNPM_VERSION: 9
NODE_VERSION: 20
REGISTRY: gitea.tegical.world
IMAGE_PREFIX: tegical/seclusion
jobs:
build-and-push:
name: Build and Push Images
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate image tag
id: meta
run: |
echo "tag=$(date +%Y%m%d%H%M%S)-${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:${{ steps.meta.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-web:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/api/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:${{ steps.meta.outputs.tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to Knative
runs-on: ubuntu-latest
needs: build-and-push
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Update image tags in manifests
run: |
IMAGE_TAG=${{ needs.build-and-push.outputs.image_tag }}
sed -i "s|IMAGE_TAG_PLACEHOLDER|${IMAGE_TAG}|g" deploy/k8s/*.yaml
- name: Deploy to Knative
run: |
kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/secret.yaml
kubectl apply -f deploy/k8s/web-ksvc.yaml
kubectl apply -f deploy/k8s/api-ksvc.yaml
- name: Wait for services to be ready
run: |
kubectl wait --for=condition=Ready ksvc/seclusion-web -n seclusion --timeout=300s
kubectl wait --for=condition=Ready ksvc/seclusion-api -n seclusion --timeout=300s
- name: Get service URLs
run: |
echo "Web URL: $(kubectl get ksvc seclusion-web -n seclusion -o jsonpath='{.status.url}')"
echo "API URL: $(kubectl get ksvc seclusion-api -n seclusion -o jsonpath='{.status.url}')"

89
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,89 @@
# syntax=docker/dockerfile:1
# ============================================
# Base stage: Install dependencies
# ============================================
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
# ============================================
# Dependencies stage: Install all dependencies
# ============================================
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/api/prisma ./apps/api/prisma/
COPY packages/shared/package.json ./packages/shared/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/typescript-config/package.json ./packages/typescript-config/
RUN pnpm install --frozen-lockfile
# Generate Prisma Client
WORKDIR /app/apps/api
RUN pnpm db:generate
# ============================================
# Builder stage: Build the application
# ============================================
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages ./packages
COPY . .
# Build shared package first
WORKDIR /app/packages/shared
RUN pnpm build
# Build API application
WORKDIR /app/apps/api
RUN pnpm build
# ============================================
# Production dependencies stage
# ============================================
FROM base AS prod-deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/api/prisma ./apps/api/prisma/
COPY packages/shared/package.json ./packages/shared/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/typescript-config/package.json ./packages/typescript-config/
RUN pnpm install --frozen-lockfile --prod
# Generate Prisma Client for production
WORKDIR /app/apps/api
RUN pnpm db:generate
# ============================================
# Runner stage: Production image
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
# Copy production dependencies
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=prod-deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=prod-deps /app/packages/shared/node_modules ./packages/shared/node_modules
# Copy built application
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
# Copy Prisma schema for migrations (optional)
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
USER nestjs
EXPOSE 4000
WORKDIR /app/apps/api
CMD ["node", "dist/main"]

63
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ============================================
# Base stage: Install dependencies
# ============================================
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /app
# ============================================
# Dependencies stage: Install all dependencies
# ============================================
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/typescript-config/package.json ./packages/typescript-config/
RUN pnpm install --frozen-lockfile
# ============================================
# Builder stage: Build the application
# ============================================
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/packages ./packages
COPY . .
# Build shared package first
WORKDIR /app/packages/shared
RUN pnpm build
# Build web application
WORKDIR /app/apps/web
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ============================================
# Runner stage: Production image
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "apps/web/server.js"]

270
deploy/k8s/README.md Normal file
View File

@@ -0,0 +1,270 @@
# Knative 部署指南
本项目使用 Knative Serving 在 Kubernetes 上部署应用。
## 目录结构
```
.gitea/workflows/
├── ci.yaml # PR 构建检查
└── deploy.yaml # 主分支部署
apps/
├── web/Dockerfile # Web 应用 Dockerfile
└── api/Dockerfile # API 应用 Dockerfile
deploy/k8s/
├── namespace.yaml # 命名空间
├── configmap.yaml # 环境变量配置
├── secret.yaml # 敏感信息配置
├── web-ksvc.yaml # Web Knative Service
└── api-ksvc.yaml # API Knative Service
```
## 前置要求
1. **Kubernetes 集群**:需要安装 Knative Serving
2. **Gitea Container Registry**:镜像仓库地址 `gitea.tegical.world`
3. **kubectl**:本地安装 kubectl 用于手动部署
## Gitea Secrets 配置
在 Gitea 仓库设置 → Secrets 中配置:
| Secret 名称 | 说明 | 生成方式 |
|------------|------|---------|
| `REGISTRY_USERNAME` | Gitea 容器镜像仓库用户名 | - |
| `REGISTRY_PASSWORD` | Gitea 容器镜像仓库密码 | Token 或密码 |
| `KUBECONFIG` | Base64 编码的 kubeconfig | `cat ~/.kube/config \| base64` |
## 配置修改
### 1. ConfigMap (`deploy/k8s/configmap.yaml`)
```yaml
data:
# 修改为实际的 API 地址
NEXT_PUBLIC_API_URL: "https://api.example.com"
# 是否启用加密
NEXT_PUBLIC_ENABLE_ENCRYPTION: "false"
ENABLE_ENCRYPTION: "false"
```
### 2. Secret (`deploy/k8s/secret.yaml`)
**方式一:直接编辑文件(不推荐)**
```yaml
stringData:
DATABASE_URL: "postgresql://user:pass@host:5432/db"
REDIS_URL: "redis://host:6379"
JWT_SECRET: "your-secret-here"
# ... 其他配置
```
**方式二:使用 kubectl 创建(推荐)**
```bash
kubectl create secret generic seclusion-secret -n seclusion \
--from-literal=DATABASE_URL='postgresql://user:pass@host:5432/db' \
--from-literal=REDIS_URL='redis://host:6379' \
--from-literal=JWT_SECRET='your-jwt-secret' \
--from-literal=JWT_EXPIRES_IN='7d' \
--from-literal=JWT_REFRESH_SECRET='your-refresh-secret' \
--from-literal=JWT_REFRESH_EXPIRES_IN='30d' \
--from-literal=ENCRYPTION_KEY='' \
--from-literal=NEXT_PUBLIC_ENCRYPTION_KEY=''
```
### 3. Knative Service 配置
**自动扩缩容配置** (`deploy/k8s/web-ksvc.yaml``deploy/k8s/api-ksvc.yaml`)
```yaml
metadata:
annotations:
# 最小实例数0 允许缩容到零)
autoscaling.knative.dev/min-scale: "1"
# 最大实例数
autoscaling.knative.dev/max-scale: "10"
# 每个实例的目标并发请求数
autoscaling.knative.dev/target: "100"
```
**资源限制**
```yaml
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
```
## 部署流程
### 自动部署(推荐)
1. **创建 PR** → 触发 CI workflow 进行构建检查
2. **合并到 main** → 自动构建镜像并部署到 Knative
### 手动部署
```bash
# 1. 构建镜像
docker build -f apps/web/Dockerfile -t gitea.tegical.world/tegical/seclusion-web:latest .
docker build -f apps/api/Dockerfile -t gitea.tegical.world/tegical/seclusion-api:latest .
# 2. 推送镜像
docker push gitea.tegical.world/tegical/seclusion-web:latest
docker push gitea.tegical.world/tegical/seclusion-api:latest
# 3. 部署到 Knative
kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/secret.yaml
kubectl apply -f deploy/k8s/web-ksvc.yaml
kubectl apply -f deploy/k8s/api-ksvc.yaml
# 4. 等待服务就绪
kubectl wait --for=condition=Ready ksvc/seclusion-web -n seclusion --timeout=300s
kubectl wait --for=condition=Ready ksvc/seclusion-api -n seclusion --timeout=300s
# 5. 查看服务 URL
kubectl get ksvc -n seclusion
```
## 查看部署状态
```bash
# 查看 Knative Service 状态
kubectl get ksvc -n seclusion
# 查看 Pod 状态
kubectl get pods -n seclusion
# 查看服务日志
kubectl logs -n seclusion -l serving.knative.dev/service=seclusion-web
kubectl logs -n seclusion -l serving.knative.dev/service=seclusion-api
# 查看服务详情
kubectl describe ksvc seclusion-web -n seclusion
kubectl describe ksvc seclusion-api -n seclusion
```
## 域名配置
Knative 会自动为每个 Service 分配一个 URL格式通常为
- `http://seclusion-web.seclusion.{knative-domain}`
- `http://seclusion-api.seclusion.{knative-domain}`
### 配置自定义域名
创建 `deploy/k8s/domain-mapping.yaml`
```yaml
apiVersion: serving.knative.dev/v1alpha1
kind: DomainMapping
metadata:
name: seclusion.example.com
namespace: seclusion
spec:
ref:
name: seclusion-web
kind: Service
apiVersion: serving.knative.dev/v1
---
apiVersion: serving.knative.dev/v1alpha1
kind: DomainMapping
metadata:
name: api.seclusion.example.com
namespace: seclusion
spec:
ref:
name: seclusion-api
kind: Service
apiVersion: serving.knative.dev/v1
```
应用配置:
```bash
kubectl apply -f deploy/k8s/domain-mapping.yaml
```
## 故障排查
### 镜像拉取失败
```bash
# 检查镜像仓库凭证
kubectl get secret -n seclusion
# 如需要私有镜像,创建 pull secret
kubectl create secret docker-registry gitea-registry \
--docker-server=gitea.tegical.world \
--docker-username=<username> \
--docker-password=<password> \
-n seclusion
# 在 Knative Service 中引用
spec:
template:
spec:
imagePullSecrets:
- name: gitea-registry
```
### 服务无法就绪
```bash
# 查看事件
kubectl get events -n seclusion --sort-by='.lastTimestamp'
# 查看 Revision 状态
kubectl get revision -n seclusion
# 查看详细日志
kubectl logs -n seclusion -l serving.knative.dev/service=seclusion-web --tail=100
```
### 数据库连接失败
确认 `DATABASE_URL``REDIS_URL` 配置正确,且集群内可访问数据库。
## 回滚
```bash
# 查看历史 Revision
kubectl get revision -n seclusion
# 回滚到指定 Revision
kubectl patch ksvc seclusion-web -n seclusion --type='json' \
-p='[{"op": "replace", "path": "/spec/traffic", "value": [{"revisionName": "seclusion-web-00001", "percent": 100}]}]'
```
## 性能优化
### 1. 调整并发限制
```yaml
spec:
template:
spec:
# 0 表示无限制,建议设置合理值
containerConcurrency: 100
```
### 2. 调整扩缩容策略
```yaml
metadata:
annotations:
# 缩容窗口期(秒)
autoscaling.knative.dev/scale-down-delay: "30s"
# 稳定窗口期(秒)
autoscaling.knative.dev/window: "60s"
```
### 3. 启用缓存优化
在 Dockerfile 中已使用多阶段构建和 BuildKit 缓存优化构建速度。

42
deploy/k8s/api-ksvc.yaml Normal file
View File

@@ -0,0 +1,42 @@
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: seclusion-api
namespace: seclusion
labels:
app: seclusion-api
spec:
template:
metadata:
annotations:
# 最小实例数API 服务建议保持至少 1 个实例)
autoscaling.knative.dev/min-scale: "1"
# 最大实例数
autoscaling.knative.dev/max-scale: "10"
# 每个实例的并发请求数
autoscaling.knative.dev/target: "100"
spec:
containerConcurrency: 0
containers:
- name: api
image: gitea.tegical.world/tegical/seclusion-api:IMAGE_TAG_PLACEHOLDER
ports:
- containerPort: 4000
envFrom:
- configMapRef:
name: seclusion-config
- secretRef:
name: seclusion-secret
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /api/health
port: 4000
initialDelaySeconds: 5
periodSeconds: 5

14
deploy/k8s/configmap.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: seclusion-config
namespace: seclusion
data:
# Web 配置
NEXT_PUBLIC_API_URL: "https://api.example.com"
NEXT_PUBLIC_ENABLE_ENCRYPTION: "false"
# API 配置
NODE_ENV: "production"
PORT: "4000"
ENABLE_ENCRYPTION: "false"

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: seclusion
labels:
app.kubernetes.io/name: seclusion

27
deploy/k8s/secret.yaml Normal file
View File

@@ -0,0 +1,27 @@
# 注意:此文件仅作为模板,实际部署时需要手动创建 Secret 或使用外部密钥管理
# kubectl create secret generic seclusion-secret -n seclusion \
# --from-literal=DATABASE_URL='postgresql://...' \
# --from-literal=REDIS_URL='redis://...' \
# --from-literal=JWT_SECRET='your-jwt-secret' \
# --from-literal=ENCRYPTION_KEY='your-encryption-key'
apiVersion: v1
kind: Secret
metadata:
name: seclusion-secret
namespace: seclusion
type: Opaque
stringData:
# 数据库连接
DATABASE_URL: "postgresql://user:password@postgres:5432/seclusion"
REDIS_URL: "redis://redis:6379"
# JWT 配置
JWT_SECRET: "change-me-in-production"
JWT_EXPIRES_IN: "7d"
JWT_REFRESH_SECRET: "change-me-in-production-refresh"
JWT_REFRESH_EXPIRES_IN: "30d"
# 加密密钥(如启用加密)
ENCRYPTION_KEY: ""
NEXT_PUBLIC_ENCRYPTION_KEY: ""

42
deploy/k8s/web-ksvc.yaml Normal file
View File

@@ -0,0 +1,42 @@
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: seclusion-web
namespace: seclusion
labels:
app: seclusion-web
spec:
template:
metadata:
annotations:
# 最小实例数(设为 1 避免冷启动,设为 0 允许缩容到零)
autoscaling.knative.dev/min-scale: "1"
# 最大实例数
autoscaling.knative.dev/max-scale: "10"
# 每个实例的并发请求数
autoscaling.knative.dev/target: "100"
spec:
containerConcurrency: 0
containers:
- name: web
image: gitea.tegical.world/tegical/seclusion-web:IMAGE_TAG_PLACEHOLDER
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: seclusion-config
- secretRef:
name: seclusion-secret
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 5