前话

从2020年至今,Claws博客(基于hexo)也连续运营了4年多时间。这期间由于搬家、服务器更换等等原因,经常需要手动重新部署博客。前段时间简单学习接触了Gitlab的workflow,并用它帮助我快速做完了硕士毕设的项目,避免了手动打包部署的繁琐操作。于是我想,能不能将博客的部署也通过CI/CD平台自动化起来,避免每次需要手动敲命令部署。这样,就算更换服务器,也不需要重新对服务器进行配置了。

原来的部署流程:image-20240520105316760

CI/CD后的效果:image-20240520105459183

CICD之后,本地只需要对源码进行编辑。由于编辑博客一般情况下也不需要测试、本地编译和语法检查,所以本地甚至不需要安装对应的环境,直接用typora写就完事了。如果需要新换服务器,只需要拷贝一下公钥(部署通过scp拷贝文件完成),更新一下项目中的变量。博客的部署和备份以前都需要借助hexo的插件,分两步完成,现在只需要git commit & push推送到远端仓库,一步自动完成构建部署。这样拜托了对hexo插件的依赖,但也要求使用这一套流程的人现在必须会用git,略微提高了一点门槛。

在方案选择上,虽然之前在毕设项目中使用过gitlab的CI/CD,但github actions给的免费分钟数更多,用户群体也似乎更大。除了Github在国内比较难使用,其他方面体验都还不错,因此最终选择Github actions实现。

Github Actions简介

Github Actions可以识别仓库中的workflow配置文件(yaml格式),在条件达到时自动触发工作流的执行,实现CI/CD的目的。这里的条件(或者说事件)不仅可以是push和merge这类和代码仓库相关的事件,还可以是比如新issue的创建。默认会在仓库根目录下的.github/workflows目录中解析所有的workflow配置。

下面的图展示了Gtihub Actions中一个workflow的结构:

image-20240520112222234

  1. Event就是触发这个workflow的事件。
  2. 一个workflow由多个Job组成。如果一个Job失败,可以单独重新执行这个Job。
  3. 一个Job由多个Step组成。不能重新执行一个Step。一个Step可以调用别人已经准备好的actions,不需要每个细节都自己完成。actions marketplace
  4. 一个Job运行在一个Runner中。这意味着同Job内的所有Step都共享相同的文件系统环境。
  5. Runner类似一个虚拟机(或容器),在Gtihub上可以选择操作系统(Linux可以选发行版,最常用的是ubuntu-latest,也有windows和mac版的可以选择,不过使用的成本更贵)

环境变量:Github运行workflow时提供了一个运行时环境,其中就包括许多常用的环境变量。环境变量分为两类:vars和secrets。vars时可以公开的变量,通常用来存储一些非敏感信息;secrets则用来存储敏感信息,一经存储,无法以任何形式查看、修改,但是可以给job使用。

部署步骤拆解

分为两个大的job:

  1. build。负责使用hexo将博客源代码编译为静态资源。
  2. deploy。负责将静态资源拷贝到服务器的目标目录中。

两个步骤之间,需要借助github artifacts来交换构建产物。build将构建产物上传,deploy再把产物下载到环境中。

build内部步骤为:

  1. checkout代码
  2. 设置nodejs环境
  3. 配置npm缓存(非必要,但是可以加速作业执行)
  4. 构建博客
  5. 上传制品

deploy内部的步骤为:

  1. 下载制品
  2. 打包制品
  3. 配置SSH客户端(主要是设置私钥)
  4. 拷贝并解压静态资源到服务器

完整workflow配置

