Skip to content

Commit

Permalink
websites-integration: init the new deployment system
Browse files Browse the repository at this point in the history
  • Loading branch information
cometkim committed Jun 5, 2024
1 parent e42b4e5 commit 4e50205
Show file tree
Hide file tree
Showing 22 changed files with 1,053 additions and 2 deletions.
97 changes: 97 additions & 0 deletions .github/workflows/about_daangn_com-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Build about.daangn.com

on:
workflow_dispatch:
inputs:
deployment_id:
description: Deployment ID
type: string
required: true
bind_url:
description: Bind URL from the websites-deployment worker
type: string
required: true
callback_url:
description: Callback URL from the websites-deployment worker
type: string
required: true

jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Bind this to the deployment
run: |
curl -fsSL -X POST \
-H "Authorization: AdminKey ${{ secrets.WEBSITES_ADMIN_KEY }}" \
--json '{ "run_id": "${{ github.run_id }}" }' \
"${{ inputs.bind_url }}"
- uses: actions/checkout@v4

- name: Setup Rclone
uses: cometkim/rclone-actions/setup-rclone@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Configure Rclone
run: |
mkdir -p ~/.config/rclone
{
echo "[r2]"
echo "type = s3"
echo "provider = Cloudflare"
echo "access_key_id = ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}"
echo "secret_access_key = ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}"
echo "endpoint = https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com"
echo "acl = private"
} > ~/.config/rclone/rclone.conf
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn

- name: Install Dependencies
run: yarn install --immutable

- name: Restore cache
id: content-cache
uses: actions/cache/restore@v4
with:
path: |
about.daangn.com/public
about.daangn.com/.cache
key: cache-about_daangn_com

- name: Build about.daangn.com
id: build
run: yarn workspace about.daangn.com build

- name: Build artifact
run: |
cd about.daangn.com && \
tar -cvf --use-compress-program="zstd -T0 --adapt --exclude-compressed" \
"public.tar.zst" \
"public/"
- name: Upload artifact
run: |
cd about.daangn.com && \
rclone copyto --log-level INFO \
"public.tar.zst" \
"r2:websites-artifacts/about.daangn.com/${{ inputs.deployment_id }}.tar.zst"
- name: Execute callback
if: ${{ always() }}
run: |
curl -fsSL -X POST \
-H "Authorization: AdminKey ${{ secrets.WEBSITES_ADMIN_KEY }}" \
--json '{' \
--json '"run_id": "${{ github.run_id }}",' \
--json '"status": "${{ job.status }}",' \
--json '"artifact_name": "about.daangn.com/${{ inputs.deployment_id }}.tar.zst"' \
--json '}' \
"${{ inputs.callback_url }}"
38 changes: 38 additions & 0 deletions .github/workflows/deploy-websites-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Deploy websites-integration

