From 60bc6914f99d406e4431fce8a42e45e52accf654 Mon Sep 17 00:00:00 2001 From: Nicolas Dorseuil Date: Mon, 31 Aug 2026 14:43:04 +0200 Subject: [PATCH] Deploy container server tier with CI integration for preview, staging, and production environments --- .changeset/container-server-tier.md | 2 +- .changeset/deploy-container-tier.md | 5 + .../gradual-deploy-cloudflare/action.yaml | 110 +++++++-------- .../composite/deploy-cloudflare/action.yaml | 17 ++- packages/gitbook/.dockerignore | 4 + .../customWorkers/containerWrangler.jsonc | 130 ++++++++++++++++++ .../openNext/customWorkers/middleware.js | 24 ++-- .../customWorkers/middlewareWrangler.jsonc | 18 ++- 8 files changed, 242 insertions(+), 68 deletions(-) create mode 100644 .changeset/deploy-container-tier.md create mode 100644 packages/gitbook/.dockerignore diff --git a/.changeset/container-server-tier.md b/.changeset/container-server-tier.md index 0e377ddae..2b06d7ae5 100644 --- a/.changeset/container-server-tier.md +++ b/.changeset/container-server-tier.md @@ -2,4 +2,4 @@ "gitbook": patch --- -Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Local dev only for now (`bun run build:all`, `bun run dev:cf:container`). +Add a container server tier: an `@opennextjs/aws` node build of the app running inside a Cloudflare Container, reaching the cache worker through the container Durable Object's outbound handler. Build it with `bun run build:all` and run it locally with `bun run dev:cf:container`. diff --git a/.changeset/deploy-container-tier.md b/.changeset/deploy-container-tier.md new file mode 100644 index 000000000..800025e14 --- /dev/null +++ b/.changeset/deploy-container-tier.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Deploy the container server tier from CI to preview, staging and production. Preview and staging serve their traffic from the container; production deploys it but keeps serving from the workerd tier. diff --git a/.github/actions/gradual-deploy-cloudflare/action.yaml b/.github/actions/gradual-deploy-cloudflare/action.yaml index ee9301286..fb1ffe673 100644 --- a/.github/actions/gradual-deploy-cloudflare/action.yaml +++ b/.github/actions/gradual-deploy-cloudflare/action.yaml @@ -21,63 +21,63 @@ outputs: description: 'Deployment URL' value: ${{ steps.deploy_middleware.outputs.deployment-url }} runs: - using: 'composite' - steps: - - id: wrangler_status - name: Check wrangler deployment status - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + using: 'composite' + steps: + - id: wrangler_status + name: Check wrangler deployment status + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - # This step is used to get the version ID that is currently deployed to Cloudflare. - - id: extract_current_version - name: Extract current version - shell: bash - run: | - version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') - echo "version_id=$version_id" >> $GITHUB_OUTPUT + # This step is used to get the version ID that is currently deployed to Cloudflare. + - id: extract_current_version + name: Extract current version + shell: bash + run: | + version_id=$(echo "${{ steps.wrangler_status.outputs.command-output }}" | grep -A 3 "(100%)" | grep -oP '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') + echo "version_id=$version_id" >> $GITHUB_OUTPUT - - id: deploy_server - name: Deploy server to Cloudflare at 0% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + - id: deploy_server + name: Deploy server to Cloudflare at 0% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - # Since we use version overrides headers, we can directly deploy the middleware to 100%. - - id: deploy_middleware - name: Deploy middleware to Cloudflare at 100% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc + # Since we use version overrides headers, we can directly deploy the middleware to 100%. + - id: deploy_middleware + name: Deploy middleware to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc - - name: Deploy server to Cloudflare at 100% - uses: cloudflare/wrangler-action@v3.14.0 - with: - apiToken: ${{ inputs.apiToken }} - accountId: ${{ inputs.accountId }} - workingDirectory: ./ - wranglerVersion: '4.122.0' - environment: ${{ inputs.environment }} - command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc + - name: Deploy server to Cloudflare at 100% + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc - - name: Outputs - shell: bash - env: - DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} - run: | - echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" + - name: Outputs + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy_middleware.outputs.deployment-url }} + run: | + echo "URL: ${{ steps.deploy_middleware.outputs.deployment-url }}" diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index daa53d734..e49928039 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -63,8 +63,10 @@ runs: GITBOOK_IMAGE_RESIZE_MODE: ${{ inputs.opItem }}/GITBOOK_IMAGE_RESIZE_MODE GITBOOK_ASSETS_PREFIX: ${{ inputs.opItem }}/GITBOOK_ASSETS_PREFIX GITBOOK_FONTS_URL: ${{ inputs.opItem }}/GITBOOK_FONTS_URL + # `build:all` shares a single `next build` between the workerd tier (.open-next) and the + # container tier (.open-next-container), so both are built from the same bundle. - name: Build worker - run: bun run turbo build:cloudflare + run: bun run turbo build:all env: GITBOOK_RUNTIME: cloudflare VERCEL_TARGET_ENV: ${{ inputs.environment }} @@ -94,6 +96,19 @@ runs: environment: ${{ inputs.environment }} command: ${{ format('deploy --var OPEN_NEXT_BUILD_ID:{0} --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc', steps.extract_deployment_id.outputs.deployment_id) }} + # `versions upload` never builds or pushes the container image, so the container tier uses + # `deploy` like the DO worker. It runs after the DO worker (which it binds to) and before + # the middleware (which binds to it). + - name: Deploy the container worker + uses: cloudflare/wrangler-action@v3.14.0 + with: + apiToken: ${{ inputs.apiToken }} + accountId: ${{ inputs.accountId }} + workingDirectory: ./ + wranglerVersion: '4.122.0' + environment: ${{ inputs.environment }} + command: deploy --config ./packages/gitbook/openNext/customWorkers/containerWrangler.jsonc + - id: upload_server name: Upload server to Cloudflare uses: cloudflare/wrangler-action@v3.14.0 diff --git a/packages/gitbook/.dockerignore b/packages/gitbook/.dockerignore new file mode 100644 index 000000000..1f46e36a1 --- /dev/null +++ b/packages/gitbook/.dockerignore @@ -0,0 +1,4 @@ +# The image only needs the `@opennextjs/aws` node bundle; without this the whole package +# (.next, .open-next, public, node_modules) would be sent to the Docker daemon on every deploy. +* +!.open-next-container/server-functions/default diff --git a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc index bffa3971f..ad4c91a11 100644 --- a/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/containerWrangler.jsonc @@ -54,5 +54,135 @@ }, ], }, + "preview": { + "vars": { + "STAGE": "preview", + "CONTAINER_INSTANCES": "3", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 3, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-preview", + }, + ], + "observability": { + "traces": { + "enabled": true, + "head_sampling_rate": 1, + }, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + }, + }, + }, + "staging": { + "vars": { + "STAGE": "staging", + "CONTAINER_INSTANCES": "5", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + "max_instances": 5, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-staging", + }, + ], + "tail_consumers": [ + { + "service": "gitbook-x-staging-tail", + }, + ], + }, + "production": { + "vars": { + "STAGE": "production", + "CONTAINER_INSTANCES": "10", + "OPEN_NEXT_REQUEST_ID_HEADER": "true", + }, + "containers": [ + { + "class_name": "NextServerContainer", + "image": "./Dockerfile", + "image_build_context": "../..", + "instance_type": "standard-1", + // The middleware does not route production traffic here yet (`SERVER_TIER` is + // unset there), this is headroom for when it does. + "max_instances": 10, + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "NEXT_SERVER_CONTAINER", + "class_name": "NextServerContainer", + }, + ], + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["NextServerContainer"], + }, + ], + "services": [ + { + "binding": "NEXT_INC_CACHE_WORKER", + "service": "gitbook-open-v2-do-production", + }, + ], + "tail_consumers": [ + { + "service": "gitbook-x-prod-tail", + }, + ], + }, }, } diff --git a/packages/gitbook/openNext/customWorkers/middleware.js b/packages/gitbook/openNext/customWorkers/middleware.js index 48b0958ff..a326f9ceb 100644 --- a/packages/gitbook/openNext/customWorkers/middleware.js +++ b/packages/gitbook/openNext/customWorkers/middleware.js @@ -45,17 +45,21 @@ export default class extends WorkerEntrypoint { // `container` routes to the Next server running inside a Cloudflare Container, // `worker` (the default) to the workerd server. - const serverWorker = - this.env.SERVER_TIER === 'container' - ? this.env.CONTAINER_WORKER - : this.env.DEFAULT_WORKER; + const isContainerTier = this.env.SERVER_TIER === 'container'; + const serverWorker = isContainerTier + ? this.env.CONTAINER_WORKER + : this.env.DEFAULT_WORKER; - if (this.env.STAGE !== 'preview') { - // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity - reqOrResp.headers.set( - 'Cloudflare-Workers-Version-Overrides', - `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` - ); + // The container worker is deployed, not versioned, so it has no per-version preview + // URL and version affinity does not apply to it — always go through the binding. + if (isContainerTier || this.env.STAGE !== 'preview') { + if (!isContainerTier) { + // https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/#version-affinity + reqOrResp.headers.set( + 'Cloudflare-Workers-Version-Overrides', + `gitbook-open-v2-${this.env.STAGE}="${this.env.WORKER_VERSION_ID}"` + ); + } const response = await serverWorker?.fetch(reqOrResp, { redirect: 'manual', cf: { diff --git a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc index cd4060648..7018ca146 100644 --- a/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc +++ b/packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc @@ -25,7 +25,7 @@ "NEXT_PRIVATE_DEBUG_CACHE": "true", "OPEN_NEXT_REQUEST_ID_HEADER": "true", "GITBOOK_URL": "http://localhost:8771", - // Set to "container" to route to gitbook-open-v2-container-dev instead. + // Unset (or any other value) routes to DEFAULT_WORKER instead. "SERVER_TIER": "container", }, "r2_buckets": [ @@ -58,6 +58,7 @@ "STAGE": "preview", "PREVIEW_HOSTNAME": "TO_REPLACE", "WORKER_VERSION_ID": "TO_REPLACE", + "SERVER_TIER": "container", }, "r2_buckets": [ { @@ -78,6 +79,10 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-preview", }, + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-preview", + }, ], "durable_objects": { "bindings": [ @@ -114,6 +119,7 @@ "STAGE": "staging", "WORKER_VERSION_ID": "TO_REPLACE", "OPEN_NEXT_REQUEST_ID_HEADER": "true", + "SERVER_TIER": "container", }, "routes": [ { @@ -144,6 +150,10 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-staging", }, + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-staging", + }, ], "tail_consumers": [ { @@ -217,6 +227,12 @@ "binding": "NEXT_INC_CACHE_WORKER", "service": "gitbook-open-v2-do-production", }, + // Bound but unused: production keeps `SERVER_TIER` unset so it serves from + // DEFAULT_WORKER. Setting the var here is all it takes to switch. + { + "binding": "CONTAINER_WORKER", + "service": "gitbook-open-v2-container-production", + }, ], "tail_consumers": [ {