Logo

依赖Github Action的Docker CICD

2025年4年16日 · 1873

这段时间一直在沉迷于建站SEO,看看能不能赚点咖啡钱,而像持续内容输出的网站不可避免地要持续更新、推送、部署。 之前一直人工去部署到VPS上,实在繁琐,就开始使用CICD,下面就是我使用的一些分享心得。

什么是 CI/CD?

CI/CD 是现代开发流程里很重要的一部分。它主要是两个东西:

  • CI(Continuous Integration)叫“持续集成”,意思是我们一有代码更新,就自动跑测试、打包、构建,确保代码没问题。
  • CD(Continuous Deployment / Delivery)叫“持续部署”或“持续交付”,意思是代码通过了测试后,可以自动部署到服务器上,真正上线。

简单说,就是:

写完代码 → 推送代码 → 自动测试构建 → 自动部署上线

这样能大大减少出错的机会,也让我们可以更快上线新功能,是团队开发中非常推荐使用的一种方式。

自动构建并部署 Docker 应用的 GitHub Actions 脚本

这是一个完整的 CI/CD 流程。当你推送代码到 main 分支时,自动构建 Docker 镜像,并部署到服务器。然后这个配置对于普通的nextjs项目是比较通用的,大部分情况你只需要修改环境变量就可以了,然后要注意Github Action中的一些组件版本可能需要手动调整。

大致的流程图如下:

image.png

完整的YAML文件如下:

name: Build and Deploy Docker Application

on:
  push:
    branches: [ main ]
  workflow_dispatch:

env:
  DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE_NAME || 'app' }}
  DOCKER_CONTAINER_NAME: ${{ secrets.DOCKER_CONTAINER_NAME || 'app' }}
  DOCKER_PORT_MAPPING: ${{ secrets.DOCKER_PORT_MAPPING || '3000:3000' }}
  DOCKER_VOLUME_MAPPING: ${{ secrets.DOCKER_VOLUME_MAPPING || '' }}
  DOCKER_EXTRA_ARGS: ${{ secrets.DOCKER_EXTRA_ARGS || '' }}

jobs:
  build:
    runs-on: ubuntu-latest

    outputs:
      new_version: ${{ steps.set_version.outputs.new_version }}
      old_version: ${{ steps.set_version.outputs.old_version }}

    steps:
      - name: Check out the code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Read & increment version
        id: set_version
        run: |
          OLD_VERSION=$(cat .version)
          IFS='.' read -r major minor patch <<< "$OLD_VERSION"
          patch=$((patch + 1))
          NEW_VERSION="$major.$minor.$patch"

          echo "$NEW_VERSION" > .version
          echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV
          echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV

          echo "::set-output name=old_version::$OLD_VERSION"
          echo "::set-output name=new_version::$NEW_VERSION"

      - name: Pull latest changes (to avoid non-fast-forward push)
        run: |
          git config --global user.email "github-actions@github.com"
          git config --global user.name "github-actions"
          git stash push -m "temp"
          git pull origin main --rebase
          git stash pop || true

      - name: Commit and push version update
        uses: EndBug/add-and-commit@v9
        with:
          add: '.version'
          message: 'Bump version to ${{ env.NEW_VERSION }}'
          author_name: 'github-actions'
          author_email: 'github-actions@github.com'

      - name: Build Docker image
        run: |
          docker build -t ${{ env.DOCKER_IMAGE_NAME }}:${{ env.NEW_VERSION }} .

      - name: Save Docker image to tar file
        run: |
          docker save ${{ env.DOCKER_IMAGE_NAME }}:${{ env.NEW_VERSION }} -o ${{ env.DOCKER_IMAGE_NAME }}-v${{ env.NEW_VERSION }}.tar

      - name: Upload Docker image tar file as artifact
        uses: actions/upload-artifact@v4
        with:
          name: docker-image
          path: ${{ env.DOCKER_IMAGE_NAME }}-v${{ env.NEW_VERSION }}.tar
          retention-days: 1

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download Docker image tar file
        uses: actions/download-artifact@v4
        with:
          name: docker-image
          path: .

      - name: Debug NEW_VERSION in deploy job
        run: echo "Deploying version ${{ needs.build.outputs.new_version }}"

      - name: Install sshpass
        run: sudo apt-get install -y openssh-client sshpass

      - name: Add SSH host key
        run: |
          mkdir -p ~/.ssh
          ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

      - name: SCP Docker image tar file to server
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SERVER_USER: ${{ secrets.SERVER_USER }}
          SERVER_IP: ${{ secrets.SERVER_IP }}
          DEST_PATH: ${{ secrets.SERVER_PATH }}
        run: |
          sshpass -p $SSH_PRIVATE_KEY scp -o StrictHostKeyChecking=no ${{ env.DOCKER_IMAGE_NAME }}-v${{ needs.build.outputs.new_version }}.tar $SERVER_USER@$SERVER_IP:$DEST_PATH/${{ env.DOCKER_IMAGE_NAME }}-v${{ needs.build.outputs.new_version }}.tar

      - name: SSH into server and deploy container
        env:
          SERVER_USER: ${{ secrets.SERVER_USER }}
          SERVER_IP: ${{ secrets.SERVER_IP }}
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEST_PATH: ${{ secrets.SERVER_PATH }}
          IMG_VERSION: ${{ needs.build.outputs.new_version }}
          OLD_VERSION: ${{ needs.build.outputs.old_version }}
          ENV_VARS: ${{ secrets.ENV_VARS || '' }}
        run: |
          sshpass -p $SSH_PRIVATE_KEY ssh $SERVER_USER@$SERVER_IP << EOF
              echo "Login server successfully"
              cd $DEST_PATH
              docker load -i ${{ env.DOCKER_IMAGE_NAME }}-v$IMG_VERSION.tar
              echo "Image loaded successfully"
              docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
              echo "Old container stopped"
              docker rm ${{ env.DOCKER_CONTAINER_NAME }} || true
              echo "Old container removed"
              docker image rm ${{ env.DOCKER_IMAGE_NAME }}:$OLD_VERSION || true
              echo "Old image removed successfully"

              DOCKER_CMD="docker run -d --name ${{ env.DOCKER_CONTAINER_NAME }} -p ${{ env.DOCKER_PORT_MAPPING }}"

              if [ ! -z "${{ env.DOCKER_VOLUME_MAPPING }}" ]; then
                DOCKER_CMD="\$DOCKER_CMD -v ${{ env.DOCKER_VOLUME_MAPPING }}"
              fi

              if [ ! -z "$ENV_VARS" ]; then
                for VAR in \$(echo \$ENV_VARS | tr ',' '\n'); do
                  DOCKER_CMD="\$DOCKER_CMD -e \$VAR"
                done
              fi

              if [ ! -z "${{ env.DOCKER_EXTRA_ARGS }}" ]; then
                DOCKER_CMD="\$DOCKER_CMD ${{ env.DOCKER_EXTRA_ARGS }}"
              fi

              DOCKER_CMD="\$DOCKER_CMD ${{ env.DOCKER_IMAGE_NAME }}:$IMG_VERSION"

              echo "Running command: \$DOCKER_CMD"
              eval \$DOCKER_CMD

              echo "New container deployed successfully"
              rm ${{ env.DOCKER_IMAGE_NAME }}-v$IMG_VERSION.tar
              echo "Cleanup completed"
          EOF

