技术 · 2026/06/06

CI-CD实践

CI-CD实践操作

一、阿里云服务的初始部署设置

1.1 配置密钥对,禁止密码登录

//登录
ssh root@ip
//修改SSH 核心配置文件
sudo nano /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes

登录阿里云控制台上传密钥绑定实例

1.2 初始化云服务器,下载必要软件

//下载软件
apt update
apt install -y git curl ufw
curl -fsSL https://get.docker.com | sh
apt install -y docker-compose-plugin
systemctl enable docker
systemctl start docker
//开放端口80,443,22,阿里云控制台安全组也要放行
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
🔍

80是用户输入域名重定向到443加密接口,必须打开,22是管理连接后端接口也得打开,443接口是https接口就是应用的流量接口

二、dockerfile和docker.compose.yml编写

# 服务器专用:只拉取镜像运行,不在服务器上构建
# 用法:
#   docker compose -f docker-compose.prod.yml pull
#   docker compose -f docker-compose.prod.yml up -d
#
# 需要在同目录的 .env 中设置 IMAGE_REGISTRY、IMAGE_TAG 以及各项密钥
# 注意:NEXT_PUBLIC_API_BASE_URL 需要在构建 myblog-web 镜像时通过 build arg 写入

services:
  logs-init:
    image: busybox:1.36
    init: true
    command:
      [
        "sh",
        "-c",
        "mkdir -p /logs/api /logs/web /logs/nginx /logs/postgresql /logs/redis && chmod -R 0777 /logs"
      ]
    volumes:
      - ./logs:/logs
    restart: "no"

  nginx:
    image: nginx:1.27-alpine
    init: true
    ports:
      - "${NGINX_PORT:-80}:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./logs/nginx:/var/log/nginx
      - ./certs:/etc/nginx/certs:ro
      - ./acme-challenge:/var/www/acme-challenge:ro
    depends_on:
      logs-init:
        condition: service_completed_successfully
      web:
        condition: service_started
      api:
        condition: service_started
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    restart: unless-stopped

  web:
    image: ${IMAGE_REGISTRY:?请在 .env 中设置 IMAGE_REGISTRY}/myblog-web:${IMAGE_TAG:-latest}
    init: true
    environment:
      NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:4000}
    expose:
      - "3000"
    command:
      [
        "sh",
        "-c",
        "node apps/web/server.js 2>&1 | tee -a /app/logs/app-$(date +%F).log"
      ]
    volumes:
      - ./logs/web:/app/logs
    depends_on:
      logs-init:
        condition: service_completed_successfully
      api:
        condition: service_started
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    restart: unless-stopped

  api:
    image: ${IMAGE_REGISTRY:?请在 .env 中设置 IMAGE_REGISTRY}/myblog-api:${IMAGE_TAG:-latest}
    init: true
    environment:
      API_PORT: 4000
      LOG_DIR: /app/logs
      LOG_FILE_LEVEL: ${LOG_FILE_LEVEL:-log}
      LOG_CONSOLE_LEVEL: ${LOG_CONSOLE_LEVEL:-warn}
      WEB_ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
      POSTGRES_URL: postgresql://postgres:${POSTGRES_PASSWORD:?请在 .env 中设置 POSTGRES_PASSWORD}@postgres:5432/blog_db
      REDIS_URL: redis://redis:6379/0
      JWT_SECRET: ${JWT_SECRET:?请在 .env 中设置 JWT_SECRET}
      JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
      AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true}
      RESEND_API_KEY: ${RESEND_API_KEY:-}
      RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
      EMAIL_CODE_REAL_SEND: ${EMAIL_CODE_REAL_SEND:-false}
      OWNER_EMAIL: ${OWNER_EMAIL:-}
      FEISHU_APP_ID: ${FEISHU_APP_ID:-}
      FEISHU_APP_SECRET: ${FEISHU_APP_SECRET:-}
      FEISHU_BITABLE_WIKI_TOKEN: ${FEISHU_BITABLE_WIKI_TOKEN:-}
      FEISHU_BITABLE_APP_TOKEN: ${FEISHU_BITABLE_APP_TOKEN:-}
      FEISHU_BITABLE_TABLE_ID: ${FEISHU_BITABLE_TABLE_ID:-}
      FEISHU_SYNC_INTERVAL_MS: ${FEISHU_SYNC_INTERVAL_MS:-600000}
      PAYMENT_FM_API_BASE_URL: ${PAYMENT_FM_API_BASE_URL:-}
      PAYMENT_FM_MERCHANT_NUM: ${PAYMENT_FM_MERCHANT_NUM:-}
      PAYMENT_FM_PAY_TYPE: ${PAYMENT_FM_PAY_TYPE:-aloop}
      PAYMENT_FM_CALLBACK_URL: ${PAYMENT_FM_CALLBACK_URL:-https://example.com/api/zhifuxpay/notify}
      PAYMENT_FM_RETURN_URL: ${PAYMENT_FM_RETURN_URL:-http://localhost:3000/payment/result}
      PAYMENT_FM_SECRET: ${PAYMENT_FM_SECRET:-}
      PAYMENT_ORDER_EXPIRE_MINUTES: ${PAYMENT_ORDER_EXPIRE_MINUTES:-5}
      LOG_ALERT_ENABLED: ${LOG_ALERT_ENABLED:-true}
      LOG_ALERT_ENV: ${LOG_ALERT_ENV:-production}
      LOG_ERROR_DEDUPE_SECONDS: ${LOG_ERROR_DEDUPE_SECONDS:-300}
      LOG_ERROR_LIMIT_PER_MINUTE: ${LOG_ERROR_LIMIT_PER_MINUTE:-20}
      LOG_4XX_WARN_THRESHOLD: ${LOG_4XX_WARN_THRESHOLD:-20}
      LOG_4XX_WARN_WINDOW_SECONDS: ${LOG_4XX_WARN_WINDOW_SECONDS:-300}
      PERSONAL_WECHAT_PUSH_WEBHOOK_URL: ${PERSONAL_WECHAT_PUSH_WEBHOOK_URL:-}
      FEISHU_ERROR_WEBHOOK_URL: ${FEISHU_ERROR_WEBHOOK_URL:-}
      FEISHU_ERROR_WEBHOOK_SECRET: ${FEISHU_ERROR_WEBHOOK_SECRET:-}
      FEISHU_ERROR_MENTION_ALL: ${FEISHU_ERROR_MENTION_ALL:-true}
      FEISHU_WARN_WEBHOOK_URL: ${FEISHU_WARN_WEBHOOK_URL:-}
      FEISHU_WARN_WEBHOOK_SECRET: ${FEISHU_WARN_WEBHOOK_SECRET:-}
      FEISHU_WARN_MENTION_ALL: ${FEISHU_WARN_MENTION_ALL:-false}
    expose:
      - "4000"
    volumes:
      - ./logs/api:/app/logs
    depends_on:
      logs-init:
        condition: service_completed_successfully
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    command:
      [
        "postgres",
        "-c",
        "shared_buffers=128MB",
        "-c",
        "max_connections=30",
        "-c",
        "logging_collector=on",
        "-c",
        "log_directory=/var/log/postgresql",
        "-c",
        "log_filename=postgresql-%Y-%m-%d.log",
        "-c",
        "log_statement=none"
      ]
    environment:
      POSTGRES_DB: blog_db
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?请在 .env 中设置 POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./sql:/docker-entrypoint-initdb.d:ro
      - ./logs/postgresql:/var/log/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d blog_db"]
      interval: 10s
      timeout: 5s
      retries: 5
    depends_on:
      logs-init:
        condition: service_completed_successfully
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command:
      [
        "redis-server",
        "--save",
        "",
        "--appendonly",
        "no",
        "--logfile",
        "/var/log/redis/redis.log"
      ]
    volumes:
      - ./logs/redis:/var/log/redis
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    depends_on:
      logs-init:
        condition: service_completed_successfully
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    restart: unless-stopped

volumes:
  postgres_data:

三、.github CI CD自动化脚本编写

CI
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10.33.0
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm test
      - run: pnpm typecheck
      - run: pnpm build
CD
name: Deploy

on:
  workflow_run:
    workflows: [CI]
    types: [completed]
    branches: [main]

permissions:
  contents: read
  packages: write

jobs:
  build-and-deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: apps/web/Dockerfile
          push: true
          tags: ghcr.io/linxiaodong1/myblog-web:latest
          build-args: |
            NEXT_PUBLIC_API_BASE_URL=${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
      
      - uses: docker/build-push-action@v6
        with:
          context: .
          file: apps/api/Dockerfile
          push: true
          tags: ghcr.io/linxiaodong1/myblog-api:latest
      
      - name: Pack deploy files
        run: |
          mkdir -p deploy-bundle
          cp docker-compose.prod.yml deploy-bundle/
          cp -r nginx deploy-bundle/
          cp -r sql deploy-bundle/
          tar -czf deploy-bundle.tar.gz -C deploy-bundle .
      
      - uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key:  ${{ secrets.SERVER_SSH_KEY }}
          source: deploy-bundle.tar.gz
          target: ${{ secrets.SERVER_APP_DIR  }}
      
      - uses: appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key:  ${{ secrets.SERVER_SSH_KEY  }}
          script: |
            cd ${{  secrets.SERVER_APP_DIR  }}
            tar -xzf deploy-bundle.tar.gz
            rm -f  deploy-bundle.tar.gz
            docker compose -f docker-compose.prod.yml pull
            docker compose -f docker-compose.prod.yml up -d
            docker compose -f docker-compose.prod.yml ps 
            docker image prune -f

四、github配置服务器相关参数