前言

『终章』从建站开始,就一直托管在自己的 VPS 上,并且通过 GitHub Webhook 以及自制后端实现自动部署。不过最近把网站托管到了 Cloudflare Workers Sites。选择它的主要原因是自己一直使用 Cloudflare 的 DNS 和 CDN,其实类似的静态托管服务 Vercel 以及 Netlify 也可以考虑一下。而这类服务的优点也大多类似:

  • 一般会部署到多个网络节点,比如 Workers Sites 就会部署在 Cloudflare 遍布世界的200多个节点上。当然,我自然是不会缺少这区区200个节点的,我缺少的是遍布世界各地的读者。
  • 自带 CDN 并且与托管网站的服务器处于同一数据中心,节省了回源时间。
  • 较高的 SLA。
  • 不需要自己处理证书,虽然现在 Let's Encrypt 已经足够普及并且Certbot 也非常易用,不过能多偷一点懒也是很好的。

由于 Workers Sites 不提供 CI/CD,所以我选择了使用 GitHub Action 来负责生成网站、处理杂物,以及部署到 Workers Sites。

首次部署

先放出官方文档以供参考。

初始化 Workers Sites

Workers Sites 需要使用 wrangler 进行部署,首先安装 Node.js,然后直接使用 npm i @cloudflare/wrangler -g 安装 wrangler。

接下来在项目目录运行 wrangler init,会生成 wrangler.toml,然后编辑这个文件。

name = "codaworld" # 网站名,不能包含 "."
type = "webpack"
account_id = "915646e8f79239578920a2a6e4c3b0a6" # Cloudflare Account ID
routes = ["coda.world/*", "www.coda.world/*"] # 自定义域名,后面的星号不要省略
zone_id = "1b825f5f5586940c5fa5cf44cfc24a8d" # 域名的 Zone ID
workers_dev = false # 不启用 workers.dev 子域名

[site]
bucket = "./public" # 生成后的网站的位置,我使用的 Hugo 所以是 public
entry-point = "workers-site" # Workers Sites 相关配置的路径,一般保持默认即可。

创建和使用 API Token

  • 在 Cloudflare 个人资料页面 就可以创建 API Token,官方已经给出了一个与 Workers 相关的模板,其实里面的 Read Account Settings 和 Read User Details 这两个权限去掉也没问题,另外记得也设置一下账户限制和域名限制。
  • 然后运行 wrangler config 后输入 API Token,或者也可以把 API Token 赋予 CF_API_TOKEN 环境变量。

预览和发布

  • 可以先运行 wrangler preview --watch 预览一下。
  • 而真汉子往往直接运行 wrangler publish 进行发布。

配置 DNS

由于只有存在 DNS 记录的域名才会匹配 Workers Sites,所以我们需要在 Cloudflare 的控制面板中对相应的域名添加 DNS 记录,我直接设置了

AAAA    coda.world        100::
AAAA    www.coda.world    100::

确保 Proxy Status 设为 Proxied 也就是要把云朵图标点亮,另外只要有 DNS 记录即可,并不需要有效的 IP。

卸磨杀驴

由于已经完成了基本配置并且生成了相应的文件,再加上以后的部署都会交给 GitHub Actions 来进行,所以我直接删除了 workers-site/node_modules

再把 wrangler 也删了 npm r @cloudflare/wrangler -g

我甚至把 Node.js 都顺便卸载了。

持续集成与自动部署

构建网站

在项目目录新建 .github/workflows/Build.yaml (其实文件名随意,只要后缀是 yaml/yml 即可)

 1name: Automic Deploy
 2on:
 3  push:
 4    branches:
 5      - master
 6  workflow_dispatch:
 7jobs:
 8  Automata:
 9    runs-on: ubuntu-latest
10    steps:
11      - uses: actions/checkout@v2
12      - name: Generate Website
13        run: |
14          chmod u+x "./hugo_Linux-64bit"
15          ./hugo_Linux-64bit --minify          

上述代码只是进行了 Checkout,然后运行了 ./hugo_Linux-64bit 生成网站。没错,我把可执行文件直接扔进 repo 里面了,这种用法不被提倡,但是方便、兼容性高并且构建速度也很快。不喜欢这样做的可以使用 Hugo Setup 这个 Action。先将写好的 workflow push 到 GitHub 测试一下,如果没问题再继续。

部署到 Workers Sites

先到 https://github.com/{username}/{repo}/settings/secrets/actions 添加 secret,名称填写 CLOUDFLARE_WORKERS_TOKEN,值自然是前面创建的 API Token (建议 roll 一个新的)。然后在 Build.yaml 里补上下面这一段,因为我不喜欢拉取 docker,所以直接把 wrangler 安装到了项目目录下。另外我选择每次都安装最新的 wrangler,所以没有缓存 node_modules。

16      - name: Deploy
17        run: |
18          npm i @cloudflare/wrangler
19          npx wrangler publish          
20        env:
21          CF_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_TOKEN }}

如果不介意拉取 docker 可以直接使用 Cloudflare 官方提供的 Action:

16      - name: Publish
17        uses: cloudflare/wrangler-action@1.3.0
18        with:
19          apiToken: ${{ secrets.CLOUDFLARE_WORKERS_TOKEN }}
20
21

处理杂物

这里我通过一串简单的例子讲解一下各种用法。

首先是每次部署完后通知搜索引擎网站已经更新:

22      - name: Ping Google
23        run: curl -fsSL "https://www.google.com/ping?sitemap=xxxxxx"

然后改进一下上述代码,让这个步骤只在 public/sitemap.xml 发生改变时进行。

