diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc98c44 --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..798441d --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..d7a6b8f --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -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}')" diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..296b67b --- /dev/null +++ b/apps/api/Dockerfile @@ -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"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..6eb9be9 --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..51e030f --- /dev/null +++ b/deploy/k8s/README.md @@ -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= \ + --docker-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 缓存优化构建速度。 diff --git a/deploy/k8s/api-ksvc.yaml b/deploy/k8s/api-ksvc.yaml new file mode 100644 index 0000000..08b5a46 --- /dev/null +++ b/deploy/k8s/api-ksvc.yaml @@ -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 diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml new file mode 100644 index 0000000..42be743 --- /dev/null +++ b/deploy/k8s/configmap.yaml @@ -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" diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..ea8dbde --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: seclusion + labels: + app.kubernetes.io/name: seclusion diff --git a/deploy/k8s/secret.yaml b/deploy/k8s/secret.yaml new file mode 100644 index 0000000..64da481 --- /dev/null +++ b/deploy/k8s/secret.yaml @@ -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: "" diff --git a/deploy/k8s/web-ksvc.yaml b/deploy/k8s/web-ksvc.yaml new file mode 100644 index 0000000..f104d1e --- /dev/null +++ b/deploy/k8s/web-ksvc.yaml @@ -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