on:
workflow_dispatch:
push:
paths:
- _workers/websites-integration/**
- .github/workflows/deploy-websites-integration.yml
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: yarn

- name: Install Dependencies
run: yarn install --immutable

- name: Deploy workers (Durable Objects only)
run: yarn workspace websites-integration deploy:pages
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

- name: Deploy pages
run: yarn workspace websites-integration deploy:pages
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.ultra.cache.json
.env
.envrc
.dev.vars

*.DS_Store
*.log
Expand Down
79 changes: 79 additions & 0 deletions _docs/deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# 웹사이트 배포 구성

- Build: GitHub Actions
- Deployment: Cloudflare Pages

CI/CD 가 통합된 플랫폼 전체 기능을 있는 그대로 사용하는 대신 필요에 따라 커스텀한 구성을 사용합니다.

## Motivation

Cloudflare Pages나 Vercel 등 통합 플랫폼이 자체적으로 빌드 시스템을 제공하지만 몇몇 사이트는 제약사항 있는 그대로 사용하기에 한계가 있습니다. 대표적으로 `about.daangn.com` 의 경우 전체 700MB 정도의 아티팩트 사이즈, 빌드 과정 중 무거운 이미지 빌드가 많다보니 제약사항이 생깁니다.

e.g) 발견된 제약사항 예시

- 클린 빌드와 캐시 빌드가 엄청나게 차이남 (거의 2분 vs 45분)
- CI의 빌드 캐시 여부를 커스터마이징 할 수 없음
- CI가 빌드 중 메모리 제약으로 실패함
- CI가 빌드 중 시간을 너무 많이 써서 타임아웃으로 실패함
- 외부에서 빌드해서 업로드만 하는 것을 지원하지 않음
- 외부에서 빌드해서 업로드하는 것을 지원하나, 업로드 가능한 바이트 수에 제한이 있음

## Build Caching

Cron 스케줄을 통해 매일 빌드 캐시를 만들어 Actions Cache로 저장합니다.

컨텐츠 수명이 길어 빌드 빈도를 높게 가져가는 것이 큰 효용은 없습니다.

반면, Actions Cache는 프로젝트 별로 10GB의 저장소 제약이 있습니다. 모든 웹사이트가 캐시를 유지하여도 충분하도록, 불변 캐시를 만드는 대신 매번 기존 캐시를 덮어 씁니다.

## Configuration

![Deployment Configuration Overview](images/deployment-configuration-overview.png)

(Enterprise 도메인이 있는 경우) Cloudflare Pages를 주 플랫폼으로 사용합니다.

구성을 위해 필요한 기능들(Deployment webhook, Custom build script, Branch preview)을 동등하게 제공하는 다른 배포 플랫폼을 사용할 수 있습니다.

### Components

Shared config:
- R2 artifacts bucket
- Cloudflare Worker for integration

Per-site config:

- GitHub Actions "cache" worfklow
- GitHub Actions "build" workflow
- Cloudflare Pages Project

### How it works

전체 사이트 배포의 세부적인 프로세스는 다음과 같습니다.

```mermaid
sequenceDiagram
actor CMS
participant Cloudflare Pages
participant Deployment Awaiter
participant Deployment Object
participant GitHub Actions
participant R2 Bucket
CMS->>+Cloudflare Pages: Deploy hook
Cloudflare Pages-->>CMS: 200 OK
Cloudflare Pages->>+Deployment Awaiter: Initialize deployment
Deployment Awaiter->>+Deployment Object: Spawn deployment object
Deployment Object->>+GitHub Actions: Trigger build
Deployment Object-->>Deployment Awaiter: Bind deployment
loop every 5 secs
Deployment Awaiter->>Deployment Object: Check build status
Deployment Object-->>Deployment Awaiter:
end
GitHub Actions->>R2 Bucket: Upload artifacts
GitHub Actions->>-Deployment Object: Notify build status
Deployment Awaiter-->>-Cloudflare Pages: Release
Cloudflare Pages->>R2 Bucket: Retrive artifacts
R2 Bucket-->>Cloudflare Pages:
Cloudflare Pages-->>-Cloudflare Pages: Extract & Batch artifacts
```

동작 방식은 프로덕션과 브랜치 프리뷰가 동일합니다.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions _workers/websites-integration/_worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Deployment } from './functions/$lib/objects/Deployment.ts';

export default {};
107 changes: 107 additions & 0 deletions _workers/websites-integration/deployment-awaiter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { parseArgs } from 'node:util';
import { setInterval } from 'node:timers/promises';

import { $ } from 'zx';

const {
WEBSITES_ADMIN_KEY,
WEBSITES_INTEGRATION_ENDPOINT,

// This script is available only on the Cloudflare Pages build, and some env vars will be injected from.
// See https://developers.cloudflare.com/pages/configuration/build-configuration/
CF_PAGES,
CF_PAGES_COMMIT_SHA,
CF_PAGES_BRANCH,
CF_PAGES_URL,
} = process.env;

if (CF_PAGES !== '1') {
throw new Error("deployment-awaiter should be executed only on Cloudflare Pages' build");
}

const baseUrl = new URL(WEBSITES_INTEGRATION_ENDPOINT);

const { values } = parseArgs({
args: process.argv.slice(2),
options: {
workflowId: {
type: 'string',
},
timeout: {
type: 'string',
default: (10 * 1000 * 60).toString(), // 10 mins
},
},
});

const params = {
workflow_id: values.workflowId,
ref: CF_PAGES_BRANCH,
commit_sha: CF_PAGES_COMMIT_SHA,
};

const initResponse = await fetch(new URL('/deployments', baseUrl), {
method: 'POST',
headers: {
Authorization: `AdminKey ${WEBSITES_ADMIN_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
});
const initData = await initResponse.json();
if (!initResponse.ok) {
console.error({ status: initResponse.status, data: initData });
process.exit(1);
}

let state = initData.state;
const checkUrl = new URL(initData.check_url);
const artifactUrl = new URL(initData.artifact_url);

const timeout = Number.parseInt(values.timeout);
for await (const startTime of setInterval(5000, timeout)) {
const now = Date.now();
if (now - startTime >= timeout) {
console.error(`Timeout exceeded (${timeout} ms)`);
process.exit(1);
}

const res = await fetch(checkUrl);
const data = await res.json();
if (!res.ok) {
console.error({ status: res.status, data });
process.exit(1);
}

state = data.state;
if (state.type === 'IDLE') {
throw new Error('invariant');
}
if (state.type === 'IN_PROGRESS') {
continue;
}
if (state.type === 'DONE') {
if (state.status === 'failure') {
console.error(
`Workflow run failed: https://github.com/daangn/websites/actions/runs/${state.runId}`,
);
process.exit(1);
}
if (state.status === 'cancelled') {
console.error(
`Workflow run cancelled: https://github.com/daangn/websites/actions/runs/${state.runId}`,
);
process.exit(1);
}
if (state.status === 'success') {
break;
}
}
}

console.log('Downloading artifact...');
await $`curl -fL -H "Authorization: ${WEBSITES_ADMIN_KEY}" "${artifactUrl.toString()}" -o public.tar.zst`;

console.log('Extracting artifact...');
await $`tar --use-compress-program="zstd -d" -xvf public.tar.zst`;
19 changes: 19 additions & 0 deletions _workers/websites-integration/functions/$lib/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
interface Env {
/**
* A shared authorization key for websites administrators
*/
WEBSITES_ADMIN_KEY: string;

/**
* A GitHub fine-grained token with the following permissions
* - "Actions" repository permissions (write)
*
* @see https://docs.github.com/en/rest/actions/workflows?apiVersion=2022-11-28#create-a-workflow-dispatch-event
*/
GITHUB_API_TOKEN: string;

/**
* R2 bucket binding for build artifacts
*/
ARTIFACTS: R2Bucket;
}
6 changes: 6 additions & 0 deletions _workers/websites-integration/functions/$lib/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function json(body: unknown, init?: ResponseInit): Response {
const headers = new Headers(init?.headers);
headers.set('Content-Type', 'application/json');

return new Response(JSON.stringify(body), { ...init, headers });
}
Loading

0 comments on commit 4e50205

Please sign in to comment.