22      - name: Detect Cache Hit
23        id: cache
24        uses: actions/cache@v2
25        with:
26          path: ~/notexist
27          key: ${{ hashFiles('public/sitemap.xml') }}
28      - name: Ping Google
29        if: steps.cache.outputs.cache-hit != 'true'
30        run: curl -fsSL "https://www.google.com/ping?sitemap=xxxxxx"

上述代码中使用了 cache@v2 这个 Action,主要目的是为了将 public/sitemap.xml 的哈希值记录下来并作为缓存的索引,下次运行时如果缓存命中就表示这个文件没发生改变,所以不需要 Ping Google。当然,使用多个文件或文件夹计算 Hash Key 也是可以的,例如 ${{ hashFiles('content/*.md') }}。另外由于我们只需要知道缓存是否命中而不在乎缓存的内容,所以代码中直接缓存了一个不存在的文件 ~/notexist

当一个步骤失败时,GitHub Action 默认会取消所有后续步骤,所以例如需要通知多个搜索引擎时,需要使用 if: always() 确保当前步骤不会因为其它步骤失败而取消,再配合其它限制例如 steps.cache.outcome == 'success' 保证只会在必要时触发。

22      - name: Detect Cache Hit
23        id: cache
24        uses: actions/cache@v2
25        with:
26          path: ~/notexist
27          key: ${{ hashFiles('public/sitemap.xml') }}
28      - name: Ping Google
29        if: always() && steps.cache.outcome == 'success' && steps.cache.outputs.cache-hit != 'true'
30        run: curl -fsSL "https://www.google.com/ping?sitemap=xxxxxx"
31      - name: Ping Bing
32        if: always() && steps.cache.outcome == 'success' && steps.cache.outputs.cache-hit != 'true'
33        run: curl -fsSL "https://www.bing.com/ping?sitemap=xxxxxx"

在前面的代码中使用了 cache@v2,而 GitHub Action 会驱逐超过 7 天未被访问的缓存,可以通过一个 Workflow 来定时访问缓存从而防止缓存失效,新建 .github/workflows/AccessCache.yaml:

name: Access Cache
on:
  schedule:
    - cron: "0 0 */6 * *"
  workflow_dispatch:
jobs:
  Access:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: chmod u+x "./hugo_Linux-64bit" && ./hugo_Linux-64bit --minify
      - uses: actions/cache@v2
        with:
          path: ~/notexist
          key: ${{ hashFiles('public/sitemap.xml') }}

上述代码中 schedule 定义了每 6 天触发一次,而 workflow_dispatch 表示这个 workflow 可以被手动触发。定时触发还可以用来 Keep Alive 各种后台服务,或者记录网站的 uptime 等等。

Workers Sites 配置(进阶篇)

通过编辑 workers-site/index.js 可以实现更多的功能,例如:

  • 通过跳转去掉 URL 结尾的 index.html。
  • 静态资源设置更长的缓存时间。
  • 添加 HTTP Header。
  • 使用 Server Push。

下方是我个人的配置,可以作为参考,不过注意由于我的 CSP 设置的非常严格,直接照搬可能导致网站的部分内容无法显示。

import { getAssetFromKV } from '@cloudflare/kv-asset-handler'

async function handleEvent(event) {
  const { origin, pathname, search } = new URL(event.request.url)
  const cacheExtRegExp = new RegExp(/\.(css|js|ttf|woff|jpg|png|ico|svg)$/)
  let response
  try {
    // 'example.com/*/index.html' -> 'example.com/*/'
    if (pathname.endsWith('/index.html')) {
      response = new Response(null, {
        status: 301,
        headers: {
          Location: `${origin}${pathname.substring(0, pathname.length - 10)}${search}`,
          'Cache-Control': 'max-age=86400',
        },
      })
    // generate_204
    } else if (pathname === '/generate_204') {
      response = new Response(null, { status: 204 })
    // Static content 1 year cache
    } else if (cacheExtRegExp.test(pathname)) {
      response = await getAssetFromKV(event, {
        cacheControl: {
          edgeTTL: 31536000,
          browserTTL: 31536000,
          cacheEverything: true,
        },
      })
      response.headers.set('cache-control', 'public, max-age=31536000, immutable')
    // Default 4 hour cdn cache, 1 hour browser cache
    } else {
      response = await getAssetFromKV(event, {
        cacheControl: {
          edgeTTL: 14400,
          browserTTL: 3600,
          cacheEverything: true,
        },
      })
    }
    // Server Push & CSP Header
    if (response.headers.get('Content-Type') && response.headers.get('Content-Type').includes('text/html')) {
      response.headers.append('Link', '</style/style.min.7f90d7ef9a6c6e1f79015475f28cdba49bc95fc4adf49bd909990be3c3f963df.css>; rel=preload; as=style')
      response.headers.append('Link', '</style/icomoon.woff?38uz27>; rel=preload; as=font; crossorigin')
      response.headers.set('Content-Security-Policy', `default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; media-src 'self'; object-src 'self'; style-src 'self'; font-src 'self'`)
    }
  } catch (e) {
    response = new Response(null, { status: 404 })
  }
  // Set Headers
  response.headers.set('Referrer-Policy', 'same-origin')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-XSS-Protection', '1; mode=block')
  return response
}

addEventListener('fetch', event => {
  event.respondWith(handleEvent(event))
})

Cloudflare Workers 还有各种用法,例如反代、Header 认证、根据 IP 位置重定向以及重写 HTML 等,可以通过官方提供的各种例子了解。