对整个YAML文件的解释

一、触发条件

on:
  push:
    branches: [ main ]  # 每次推送到 main 分支就会触发
  workflow_dispatch:    # 也可以手动触发

二、全局变量(环境变量)

env:
DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE_NAME || 'app' }}
DOCKER_CONTAINER_NAME: ${{ secrets.DOCKER_CONTAINER_NAME || 'app' }}
DOCKER_PORT_MAPPING: ${{ secrets.DOCKER_PORT_MAPPING || '3000:3000' }}
DOCKER_VOLUME_MAPPING: ${{ secrets.DOCKER_VOLUME_MAPPING || '' }}
DOCKER_EXTRA_ARGS: ${{ secrets.DOCKER_EXTRA_ARGS || '' }}

这些值可以从 secrets 设置。没有设置的话,就用默认值。

三、构建步骤(build job)

jobs:
	build:
		runs-on: ubuntu-latest  # 使用 GitHub 提供的 Ubuntu 环境

1. 拉取代码

- name: Check out the code
  usesactions/checkout@v4

2. 设置 Docker 构建工具

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

3. 自动更新版本号

- name: Read & increment version
	id: set_version
  run: |
    OLD_VERSION=$(cat .version)
    IFS='.' read -r major minor patch <<< "$OLD_VERSION"
    patch=$((patch + 1))
    NEW_VERSION="$major.$minor.$patch"

    echo "$NEW_VERSION" > .version
    echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV
    echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV

    echo "::set-output name=old_version::$OLD_VERSION"
    echo "::set-output name=new_version::$NEW_VERSION"

读取 .version 文件,版本号 +1。然后保存回文件。

4. 拉最新代码,避免冲突

- name: Pull latest changes ...
  run: |
    git config --global user.email "github-actions@github.com"
    git config --global user.name "github-actions"
    git stash push -m "temp"
    git pull origin main --rebase
    git stash pop || true

先把当前修改存起来,再拉取远程代码,然后把存的内容还原。

5. 提交版本更新

- name: Commit and push version update
	uses: EndBug/add-and-commit@
  with:
	  add: '.version'
	  message: 'Bump version to ${{ env.NEW_VERSION }}'
	  author_name: 'github-actions'
	  author_email: 'github-actions@github.com'

把更新后的 .version 文件提交并推送。

6. 构建 Docker 镜像

- name: Build Docker image
  run: |
          docker build -t ${{ env.DOCKER_IMAGE_NAME }}:${{ env.NEW_VERSION }} .

7. 保存镜像成 .tar 文件

- name: Save Docker image to tar file
  run: |
    docker save ${{ env.DOCKER_IMAGE_NAME }}:${{ env.NEW_VERSION }} -o ${{ env.DOCKER_IMAGE_NAME }}-v${{ env.NEW_VERSION }}.tar