name: Deploy

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v4

    - name: Setup Node
      uses: actions/setup-node@v4
      with:
        node-version: '21'
    
    # Caching dependencies to speed up workflows. (GitHub will remove any cache entries that have not been accessed in over 7 days.)
    - name: Cache node modules
      uses: actions/cache@v4
      id: cache
      with:
        path: node_modules
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-node-
    - name: Install Dependencies
      if: steps.cache.outputs.cache-hit != 'true'
      run: npm ci
    
    - name: Generate and pakcage static files
      run: npm run build
    
    - name: Upload the artifacts
      uses: actions/upload-artifact@v4
      with:
        name: blog-achieve
        path: public
        if-no-files-found: error
        retention-days: 1
    
  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - name: Download ZIP archive
      uses: actions/download-artifact@v4
      with: 
        name: blog-achieve
        
    # 这里构建过程同时将博客部署到github pages上
    - name: Deploy to GitHub Pages
      uses: peaceiris/actions-gh-pages@v4
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: .
        cname: blog.claws.top
    
    - name: Zip the blog files
      run: zip -r blog.zip .

    - name: Setup SSH
      run: |
        mkdir -p ~/.ssh
        echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_rsa
        chmod 600 ~/.ssh/id_rsa
        ssh-keyscan -H ${{ vars.SERVER_HOST }} >> ~/.ssh/known_hosts

    # warning! this step will remove all files under secrets.SERVER_PATH, make sure this is configured to the right blog root path
    - name: Copy files to server
      env:
        SERVER_HOST: ${{ vars.SERVER_HOST }}
        SERVER_PORT: ${{ vars.SERVER_PORT }}
        SERVER_USER: ${{ vars.SERVER_USER }}
        SERVER_PATH: ${{ vars.SERVER_PATH }}
      run: |
        ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -p $SERVER_PORT $SERVER_USER@$SERVER_HOST "rm -rf $SERVER_PATH/*" 
        scp -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -P $SERVER_PORT blog.zip $SERVER_USER@$SERVER_HOST:$SERVER_PATH
        ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -p $SERVER_PORT $SERVER_USER@$SERVER_HOST "unzip -o $SERVER_PATH/blog.zip -d $SERVER_PATH && rm $SERVER_PATH/blog.zip"

配置中间涉及到几个vars和secrets需要配置,配置的方法是在github的项目中找到settings-secrets and variables-actions,在这里先添加vars再添加secrets:image-20240520145048712

后话

反思了一下,将博客作为静态内容直接部署在对象存储服务中开发公共访问是更可靠、维护成本更小的做法。在选择云服务商时,主要考虑了国内的阿里云和腾讯云的对象存储。在定价上,腾讯云的定价较阿里云稍低,而且目前支持免费证书的免费托管,而阿里云的托管费用不菲。

域名托管:使用自定义域名访问对象存储的存储桶时,为了开启https安全传输,需要使用云服务商提供的证书。现在各云服务商提供的免费证书都只有3个月,如果自己更换证书比较麻烦。因此阿里云和腾讯云这样的服务商提供了证书托管的服务,在证书过期前可以自动更换证书并部署到对应的云服务中。相比于阿里云,目前腾讯云的证书托管服务还是免费的。

Github Actions workflow 文件

对Github Actions的workflow文件进行如下修改,删除之前的部署到服务器的step,并添加复制到COS存储桶的步骤。

deploy:
runs-on: ubuntu-latest
needs: build
steps:
- name: Download ZIP archive
  uses: actions/download-artifact@v4
  with: 
    name: blog-achieve
    path: public

- name: Deploy to GitHub Pages
  uses: peaceiris/actions-gh-pages@v4
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    publish_dir: .
    cname: blog.claws.top

- name: Upload to Tencent COS
  uses: TencentCloud/cos-action@v1
  with:
    secret_id: ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
    secret_key: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
    cos_bucket: blog-xxxxxxx
    cos_region: ap-shanghai
    local_path: public
    remote_path: /
    clean: true

之后为了actions步骤能够读取到secret id和secret key,需要在github的secrets里面添加一下这些变量。

不建议把blog的artifact直接展开在当前目录,可能会造成上传COS出问题。这里我放在了public/目录下。最后一步中的clean参数表示会清楚COS中没有但是当前有的文件,保证存储桶中不会保存有后来删掉的文件。

COS存储桶配置

上面的一步保证了每次push之后,博客可以自动构建并将静态内容部署到存储桶中。下面将存储桶暴露为静态网站,并配置自定义域名。

首先在存储桶的基本配置中,开启静态网站设置,并注意图中的红框配置项。尤其是最后一个,决定了访问一个路径path时,如果以/结尾,则会自动定位到目录中的index.html页面。

image-20240524140825541

之后在域名与传输管理中,配置自定义源站域名。需要注意的是源站类型要选择静态网站源站。如果选错成默认源站,之后访问网站时会被拒绝访问。这里证书可以绑定一个腾讯云上托管的免费ssl证书。

image-20240524141434926

最后,只需要在dns提供商那里,将域名通过CNAME解析到上面页面中的CNAME记录值,过大概10分钟左右,网站就可以访问了。

之后,每次更新博客内容直接push,新的博客就会被部署到存储桶中,供国内用户访问,再也不需要担心换服务器带来的博客迁移麻烦,真正做到一劳永逸。