把镜像保存成文件,方便传输到服务器。

8. 上传构建产物

- name: Upload Docker image tar file as artifact
	uses: actions/upload-artifact@v4
  with:
    name: docker-image
    path: ${{ env.DOCKER_IMAGE_NAME }}-v${{ env.NEW_VERSION }}.tar
    retention-days: 1

把镜像文件上传为 GitHub Artifact,方便下一个步骤下载。

四、部署步骤(deploy job)

deploy:
  needs: build  # 需要 build job 完成后才能执行
  runs-on: ubuntu-latest

1. 下载镜像文件


- name: Download Docker image tar file
	uses: actions/download-artifact@v4
  with:
    name: docker-image
    path: 

2. 打印版本号(用于调试)

- name: Debug NEW_VERSION in deploy job
	run: echo "Deploying version ${{ needs.build.outputs.new_version }}

3. 安装 sshpass 工具

- name: Install sshpass
	run: sudo apt-get install -y openssh-client sshpass

用来远程连接服务器用的。

4. 添加服务器 SSH key

- name: Add SSH host key
	run: |
      mkdir -p ~/.ssh
      ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts

避免第一次连接时报“未知主机”的错误。

5. 把镜像传到服务器

- name: SCP Docker image tar file to server
	env:
    SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    SERVER_USER: ${{ secrets.SERVER_USER }}
    SERVER_IP: ${{ secrets.SERVER_IP }}
    DEST_PATH: ${{ secrets.SERVER_PATH }}
  run: |
    sshpass -p $SSH_PRIVATE_KEY scp -o StrictHostKeyChecking=no ${{ env.DOCKER_IMAGE_NAME }}-v${{ needs.build.outputs.new_version }}.tar $SERVER_USER@$SERVER_IP:$DEST_PATH/${{ env.DOCKER_IMAGE_NAME }}-v${{ needs.build.outputs.new_version }}.ta

使用 scp 命令把镜像上传到服务器目录。

6. 登录服务器并部署

- name: SSH into server and deploy container
	env:
    SERVER_USER: ${{ secrets.SERVER_USER }}
    SERVER_IP: ${{ secrets.SERVER_IP }}
    SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    DEST_PATH: ${{ secrets.SERVER_PATH }}
    IMG_VERSION: ${{ needs.build.outputs.new_version }}
    OLD_VERSION: ${{ needs.build.outputs.old_version }}
    ENV_VARS: ${{ secrets.ENV_VARS || '' }}
  run: |
    sshpass -p $SSH_PRIVATE_KEY ssh $SERVER_USER@$SERVER_IP << EOF
        echo "Login server successfully"
        cd $DEST_PATH
        docker load -i ${{ env.DOCKER_IMAGE_NAME }}-v$IMG_VERSION.tar
        echo "Image loaded successfully"
        docker stop ${{ env.DOCKER_CONTAINER_NAME }} || true
        echo "Old container stopped"
        docker rm ${{ env.DOCKER_CONTAINER_NAME }} || true
        echo "Old container removed"
        docker image rm ${{ env.DOCKER_IMAGE_NAME }}:$OLD_VERSION || true
        echo "Old image removed successfully"

        DOCKER_CMD="docker run -d --name ${{ env.DOCKER_CONTAINER_NAME }} -p ${{ env.DOCKER_PORT_MAPPING }}"

        if [ ! -z "${{ env.DOCKER_VOLUME_MAPPING }}" ]; then
          DOCKER_CMD="\$DOCKER_CMD -v ${{ env.DOCKER_VOLUME_MAPPING }}"
        fi

        if [ ! -z "$ENV_VARS" ]; then
          for VAR in \$(echo \$ENV_VARS | tr ',' '\n'); do
            DOCKER_CMD="\$DOCKER_CMD -e \$VAR"
          done
        fi

        if [ ! -z "${{ env.DOCKER_EXTRA_ARGS }}" ]; then
          DOCKER_CMD="\$DOCKER_CMD ${{ env.DOCKER_EXTRA_ARGS }}"
        fi

        DOCKER_CMD="\$DOCKER_CMD ${{ env.DOCKER_IMAGE_NAME }}:$IMG_VERSION"

        echo "Running command: \$DOCKER_CMD"
        eval \$DOCKER_CMD

        echo "New container deployed successfully"
        rm ${{ env.DOCKER_IMAGE_NAME }}-v$IMG_VERSION.tar
        echo "Cleanup completed"
    EOF
  1. 首先通过 SSH 登录服务器。
  2. 进入指定的部署目录
  3. 把刚上传的镜像文件加载进 Docker。这样服务器上就有了这个新镜像。
  4. 停掉并删除旧容器,顺带清掉旧版本镜像。|| true 是为了防止失败后中断。
  5. 根据有没有传 volume、环境变量、额外参数,动态追加拼接 docker run 命令
  6. 最后把镜像名和版本拼上去,执行拼出来的 run 命令,启动新容器!