↓ Chuyển đến nội dung chính

Fly.io Cloudflare DevOps

Mỗi nhánh một môi trường: chấm dứt cuộc tranh giành staging với Fly.io và Cloudflare

Một đội nhỏ, mỗi lập trình viên tự làm trọn một tính năng từ đầu đến cuối trên cùng một ứng dụng, một môi trường dev và một staging, cùng một luồng Slack để đặt chỗ. Cách giải là biến nhánh của lập trình viên thành đơn vị triển khai: một nhãn gắn lên PR chạy một script, script ấy rút ra một mã băm từ tên nhánh rồi đóng dấu nó lên mọi thứ — một ứng dụng Fly, một cơ sở dữ liệu Postgres nhân bản từ mẫu, một tiền tố khóa Redis, một tiền tố Tigris, một bộ bí mật được sinh mới, và một tên miền dưới wildcard của Cloudflare. Hạ tầng vẫn dùng chung; việc tách biệt là ở tầng logic, và bị dỡ bỏ khi nhánh bị xóa. Tôi đã dựng thật trên ứng dụng chợ của mình, chạy ba nhánh song song rồi tháo dỡ, và có hai điều chỉ lộ ra khi bắt tay vào làm.

Hãy hình dung một đội nhỏ làm chung một ứng dụng. Hai lập trình viên, hay năm, cũng được. Không phải các chuyên gia mỗi người một mảng, mà là những người mỗi người trên nhánh riêng của mình, ôm trọn một tính năng từ migration cơ sở dữ liệu, qua API, đến màn hình trên ứng dụng di động. Đó là cách vận hành hay cho một đội, và kể từ khi có lập trình viên thứ hai, nó có một lỗi lặp đi lặp lại: tin nhắn “Có ai đang dùng staging không?”

Đằng sau tin nhắn ấy là một lập trình viên đã làm xong tính năng, muốn tester hoặc designer xem, mà không được, vì staging đang chạy nhánh của người thứ hai, còn dev đang chạy nhánh của người thứ ba, cái nhánh đã làm hỏng đăng nhập hai ngày trước và từ đó chưa được triển khai lại. Vài người, hai môi trường dùng chung, và một luồng Slack làm việc xếp lịch.

Đó không phải vấn đề con người. Đó là vấn đề topology. Mỗi lập trình viên đều coi môi trường là nơi nhánh của mình trở thành thật, nên môi trường đang làm thay việc mà merge đáng lẽ phải làm, mỗi lần một nhánh, và những người còn lại xếp hàng chờ.

Tôi đã mô tả hình dạng production của stack mình trong My Current Stack: Django chạy trên Granian trên những máy rẻ nhất của Fly.io, Fly Postgres sau PgBouncer, Redis cho cache và tầng Channels, một ứng dụng WebSocket riêng, một db_worker cho việc nền, Tigris cho tệp, Firebase Auth, Cloudflare đứng trước, và một ứng dụng Expo nói chuyện với tất cả. Bài này kể tôi đã biến stack ấy thành thứ mà với bất kỳ nhánh nào, lập trình viên chỉ cần gắn một nhãn lên PR là vài phút sau có ngay bản sao công khai, có HTTPS, của toàn bộ hệ thống, không phải nhờ ai khác và không ai phải cấp phát (provision) cho, mà cũng không cần cụm Postgres thứ hai, Redis thứ hai hay bucket thứ hai. Hạ tầng vẫn dùng chung. Điều thay đổi là mọi tài nguyên dùng chung học cách bị cắt lát theo một cái tên, và cái tên ấy đến từ nhánh. Không có gì trong đó giả định quy mô đội: thiết kế này giống hệt nhau cho hai lập trình viên hay hai mươi, thứ duy nhất tăng lên là danh sách ứng dụng, và việc đó đã có bộ dọn dẹp (sweeper) lo. Tôi dựng nó, chạy ba nhánh cùng lúc, làm hỏng ở hai chỗ không lường trước, rồi dỡ đi, tất cả trong một buổi chiều; các kết quả bên dưới lấy từ lần chạy đó.

Vì sao một môi trường dùng chung không chịu nổi quá một lập trình viên
#

Một môi trường dev dùng chung có sẵn một giả định: chỉ có một “phiên bản code hiện tại” mà ai cũng muốn xem. Điều đó đúng với một lập trình viên, hoặc với một nhóm cùng bước trên một dòng thay đổi duy nhất. Nó hết đúng ngay khi người thứ hai bắt đầu một tính năng theo lịch khác, và với kiểu sở hữu trọn gói thì tính năng nào cũng thế. Từ đó, mọi thứ môi trường nắm giữ đều thành thứ bị tranh:

  • Code đã triển khai. Ai triển khai sau cùng thì thắng. Công việc của những người còn lại vô hình cho đến khi họ triển khai lại, và việc đó xóa của người trước.
  • Schema cơ sở dữ liệu. Đây là chỗ cắn đau nhất khi mỗi người sở hữu mô hình dữ liệu của tính năng mình. Nhánh thứ nhất thêm một cột. Nhánh thứ hai, triển khai một giờ sau, không biết về cột đó và ORM bắt đầu lỗi khi insert. Hoặc cả hai nhánh cùng có migration 0042_*, cơ sở dữ liệu dùng chung rơi vào trạng thái không khớp lịch sử migration của ai, rồi ai đó reset nó, xóa luôn dữ liệu test mà người thứ ba mất cả buổi chiều chuẩn bị.
  • Cache. Thay đổi serializer của một nhánh bị cache dưới đúng những khóa Redis mà code nhánh khác đọc. Các báo cáo lỗi sinh ra từ đó khó hiểu và ngốn mỗi cái một buổi sáng.
  • Tích hợp bên thứ ba. Một OAuth callback, một URL webhook, một chứng chỉ push. Nhánh của ai đang được triển khai thì nhánh ấy nhận sự kiện.
  • Tester. Họ chỉ test được thứ đã triển khai, nên họ test từng tính năng theo thứ tự môi trường tình cờ bị chiếm, chứ không theo thứ tự tính năng sẵn sàng.

Nhóm ứng phó bằng hệ thống đặt chỗ: một luồng Slack, một tin nhắn ghim, một bot /claim staging. Đó là một cái khóa, và khóa đặt lên nơi duy nhất công việc của bạn có thể được nhìn thấy là khóa đặt lên tốc độ giao hàng. Với hai người thì chỉ hơi khó chịu. Với ba người thì tuần thuận lợi còn chịu được. Thêm người thứ tư, hoặc có một tính năng giữ staging ba ngày để qua lại với designer, là hết chịu nổi.

Cách sửa đầu tiên người ta với tới là môi trường dùng chung thứ ba, “dev2” hay “uat”. Nó mua được vài tuần. Hàng chờ chỉ có thêm một làn.

Cách sửa thật sự là nhận ra dev và staging chưa bao giờ là môi trường. Chúng là trạng thái hiện tại của nhánh ai đó, kèm một URL. Nếu chúng là thế, thì số lượng đúng là số nhánh mà cả đội đang quan tâm lúc này, và vòng đời đúng là vòng đời của nhánh.

Mô hình: nhánh chính là môi trường
#

Đây là trạng thái đích.

Môi trườngBao nhiêuAi tạoAi hủySống bao lâu
Môi trường nhánh (trước là “dev”)Mỗi nhánh đang hoạt động một cáiNhãn env trên PR của nhánh, rồi mỗi lần push sau đó; với một spike chưa có PR thì tự chạy cùng script ./scripts/env up ấy bằng tayXóa nhánh, cộng với trình quét chạy hằng đêmVài ngày
StagingMỗi ứng viên phát hành một cáiCI khi cắt tag từ mainCI khi cùng image đó được đưa lên prodVài giờ
ProdMộtBạn, một lầnKhông aiMãi mãi

Hãy nhìn lại trong bảng xem ai tạo và ai hủy: mục nào cũng là một sự kiện git hoặc một cái đồng hồ. Git chính là mặt phẳng điều khiển (control plane). Tập hợp môi trường cần tồn tại được suy thẳng từ tập hợp nhánh đang tồn tại, và việc duy nhất của tự động hóa là giữ cho thực tế khớp với nó: nhãn trên PR thì tạo, mỗi lần push sau đó thì cập nhật, đóng hay merge PR thì hủy, và một job chạy hằng đêm dọn nốt những gì ba bước trước bỏ sót. Cũng không phải nhánh nào cũng có môi trường: nhãn chính là cái cổng, một cú bấm của lập trình viên, nên một lần push để sao lưu hay bản cập nhật thư viện của bot không tốn gì. Không ai cấp phát môi trường cả. Không phải kỹ sư DevOps nhận ticket, không phải bot bạn gọi trên Slack, không phải AI agent bạn phải nhắc (prompt) mỗi lần. Nhánh đầu tiên của lập trình viên thứ mười có môi trường theo đúng cách nhánh của người thứ nhất đã có: vì PR của nó được gắn nhãn. Nếu vẫn phải nhờ đến một người, thì hệ thống chưa mở rộng được; hàng đợi chỉ đổi chỗ thôi.

Và quy tắc khiến nó vận hành: mọi tài nguyên mà một môi trường chạm vào đều được đặt tên theo nhánh, một cách tất định. Không theo lập trình viên, không theo ticket, không theo số PR vốn không tồn tại cho đến khi ai đó mở. Theo nhánh, vì nhánh là thứ lập trình viên thật sự làm việc cùng, và vì tên tất định nghĩa là lần chạy ./scripts/env up thứ hai trên cùng nhánh sẽ tìm thấy môi trường đã tạo thay vì tạo thêm cái mới.

Tên gồm một slug cho người đọc và một mã băm ngắn để đảm bảo duy nhất:

branch=$(git rev-parse --abbrev-ref HEAD)              # feature/payments-retry
slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20)
hash=$(printf '%s' "$branch" | sha256sum | cut -c1-6)
ENV="${slug%-}-${hash}"                                 # feature-payments-retr-3f9a1c

feature-payments-retr-3f9a1c là môi trường. Chuỗi đó trở thành tên ứng dụng Fly, tên cơ sở dữ liệu Postgres (gạch ngang đổi thành gạch dưới), tiền tố khóa Redis, tiền tố đối tượng Tigris, environment của Sentry, và nhãn ngoài cùng bên trái của tên miền. Ai biết tên nhánh đều tính ra được. Không phải tra cứu ở đâu.

Câu cuối ấy chính là thiết kế. Không có sổ đăng ký môi trường và không có người điều phối. env up, env down, Cloudflare Worker định tuyến tên miền, và trình quét hằng đêm, mỗi thứ tự tính lại tên từ nhánh một cách độc lập và khớp nhau nhờ cấu trúc. Riêng Cloudflare không điều phối gì cả: Worker là một hàm thuần từ tên miền sang ứng dụng Fly, và nó không bao giờ biết một môi trường vừa được tạo hay hủy. Ứng dụng tồn tại thì yêu cầu đến nơi; không thì Fly trả lời bằng lỗi của chính nó.

Ba điều rút ra từ đây.

Thứ nhất, “ai đang dùng staging” không còn là câu hỏi. Có feature-payments-retr-3f9a1c-dev.marucommunity.com và có fix-login-redirect-8b21e0-dev.marucommunity.com, mỗi cái thuộc về người đang ở nhánh đó.

Thứ hai, staging trở thành buổi tổng duyệt trung thực của prod thay vì bãi rác. Nó được dựng từ chính image sẽ lên prod, trên cơ sở dữ liệu nhân bản vài phút trước từ snapshot có hình dạng prod. Chạy được ở đó thì biến số duy nhất còn lại trên prod là dữ liệu của prod.

Thứ ba, mỗi lập trình viên có quyền lực mà không cần root trên bất cứ thứ gì dùng chung. Bán kính nổ của một môi trường nhánh là chính môi trường nhánh đó. Người thứ tư vào ngày đầu có thể triển khai gì tùy thích lên nhánh mình, và kết cục tệ nhất là nhánh của họ hỏng.

Bảng điều khiển Fly.io của tổ chức: ba ứng dụng maru-feature-* (hai cái đã suspended) bên cạnh các ứng dụng koreapost production và Postgres, Redis preview dùng chung
Danh sách ứng dụng của tổ chức khi ba nhánh đang chạy. Hai trong ba đã suspended chỉ một phút sau khi triển khai; Postgres và Redis preview dùng chung nằm bên cạnh. Prod là các dòng koreapost bên dưới.

Dùng chung về vật lý, tách biệt về logic
#

Phiên bản cám dỗ của “mỗi nhánh một môi trường” là sao chép toàn bộ: mỗi cái một cụm Postgres, một Redis, một bucket. Sạch sẽ, và là một cuộc đổi chác sai. Một cụm Fly Postgres là một máy cộng một volume cộng một lần khôi phục; một Redis là thêm một máy nữa; bạn sẽ chờ vài phút cho mỗi môi trường và trả tiền cho hàng chục cơ sở dữ liệu nằm không. Trình quét sẽ phải dọn bốn loại thứ thay vì một.

Thay vào đó, mỗi dịch vụ dùng chung được cấp phát một lần cho cả tổ chức preview, cỡ như một prod nhỏ, và mỗi môi trường nhận một lát cắt khóa theo $ENV. Đây là bảng tôi muốn dán lên tường:

Tài nguyênThứ dùng chungThứ riêng từng môi trườngĐược đảm bảo bởi
Tính toánTổ chức Fly maru-previewMột ứng dụng Fly maru-$ENV, một máyFly: ứng dụng là đơn vị cô lập
Bí mậtKho bí mật theo ứng dụng của FlyBí mật của riêng ứng dụng; SECRET_KEY, FIELD_KEY và ENV_TOKEN được sinh mới cho từng môi trường, không sao chépFly: bí mật thuộc về ứng dụng, máy chỉ đọc được của mình
PostgresMột cụm Fly Postgres maru-preview-pgMột cơ sở dữ liệu env_feature_payments_retr_3f9a1c, nhân bản từ seed_templatePostgres: cơ sở dữ liệu là ranh giới cứng, không truy vấn chéo được
RedisMột Upstash/Fly RedisTiền tố khóa feature-payments-retr-3f9a1c: trên cache và tầng ChannelsKEY_PREFIX của Django, prefix của channels_redis
TệpMột bucket Tigris maru-previewTiền tố đối tượng feature-payments-retr-3f9a1c/location của django-storages; URL ký sẵn vốn đã giới hạn theo khóa
Tên miềnBản ghi *.marucommunity.com đã proxy sẵn có của zoneNhãn ngoài cùng bên trái <env>-devMột route Cloudflare Worker trên *-dev.marucommunity.com/*
Xác thựcMột dự án Firebase maru-devKhông có gì; danh tính người dùng cố ý dùng chung (xem dưới)Tên miền được ủy quyền marucommunity.com
Lỗi và logMột dự án Sentry, log của FlyThẻ environment=$ENV trên mọi thứCấu hình

Hai dòng trong đó đáng nói thêm một câu.

Postgres: cơ sở dữ liệu, không phải schema. Cách khác để cắt lát một cụm là mỗi môi trường một schema, đặt search_path theo từng kết nối. Django làm được nếu bạn nhét -c search_path=... qua options của kết nối, nhưng PgBouncer ở chế độ transaction pooling bỏ qua tham số khởi động trừ khi bạn bảo nó đừng, và dù có thế thì mọi kết nối trong pool rốt cuộc cũng phải thuộc về một môi trường. Đó là nguồn của những lỗi kiểu “sao truy vấn của tôi lại chạm bảng khác”, thứ mà mỗi môi trường một cơ sở dữ liệu đơn giản là không có. Cơ sở dữ liệu là ranh giới cứng, CREATE DATABASE ... TEMPLATE tạo một cái trong vài giây, và DROP DATABASE xóa nó không để lại gì. Các môi trường nhánh kết nối thẳng vào Postgres qua cổng 5433 thay vì qua PgBouncer, giống như db_worker làm trên prod; pooler chỉ đáng giá ở mức đồng thời của production, thứ mà một môi trường nhánh không bao giờ thấy.

Bí mật: sinh mới, không dùng chung. Sẽ dễ nếu có một bộ bí mật dev rồi chép vào mọi môi trường. Đừng. Script env up sinh SECRET_KEY (ký phiên), FIELD_KEY (mọi thứ mã hóa khi lưu) và ENV_TOKEN (bearer token mà ứng dụng di động xuất trình, nói thêm bên dưới) mới cho mỗi môi trường và chỉ lưu trong kho bí mật của ứng dụng Fly đó. Nghĩa là cookie phiên của nhánh này vô giá trị trên nhánh khác, một token preview bị lộ chỉ mở được một preview, và lập trình viên không bao giờ thấy giá trị trừ khi cố tình tìm. Nếu bạn giữ bí mật ở nơi như 1Password hay Doppler thay vì chỉ trong Fly, quy tắc vẫn vậy: đường dẫn là maru/preview/$ENV/, và trình quét xóa đường dẫn cùng với môi trường. Những bí mật thật sự dùng chung giữa các môi trường chỉ là thông tin đăng nhập của chính hạ tầng dùng chung (URL quản trị Postgres, URL Redis, khóa Tigris), và chúng nằm trong CI, không nằm trong môi trường nào.

Dòng Redis là dòng dễ nghi ngờ nhất, nên đây là bài kiểm tra. Đặt một khóa cache từ bên trong một môi trường, đọc từ môi trường kia, rồi liệt kê mọi khóa trong Redis dùng chung:

$ fly ssh console -a maru-feature-profile-tagl-fa8832 -C "python manage.py shell -c \"from django.core.cache import cache; cache.set('who-am-i','tagline-branch',600)\""
$ fly ssh console -a maru-feature-profile-webs-dcb2e7 -C "python manage.py shell -c \"from django.core.cache import cache; print(cache.get('who-am-i'))\""
None

$ # every key in the shared Redis, scanned from the website machine
  feature-profile-tagl-fa8832:1:who-am-i
  release:89112c88f7da008e:done
  ...

Cùng một Redis, cùng tên khóa, môi trường kia không thấy gì. (Các khóa release:* trong danh sách đó là lỗi cổng phát hành tôi nhắc ở trên, bị bắt bởi chính lần quét này; giờ chúng đã nằm dưới tiền tố.)

Phần còn lại của bài là script đóng dấu $ENV lên tám dòng ấy, và các quy tắc giữ cho nó trung thực.

./scripts/env up
#

Đây là toàn bộ, dưới dạng một script lập trình viên chạy từ nhánh của mình. CI chạy cùng script; không có đường riêng cho CI để lệch đi. Trên laptop chỉ cần fly và git, không gì khác: SQL đi tới ứng dụng Postgres qua fly ssh, còn khóa Redis và đối tượng Tigris của môi trường được chính máy của môi trường xóa trước khi nó bị hủy, nên laptop không giữ thông tin đăng nhập Redis hay bucket dùng chung ngoài một tệp .env.preview đã gitignore.

#!/usr/bin/env bash
# scripts/env up|down|name|url — one environment per branch on shared preview infrastructure
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; }   # shared-infra credentials; CI secrets in Actions

ORG=${PREVIEW_ORG:-korea-post}
PREVIEW_PG_APP=${PREVIEW_PG_APP:-maru-preview-pg}
BASE_DOMAIN=${BASE_DOMAIN:-marucommunity.com}

branch=${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}
[[ "$branch" == "main" ]] && { echo "main has no branch environment; it has staging" >&2; exit 1; }

slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20); slug=${slug%-}
hash=$(printf '%s' "$branch" | shasum -a 256 | cut -c1-6)
ENV="${slug}-${hash}"
[[ -n "${ENV_OVERRIDE:-}" ]] && ENV="$ENV_OVERRIDE"       # the sweeper only knows the name
APP="maru-${ENV}"
DB="env_${ENV//-/_}"
HOST="${ENV}-dev.${BASE_DOMAIN}"

pg() { fly ssh console -a "$PREVIEW_PG_APP" -q -C "psql postgres://postgres:${PREVIEW_PG_PASSWORD}@localhost:5433/postgres -At -c \"$1\""; }

up() {
  echo "== $branch -> $ENV"
  # 1. Compute. Idempotent: an app that exists is left alone.
  fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\"" || fly apps create "$APP" --org "$ORG"

  # 2. Database: clone the seed template unless this branch already has one.
  if [[ "$(pg "SELECT 1 FROM pg_database WHERE datname='$DB'")" != "1" ]]; then
    pg "CREATE DATABASE $DB TEMPLATE seed_template"
  fi
  DATABASE_URL="postgres://postgres:${PREVIEW_PG_PASSWORD}@${PREVIEW_PG_APP}.flycast:5432/${DB}"

  # 3. Secrets. Generated ones are generated once; a later `up` must not rotate them.
  existing=$(fly secrets list -a "$APP" --json 2>/dev/null | grep -o '"Name": *"[^"]*"' | cut -d'"' -f4 || true)
  gen() { grep -qx "$1" <<<"$existing" || printf '%s=%s\n' "$1" "$(openssl rand -hex 32)"; }
  {
    gen SECRET_KEY
    gen ENV_TOKEN
    cat <<S
ENV_NAME=$ENV
ALLOWED_HOSTS=$HOST,$APP.fly.dev
CSRF_TRUSTED_ORIGINS=https://$HOST,https://$APP.fly.dev
DATABASE_URL=$DATABASE_URL
REDIS_URL=$PREVIEW_REDIS_URL
BUCKET_NAME=$PREVIEW_BUCKET
AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev
AWS_ACCESS_KEY_ID=$PREVIEW_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$PREVIEW_AWS_SECRET_ACCESS_KEY
WS_URL=wss://$HOST
S
  } | fly secrets import -a "$APP" --stage

  # 4. Deploy the working tree, stamped with what it is.
  fly deploy -a "$APP" --config fly.preview.toml --remote-only --ha=false \
    --build-arg GIT_SHA="$(git rev-parse --short HEAD)" --build-arg GIT_BRANCH="$branch" \
    --image-label "$(git rev-parse --short HEAD)"
  echo "https://$HOST"
}

down() {
  [[ "$APP" =~ ^maru-[a-z0-9-]+-[0-9a-f]{6}$ ]] || { echo "refusing to destroy $APP" >&2; exit 1; }
  if fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\""; then
    fly machine start -a "$APP" >/dev/null 2>&1 || true
    fly ssh console -a "$APP" -q -C "python manage.py env_teardown" || echo "warning: self-teardown failed; the sweeper will catch stragglers" >&2
    fly apps destroy "$APP" --yes
  fi
  pg "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB'" >/dev/null || true
  pg "DROP DATABASE IF EXISTS $DB" || true
}

case "${1:-}" in up) up ;; down) down ;; name) echo "$ENV" ;; url) echo "https://$HOST" ;; esac

Trong bản demo tôi chạy nó từ laptop thay vì push, vì ba nhánh này là đồ dùng một lần tôi không muốn để lại trong lịch sử kho, và tôi muốn nhìn output chạy ra từng dòng. Khi cả đội dùng thì không ai gõ lệnh này cả: workflow gắn nhãn ở phần dưới chạy đúng script này, và những gì bạn thấy tiếp theo là thứ sẽ nằm trong log của Actions. Đây là những gì nó in ra lần đầu, trên chính nhánh chứa bộ công cụ:

$ ./scripts/env up
== feature/branch-environments -> feature-branch-envir-c65cb4
New app created: maru-feature-branch-envir-c65cb4
creating database env_feature_branch_envir_c65cb4 from seed_template
CREATE DATABASE
Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
==> Building image with Depot
image: registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9
image size: 139 MB
> Machine 865529aee76918 [app] was created
✔ Machine 865529aee76918 [app] update finished: success
https://feature-branch-envir-c65cb4-dev.marucommunity.com

$ curl -s https://feature-branch-envir-c65cb4-dev.marucommunity.com/api/version/
{"environment": "feature-branch-envir-c65cb4", "branch": "feature/branch-environments", "commit": "87c061b9",
 "image": "registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9", "app": "maru-feature-branch-envir-c65cb4", "region": "syd"}

Khoảng hai phút rưỡi từ lệnh đến URL, phần lớn là build image. Lần up thứ hai trên cùng nhánh, sau một commit nữa, không in “New app created” hay “creating database”, stage đúng các tên bí mật cũ mà không sinh lại hai cái được sinh, rồi build lại: cùng URL, commit mới.

Vài lựa chọn trong đó xứng đáng mỗi cái một câu.

fly.preview.toml không phải fly.toml. Prod chạy API, máy chủ WebSocket và db_worker như các ứng dụng Fly và nhóm tiến trình riêng để mỗi thứ mở rộng theo chỉ số của mình, đó là toàn bộ lập luận của bài về stack. Một môi trường nhánh không cần mở rộng; nó cần là một máy. Nên cấu hình preview chỉ có một tiến trình, và entrypoint.sh có thêm chế độ granian-preview chạy cổng phát hành và migration rồi khởi động một Granian dưới ASGI với --ws. ProtocolTypeRouter trong asgi.py vốn đã định tuyến cả http lẫn websocket, nên một tiến trình phục vụ cả ứng dụng. Ở chế độ đó các view đồng bộ chạy thread_sensitive, gần như mỗi lần một yêu cầu, chính là cuộc đổi chác tôi đã rời bỏ trên prod và lại đúng ở đây, vì không ai test một nhánh mà nhận ra.

# fly.preview.toml (the app name is supplied by `fly deploy --app`)
app = 'branch-environment-placeholder'
primary_region = 'syd'

[processes]
  app = "granian-preview"

[env]
  DJANGO_SETTINGS_MODULE = 'koreapost_project.settings'
  DEBUG = 'False'
  WEB_BUNDLE_FROM_BUCKET = '0'     # the image's own web bundle; the preview bucket never holds one

[http_service]
  internal_port = 8000
  force_https = true
  auto_stop_machines = 'suspend'
  auto_start_machines = true
  min_machines_running = 0
  [[http_service.checks]]
    path = '/api/health/'
    [http_service.checks.headers]
      Host = '127.0.0.1'           # base.py allows loopback; the app's own hostname isn't known to a static file

[[vm]]
  size = 'shared-cpu-1x'
  memory = '1gb'

auto_stop_machines = "suspend", min_machines_running = 0. Một môi trường nhánh không ai mở từ sau bữa trưa nên tốn số không. Suspend thay vì stop cho ra khoảng 3 giây khôi phục mà tôi đạt được sau khi đuổi theo lỗi 504 thay vì 30 giây khởi động lạnh, và ở đây ngay cả 30 giây cũng chấp nhận được. Hai trong ba môi trường demo đã suspended trong fly apps list chỉ một phút sau khi triển khai xong.

Tổng quan Fly.io của một ứng dụng nhánh: một máy shared-cpu-1x ở SYD, một nhóm tiến trình, tên miền *.fly.dev riêng
Một môi trường nhánh dưới mắt Fly: một máy, một nhóm tiến trình, một vùng. Không có gì ở đây đáng để mở rộng.

DEBUG = False, cùng module settings với prod. Môi trường nhánh là một URL công khai. Nó nên hành xử như prod ở mọi mặt quan trọng cho việc test (HTTPS thật, cookie thật, cùng health check) và khác prod ở mọi mặt có thể làm ai đó bị hại. Thay vì một preview.py riêng, sự khác biệt được mang bởi các bí mật mà env up đặt: khóa thanh toán sandbox, không có thông tin quản trị Firebase, và ENV_NAME bật mọi tiền tố bên dưới.

Một cấu hình cắt lát mọi thứ. ENV_NAME là núm vặn mới duy nhất trong settings/base.py, và được áp ở bốn chỗ:

ENV_NAME = os.getenv("ENV_NAME", "")
_ENV_PREFIX = f"{ENV_NAME}/" if ENV_NAME else ""

CACHES["default"]["KEY_PREFIX"] = ENV_NAME                     # every cache key, and the list-cache version counters
CHANNEL_LAYERS["default"]["CONFIG"]["prefix"] = f"{ENV_NAME}:asgi"  # group and channel names
STORAGES["default"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}media"  # uploads
STORAGES["staticfiles"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}static"

Bỏ sót chỗ channels thì máy chủ WebSocket của hai nhánh dùng chung tên nhóm, và tin nhắn gửi ở môi trường này hiện ra ở môi trường kia. Đó là loại lỗi tốn cả ngày.

Còn chỗ thứ năm tôi chỉ tìm thấy khi đọc Redis dùng chung sau đó. Cổng phát hành từ bài về 504, thứ cho phép mỗi image chỉ một máy chạy migration trong khi các máy khác chờ, khóa lock theo mã băm của tham chiếu image. Hai môi trường nhánh dựng từ cùng một commit có cùng image. Cái thứ hai sẽ thấy dấu “done” của cái thứ nhất và bỏ qua migration của chính nó trên cơ sở dữ liệu chưa migration của chính nó. Giờ tên môi trường là một phần của giá trị được băm và các khóa nằm dưới tiền tố. Đó là loại chuyện mà thiết kế hạ tầng dùng chung cứ sinh ra mãi: mọi thứ ngầm là “theo lần triển khai” phải được làm rõ thành theo môi trường, và bạn không biết cái gì là ngầm cho đến khi nhìn vào.

Cơ sở dữ liệu được nhân bản, không migration từ rỗng. Đó là mục tiếp theo.

Cơ sở dữ liệu: một mẫu, nhân bản trong vài giây
#

Cơ sở dữ liệu của một môi trường nhánh có thể là một trong ba thứ. Dùng chung với mọi người, thứ đưa chúng ta đến đây. Rỗng, migration từ số không rồi đổ fixture: tất định, nhưng fixture chưa bao giờ tốt bằng dữ liệu có hình dạng thật và luôn cũ mười tám tháng. Hoặc một bản nhân bản từ snapshot có hình dạng prod: số dòng thực tế, những giá trị null thực tế trong các cột mà fixture cứ tưởng luôn có dữ liệu, cái migration tức thì trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật sẽ lộ mặt.

Chọn cách thứ ba, giữ cách thứ hai làm một kiểm tra chỉ chạy trên CI. Và thứ khiến cách thứ ba đủ nhanh để làm cho từng nhánh là một tính năng Postgres mà hầu hết mọi người quên là có tồn tại:

-- Nightly, on maru-preview-pg. Restore last night's prod backup into seed_raw,
-- run the anonymiser, then freeze it as a template nobody can connect to.
ALTER DATABASE seed_raw RENAME TO seed_template;
UPDATE pg_database SET datistemplate = true, datallowconn = false WHERE datname = 'seed_template';

-- Per branch, in the time it takes to copy the files:
CREATE DATABASE env_feature_payments_retr_3f9a1c TEMPLATE seed_template;

CREATE DATABASE ... TEMPLATE là sao chép ở mức tệp trong cụm. Vài gigabyte là vài giây, không phải vài phút như pg_restore. Mẫu không được có kết nối mở trong khi đang nhân bản, đó là điều datallowconn = false đảm bảo, và cũng là lý do tác vụ đêm dựng vào seed_raw rồi đổi tên ở cuối thay vì khôi phục đè lên mẫu đang sống.

Trong demo, mẫu được dựng bằng tay thay vì từ dump prod: một đường hầm tới cụm preview bằng fly proxy, manage.py migrate, rồi các lệnh seed sẵn có của repo cho hệ phân loại, một người mua và một người bán kèm một cuộc trò chuyện, và hai mươi bài viết cộng đồng. Mười chín megabyte. Ba bản nhân bản của nó xuất hiện trong cụm khi ba nhánh khởi lên, mỗi cái 19 MB, mỗi cái chưa tới một giây:

$ fly ssh console -a maru-preview-pg -C "psql ... -c '\l'"
             datname             | template | size
---------------------------------+----------+-------
 env_feature_branch_envir_c65cb4 | f        | 19 MB
 env_feature_profile_tagl_fa8832 | f        | 19 MB
 env_feature_profile_webs_dcb2e7 | f        | 19 MB
 seed_template                   | t        | 19 MB

Cụm preview là một node Fly Postgres không quản lý, shared-cpu-1x với volume 1 GB, khoảng hai đô la một tháng. Các môi trường nhánh kết nối tới nó qua .flycast trên mạng riêng; không có gì công khai.

Bộ ẩn danh hóa không phải tùy chọn. Khoảnh khắc dữ liệu prod có thể truy cập trên một tên miền -dev, nó phải không còn là dữ liệu prod: tên, email, số điện thoại, địa chỉ, văn bản tự do, tham chiếu thanh toán, token, tất cả bị ghi đè trước khi đổi tên. Nếu bộ ẩn danh hóa thất bại, mẫu của đêm trước được giữ lại và ai đó nhận tin nhắn. Đó là một việc nhỏ và là thứ khiến toàn bộ cách tiếp cận này chấp nhận được với người trả lời bảng câu hỏi về quyền riêng tư.

Cổng phát hành trong entrypoint.sh chạy migrate ở lần khởi động đầu của mỗi image, nên migration của chính nhánh được áp lên trên mẫu. Đó là bài kiểm tra trung thực đầu tiên cho câu “migration của tôi có chạy trên dữ liệu có hình dạng prod không”, và nó xảy ra trước khi ai đó mở URL.

Tên miền: một wildcard, một Worker, và hai điều tài liệu không nói
#

Mỗi môi trường cần một tên miền HTTPS công khai, và “công khai” chính là điểm mấu chốt: tester cầm điện thoại, designer ở mạng khác, product manager không chịu cài VPN. Fly cho mỗi ứng dụng maru-$ENV.fly.dev miễn phí, và chỉ thế cũng chạy được. Nhưng tên miền *.fly.dev có ba vấn đề: gửi đi trông xấu, Cloudflare không đứng trước nên không có lớp bảo vệ nào của prod được áp dụng, và nếu muốn đặt chúng dưới tên miền của mình thì phải tạo mỗi môi trường một bản ghi DNS và một chứng chỉ, thêm một thứ nữa cho trình quét.

Phiên bản loại bỏ tất cả những thứ ấy là một wildcard và một Cloudflare Worker. Tôi đã định dùng *.dev.marucommunity.com. Hai điều đã chặn lại, và cả hai đều đáng biết trước khi bạn lên kế hoạch tương tự.

Universal SSL chỉ phủ một tầng wildcard. Chứng chỉ miễn phí của Cloudflare được cấp cho marucommunity.com và *.marucommunity.com. Nó không phủ *.dev.marucommunity.com; wildcard tầng hai cần Advanced Certificate Manager giá 10 đô la một tháng. Yêu cầu đầu tiên tới feature-branch-envir-c65cb4.dev.marucommunity.com chết vì lỗi bắt tay SSL trước khi bất cứ thứ gì của tôi kịp chạy. Cách sửa là giữ môi trường ở nhãn đầu tiên: feature-branch-envir-c65cb4-dev.marucommunity.com. Nó được chứng chỉ sẵn có phủ, và được bản ghi *.marucommunity.com đã proxy sẵn có phủ, chính bản ghi tôi dựng làm hố đen cho các trình quét lần mò những tên miền con bịa đặt. Vậy nên một môi trường hoàn toàn không cần DNS.

Redirect Rules chạy trước Workers. Với route đã đặt, mọi tên miền -dev đều trả 301 về trang chủ www mà Worker không hề chạy. Hố đen là một quy tắc chuyển hướng cấp zone, “mọi tên miền con khác → www”, và pha chuyển hướng của Cloudflare thực thi trước pha Workers. Quy tắc cần thêm một mệnh đề: and not ends_with(http.host, "-dev.marucommunity.com"). Script triển khai thêm nó khi up và gỡ khi down, vì một quy tắc ai đó sửa tay chính là thứ bị quên.

Quy tắc chuyển hướng trong bảng điều khiển Cloudflare, biểu thức kết thúc bằng ngoại lệ -dev
Chuyển hướng hố đen sau khi script đã can thiệp. Mọi thứ dưới zone vẫn bật về www, trừ các tên miền kết thúc bằng -dev.marucommunity.com, chúng lọt xuống Worker.

Phía DNS là bản ghi vốn đã có sẵn:

Bản ghi DNS của zone lọc theo wildcard: một bản ghi A đã proxy *.marucommunity.com trỏ tới 192.0.2.1
Bản ghi DNS duy nhất liên quan, và nó có trước bài viết này. Một wildcard đã proxy trỏ tới địa chỉ giữ chỗ: Cloudflare trả lời, còn chuyện tiếp theo tùy quy tắc và Workers.

Gỡ xong hai thứ đó, việc định tuyến là một Worker duy nhất trên *-dev.marucommunity.com/*:

// infra/dev-router.js — <env>-dev.marucommunity.com -> maru-<env>.fly.dev
const DEV_SUFFIX = "-dev.marucommunity.com";
const ENV_NAME = /^[a-z0-9-]+-[0-9a-f]{6}$/;   // slug-hash, as scripts/env names them

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (!url.hostname.endsWith(DEV_SUFFIX)) return new Response("not a dev hostname", { status: 404 });
    const env = url.hostname.slice(0, -DEV_SUFFIX.length);
    if (!ENV_NAME.test(env)) return new Response(`no such environment: ${env}`, { status: 404 });

    url.hostname = `maru-${env}.fly.dev`;
    const upstream = new Request(url, request);
    upstream.headers.set("X-Forwarded-Host", request.headers.get("host") ?? "");

    const res = await fetch(upstream);
    const out = new Response(res.body, res);
    out.headers.set("X-Robots-Tag", "noindex, nofollow");   // a half-finished branch is not for search engines
    out.headers.set("X-Dev-Environment", env);
    return out;
  },
};

Cloudflare kết thúc TLS trên wildcard, Worker tính tên miền Fly từ nhãn, và chứng chỉ *.fly.dev của chính Fly phủ chặng tới máy. Nâng cấp WebSocket đi qua fetch không đổi. Và vì route nằm trên zone, mọi thứ Cloudflare làm cho prod đều dùng được: quy tắc WAF, giới hạn tốc độ, chế độ chống bot, và Access.

Trang Workers Routes của zone trên Cloudflare: một route *-dev.marucommunity.com/* gắn với Worker dev-router
Toàn bộ phía Cloudflare của một môi trường: một mẫu route, một Worker. Không có tên môi trường nào xuất hiện ở bất kỳ đâu trong bảng điều khiển này.

Ba nhánh cùng chạy, một Worker, và câu trả lời của từng tên miền:

Tên miềnHTTPĐịnh tuyến tớibranchcommit
feature-branch-envir-c65cb4-dev.marucommunity.com200maru-feature-branch-envir-c65cb4.fly.devfeature/branch-environments6560ec9a
feature-profile-tagl-fa8832-dev.marucommunity.com200maru-feature-profile-tagl-fa8832.fly.devfeature/profile-taglinee3baa936
feature-profile-webs-dcb2e7-dev.marucommunity.com200maru-feature-profile-webs-dcb2e7.fly.devfeature/profile-websitebeb08161
not-an-env-dev.marucommunity.com404không đâu cả; Worker từ chối nhãn

Mọi phản hồi 200 đều kèm X-Robots-Tag: noindex, nofollow và X-Dev-Environment: <env> do Worker thêm vào. Cái 404 là của chính Worker, trước cả khi hỏi Fly.

Cloudflare Access là thứ biến “công khai” thành “đến được với những người nên đến”. Một ứng dụng trên *-dev.marucommunity.com với chính sách “bất kỳ ai có email @marucommunity.com” và người dùng trình duyệt thấy trang đăng nhập một lần rồi không bao giờ phải nghĩ tới nữa. Designer, PM và tester dùng laptop đều được phủ. Tôi không thêm nó cho demo; các môi trường chạy một giờ với noindex và không có gì ngoài dữ liệu seed.

Nó không phủ được ứng dụng di động, vì ứng dụng native không hoàn tất được chuyển hướng đăng nhập của Access. Hai lối đi. Access hỗ trợ service token (cặp header CF-Access-Client-Id / CF-Access-Client-Secret) bỏ qua đăng nhập, và menu dev của ứng dụng Expo có thể mang một cặp cho các tên miền dev. Hoặc đơn giản hơn, thêm một bypass Access cho /api/ và để Django từ chối mọi yêu cầu không có người dùng Firebase hoặc ENV_TOKEN của môi trường, chính cái token env up đã sinh cho đúng môi trường này. Dù cách nào, phía Expo là một màn hình menu dev với hai trường, URL gốc và token, và ứng dụng tester đã cài sẵn giờ nói chuyện với nhánh đó. Không cần build mới. Kênh cập nhật OTA vẫn trỏ về thứ ứng dụng được build cùng; URL gốc của API là cấu hình lúc chạy, không phải lúc build, và vốn nên như thế từ đầu.

Trang chủ chợ được phục vụ từ một môi trường nhánh trên tên miền riêng của nó
Môi trường của nhánh tagline phục vụ ứng dụng thật, trên tên miền riêng, từ cơ sở dữ liệu nhân bản hai phút trước.

Firebase Auth là dịch vụ dùng chung duy nhất được cố ý dùng chung. Danh tính không thuộc về môi trường nào; tester muốn đăng nhập bằng cùng tài khoản trên mọi nhánh. Một dự án Firebase dev với marucommunity.com trong danh sách tên miền được ủy quyền phủ mọi tên miền -dev, và mọi môi trường xác minh ID token với nó. Bảng người dùng trong cơ sở dữ liệu mỗi môi trường là bản sao người dùng của mẫu, nên tài khoản của tester tồn tại ở mọi nơi mẫu có.

Tự động hóa trên GitHub: sự kiện vào, môi trường ra
#

Mọi thứ ở trên làm bằng tay thì ổn khi chỉ có bạn làm. Từ khi có lập trình viên thứ hai, nó phải xảy ra vì có gì đó xảy ra trong git, chứ không phải vì ai đó nhớ. Toàn bộ tự động hóa là bốn workflow, một cài đặt kho, một ruleset và một GitHub environment, và mỗi thứ ánh xạ một sự kiện git thành một lời gọi tới chính scripts/env mà lập trình viên chạy bằng tay. Không có đường riêng cho CI để lệch đi.

Sự kiện gitChạy gìLàm gì
PR được gắn nhãn envbranch-env.yml → scripts/env uptạo môi trường của nhánh đó, đăng URL lên PR
push lên nhánh của một PR đã gắn nhãnbranch-env.yml → scripts/env upcập nhật tại chỗ
gỡ nhãn, PR bị đóng hoặc mergebranch-env.yml → scripts/env downmôi trường tự dọn lát cắt của mình rồi bị hủy
xóa nhánhbranch-env.yml → scripts/env downlưới an toàn cho nhánh dựng bằng tay mà chưa từng có PR; cài đặt kho delete branch on merge làm merge cũng kích hoạt nó
push lên bất kỳ nhánh nào kháckhông gì cảmột lần push để sao lưu hay một spike không tốn gì cho đến khi có người muốn xem
pull request vào mainmigrations-check.ymlmột lá duy nhất trong đồ thị migration sau khi merge main; chuỗi đầy đủ áp được từ rỗng
push tag v*release.ymlmôi trường staging từ tag; người duyệt phê chuẩn thì cùng image được đưa lên prod và staging bị hủy
02:00 hằng đêmsweep-envs.yml → scripts/sweep-envshủy mọi thứ có nhánh đã mất hoặc nằm không một tuần

Workflow nhãn, push và delete
#

# .github/workflows/branch-env.yml
name: branch environment
on:
  pull_request:
    types: [labeled, unlabeled, synchronize, closed]
  delete:
  workflow_dispatch:
    inputs:
      branch:
        description: branch to bring up without a PR
        required: true

concurrency:
  group: env-${{ github.head_ref || github.event.ref || inputs.branch }}
  cancel-in-progress: true

env:
  FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_TOKEN }}        # org-scoped deploy token; a separate preview org keeps it away from prod
  PREVIEW_PG_PASSWORD: ${{ secrets.PREVIEW_PG_PASSWORD }}
  PREVIEW_REDIS_URL: ${{ secrets.PREVIEW_REDIS_URL }}
  PREVIEW_BUCKET: ${{ vars.PREVIEW_BUCKET }}
  PREVIEW_AWS_ACCESS_KEY_ID: ${{ secrets.PREVIEW_AWS_ACCESS_KEY_ID }}
  PREVIEW_AWS_SECRET_ACCESS_KEY: ${{ secrets.PREVIEW_AWS_SECRET_ACCESS_KEY }}

jobs:
  up:
    # the gate: a PR carrying the `env` label, or someone asking for a branch by hand
    if: >-
      github.event_name == 'workflow_dispatch' ||
      (github.event_name == 'pull_request' && github.event.action != 'closed'
        && contains(github.event.pull_request.labels.*.name, 'env'))
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.head_ref || inputs.branch }}
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - id: env
        env:
          BRANCH: ${{ github.head_ref || inputs.branch }}
        run: |
          scripts/env up
          echo "url=$(scripts/env url)" >> "$GITHUB_OUTPUT"
      - if: github.event_name == 'pull_request'
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: env
          message: |
            Branch environment: ${{ steps.env.outputs.url }}
            Commit: `${{ github.event.pull_request.head.sha }}` · `/api/version/` says what it is running.

  down:
    # label removed, labelled PR closed or merged, or the branch itself deleted
    if: >-
      (github.event_name == 'pull_request' &&
        ((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'env')) ||
         (github.event.action == 'unlabeled' && github.event.label.name == 'env'))) ||
      (github.event_name == 'delete' && github.event.ref_type == 'branch')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: BRANCH="${{ github.head_ref || github.event.ref }}" scripts/env down

Cái cổng là nhãn env, và đây là phần tôi sẽ bảo vệ mạnh nhất. Không có cổng, mỗi nhánh đều tốn một ứng dụng và một bản nhân bản cơ sở dữ liệu: nhánh ai đó push lên chỉ để sao lưu laptop, spike ngày mai sẽ bị squash, bản cập nhật thư viện mà bot mở lúc 3 giờ sáng. Nên chỉ push thì không có gì xảy ra, và chỉ mở PR cũng không có gì xảy ra. Gắn nhãn env lên PR thì môi trường được dựng lên, mỗi lần push khi nhãn còn đó thì nó được cập nhật, còn gỡ nhãn, đóng PR hay merge thì nó bị hạ xuống. Việc chọn tham gia vẫn là một sự kiện trên GitHub chứ không phải lời nhờ vả một ai, và đó là một cú bấm của đúng người biết có ai sẽ xem hay không. Bình luận ghim là giao diện người dùng: gắn nhãn cho PR, một bình luận hiện ra với URL có thể gửi cho bất kỳ ai, và nó cập nhật tại chỗ sau mỗi lần push thay vì chồng lên nhau. workflow_dispatch lo cho nhánh bạn muốn dựng trước khi có PR, còn ./scripts/env up từ laptop lo phần còn lại.

Có hai kiểu cổng khác đáng biết. Bộ lọc theo tên nhánh (branches: ['feature/**', 'fix/**']) không cần PR nhưng khiến mọi nhánh tính năng đều phải trả tiền. “Mọi PR không phải bản nháp” là kiểu tự động nhất, và là lựa chọn đúng nếu đội bạn mở PR muộn, chỉ khi đã sẵn sàng. Tôi chọn nhãn vì nó đặt quyết định chi phí vào tay người có ngữ cảnh, và vì có thể rút lại mà không phải đóng gì cả. Một tác dụng phụ tôi thích: các lần chạy pull_request từ fork không nhận được secret, nên fork không thể dựng môi trường.

concurrency quan trọng hơn vẻ ngoài. Một loạt push nhanh vào một nhánh sẽ hủy các lần triển khai trước thay vì để chúng chạy đua; hai fly deploy cùng lúc vào cùng ứng dụng kết thúc bằng việc một cái thua.

closed lo việc dỡ bỏ cho mọi thứ từng có PR, merge hay không. Sự kiện delete là lưới an toàn cho nhánh dựng bằng workflow_dispatch mà rốt cuộc không có PR, và nó chỉ kích hoạt nếu nhánh thật sự bị xóa. Đó là một cài đặt kho, Automatically delete head branches, mặc định tắt. Bật lên, bấm Merge sẽ đóng PR và xóa nhánh, và chỉ một trong hai sự kiện ấy cũng đủ để chạy env down. Nút merge chính là việc dỡ bỏ.

gh api -X PATCH repos/jaredlynskey/koreapost -f delete_branch_on_merge=true

FLY_PREVIEW_TOKEN là dòng nguy hiểm trong tệp. Nó tạo và hủy được ứng dụng, và nếu $APP có lúc nào là thứ gì ngoài môi trường nhánh, nó có thể hủy prod. Hai lớp bảo vệ, và tôi giữ cả hai: token giới hạn trong một tổ chức Fly riêng để nó theo đúng nghĩa đen không nhìn thấy tổ chức production (trong demo tôi dùng tổ chức production korea-post để khỏi phải gắn thanh toán cho tổ chức mới, và chỉ dựa vào lớp thứ hai); và down() từ chối mọi tên ứng dụng không khớp mẫu slug-hash.

Kiểm tra mà môi trường nhánh không tự làm được
#

Một nhánh chỉ thấy migration của chính nó, nên kiểm tra quan trọng chạy trên pull request đối với trạng thái đã merge main, và đó là kiểm tra trạng thái duy nhất main yêu cầu:

# .github/workflows/migrations-check.yml
name: migrations
on:
  pull_request:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:18
        env: { POSTGRES_PASSWORD: ci, POSTGRES_DB: ci }
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:ci@localhost:5432/ci
      DEBUG: 'False'
      SECRET_KEY: ci-only
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: astral-sh/setup-uv@v4
      - run: uv sync --frozen
      - name: Merge main into the branch (a conflict here fails the check too)
        run: |
          git config user.email [email protected] && git config user.name ci
          git merge --no-edit origin/main
      - name: One leaf in the migration graph
        run: |
          uv run manage.py makemigrations --check --dry-run
          uv run manage.py migrate --plan > /dev/null
      - name: The full chain applies from nothing
        run: uv run manage.py migrate --noinput

Ba mươi giây. Chính nó biến va chạm 0114 / 0114 từ bất ngờ ngày phát hành thành một dấu đỏ trên PR của lập trình viên thứ hai; mục migration bên dưới cho thấy nó nổ thật.

Quy tắc trên main
#

Không gì trong đây trụ được nếu nhánh sống một tháng hay ai đó push thẳng lên main. Các quy tắc thiên về giữ môi trường nhỏ và migration mới hơn là bảo vệ:

  • main được bảo vệ và luôn triển khai được. Chỉ merge qua PR với kiểm tra migrations xanh đối với main hiện tại (kiểm tra trạng thái nghiêm ngặt, nên PR xanh tuần trước phải chạy lại khi main tiến lên). Lịch sử tuyến tính, không force-push, không xóa.
  • Nhánh tính năng phải ngắn. Nhánh mở hai tuần có môi trường tụt sau main hai tuần và migration có thêm hai tuần khả năng va với của người khác. Tính năng kéo dài một tháng thì merge vào main từng phần sau feature flag. Thứ sống lâu là flag, không phải nhánh.
  • Một nhánh, một môi trường, một cơ sở dữ liệu. Cách đặt tên đã đảm bảo.
  • Chỉ tag phát hành mới lên prod. Image được build một lần từ tag; staging và prod chạy image đó, không build lại.
  • PR chạm vào migrations/ được một trong hai người còn lại review. Bên dưới.

Dưới dạng ruleset, áp một lần bằng gh api -X POST repos/…/rulesets --input infra/github-ruleset-main.json:

{
  "name": "main", "target": "branch", "enforcement": "active",
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    { "type": "required_linear_history" },
    { "type": "pull_request",
      "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true,
                      "required_review_thread_resolution": true } },
    { "type": "required_status_checks",
      "parameters": { "strict_required_status_checks_policy": true,
                      "required_status_checks": [ { "context": "check" } ] } }
  ]
}

Một nếp gấp thành thật. Kho này là kho riêng tư trên gói GitHub Free, và trên Free, bảo vệ nhánh và ruleset chỉ có cho kho công khai; API trả lời 403 Upgrade to GitHub Pro or make this repository public. Cài đặt delete_branch_on_merge, các environment và workflow đều chạy trên Free. Vậy nên với một đội nhỏ trên kho riêng tư Free, ruleset ở trên là một quy ước: workflow migrations vẫn chạy và vẫn đỏ, nhưng không gì làm xám nút merge. Đó là quyết định 4 đô la một tháng, và tôi sẽ quyết ngay khoảnh khắc có người thứ hai được phép merge.

Không có nhánh develop và không có nhánh release/*. Git-flow được thiết kế quanh giả định rằng tích hợp đắt đỏ và phải gom theo đợt. Môi trường nhánh làm tích hợp rẻ đi, nên việc gom đợt biến mất.

Staging là môi trường của một tag, và prod là một lần thăng cấp
#

Staging tái sử dụng toàn bộ bộ máy. Một tag v* trên main chạy scripts/env up với tên nhánh staging/v1.42.0, cách đặt tên biến nó thành staging-v1-42-0-<hash>: cùng cơ sở dữ liệu nhân bản từ mẫu, cùng máy suspend khi rảnh, cùng sơ đồ tên miền. Khác biệt là chuyện xảy ra sau đó:

# .github/workflows/release.yml (abridged)
on:
  push:
    tags: ['v*']
jobs:
  staging:
    steps:
      - run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env up
      - run: echo "image=$(fly image show -a "maru-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)" --json | jq -r '…')" >> "$GITHUB_OUTPUT"
  production:
    needs: staging
    environment: production        # required reviewer on this environment = the promotion gate
    steps:
      - run: fly deploy --config fly.api.toml --image "${{ needs.staging.outputs.image }}"
      - run: fly deploy --config fly.ws.toml  --image "${{ needs.staging.outputs.image }}"
      - run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env down

Job production chờ một GitHub environment có người duyệt bắt buộc. Ai đó xem staging, bấm Approve, và prod nhận cùng image staging đã chạy, theo tham chiếu, không build lại. Bước cuối hủy staging vì nó đã xong việc. Nếu không ai duyệt, ứng viên phát hành bị từ chối, và trình quét gỡ staging khi “nhánh” của nó (staging/v1.42.0 chưa bao giờ tồn tại trên origin) rớt kiểm tra còn sống. Không thứ gì được tồn tại mà không có lý do trình quét kiểm chứng được.

Trình quét hằng đêm
#

# .github/workflows/sweep-envs.yml
on:
  schedule:
    - cron: '0 14 * * *'     # 02:00 NZST
  workflow_dispatch:
jobs:
  sweep:
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: superfly/flyctl-actions/setup-flyctl@master
      - run: scripts/sweep-envs

fetch-depth: 0 vì trình quét cần mọi nhánh từ xa và ngày commit cuối của chúng, chứ không phải một commit Actions checkout mặc định. workflow_dispatch để có thể chạy tay sau sự cố.

Lập trình viên thật sự làm gì
#

Push một nhánh, mở PR, và gắn nhãn env khi muốn ai đó xem. Danh sách chỉ có thế. Một URL xuất hiện trên PR trong vòng ba phút, cập nhật sau mỗi lần push, và biến mất khi PR được merge hoặc đóng. Kiểm tra migration báo cho họ trước bất kỳ ai nếu họ va với đồng đội. Không ai giành staging vì không có staging để giành, và không ai dỡ gì cả vì dỡ bỏ chính là merge.

Còn người đã dựng hệ thống này, sau đó phải làm gì cho mỗi nhánh? Không gì cả. Postgres, Redis và bucket dùng chung được cấp phát một lần, các bí mật được đưa vào Actions một lần, và từ đó việc lặp lại duy nhất là đọc log của trình quét vào sáng thứ Hai. Không hàng đợi ticket, không bot để gọi, không ai để nhắc.

Dỡ bỏ không tin vào việc dỡ bỏ
#

env down là trường hợp thông thường, và phần thú vị là ai dọn. Khóa Redis và đối tượng Tigris thuộc về các dịch vụ dùng chung, và laptop chạy down không cần thông tin đăng nhập của cả hai, vì chính máy của môi trường đã có chúng và đã biết tiền tố của mình. Nên down khởi động máy nếu nó đang suspend, chạy manage.py env_teardown trên đó, rồi mới hủy ứng dụng và xóa cơ sở dữ liệu. Môi trường tự dọn dẹp sau mình:

$ BRANCH=feature/profile-website ./scripts/env down
== feature/profile-website -> feature-profile-webs-dcb2e7 (destroying)
redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
tigris: deleted 278 objects under maru-preview/feature-profile-webs-dcb2e7/
Destroyed app maru-feature-profile-webs-dcb2e7
DROP DATABASE
destroyed feature-profile-webs-dcb2e7

$ curl -s -o /dev/null -w '%{http_code}\n' https://feature-profile-webs-dcb2e7-dev.marucommunity.com/api/version/
530

Cái 530 lại là điểm về điều phối: Worker vẫn định tuyến tên miền, Fly không có gì ở đó, và không ai phải báo cho Cloudflare.

env_teardown từ chối chạy khi không có ENV_NAME, vì với tiền tố rỗng nó sẽ xóa sạch cả bucket dùng chung. Lớp bảo vệ ấy là toàn bộ lý do lệnh này tồn tại như một lệnh thay vì ba dòng trong shell script.

Môi trường vẫn rò rỉ. Một nhánh bị xóa khi GitHub Actions đang gặp sự cố. Một lập trình viên chạy env up từ laptop cho một thử nghiệm và không bao giờ push. Một fly apps destroy thất bại thoáng qua và || true nuốt mất. Ba tháng sau có bốn mươi ứng dụng và một hóa đơn khiến ai đó hỏi chuyện gì đã xảy ra.

Nên có cơ chế thứ hai không tin cơ chế thứ nhất: trình quét hằng đêm trong bảng ở trên. Nó liệt kê mọi ứng dụng maru-*, tìm ra mỗi cái thuộc nhánh nào, và hủy mọi cái có nhánh đã mất hoặc không được push trong bảy ngày. Một nhánh nằm không một tuần không cần URL sống; lần push tiếp theo dựng lại nó trong hai phút. Mã băm không đảo ngược được, nên trình quét tính tên môi trường cho mọi nhánh còn sống và hủy mọi ứng dụng không nằm trong tập đó, rồi xóa mọi cơ sở dữ liệu env_* không có ứng dụng đứng sau:

#!/usr/bin/env bash
# scripts/sweep-envs — nightly
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; }
ORG=${PREVIEW_ORG:-korea-post}; TTL_DAYS=${TTL_DAYS:-7}
cutoff=$(( $(date +%s) - TTL_DAYS*86400 ))

git fetch --prune --quiet origin
live=""
while IFS=$'\t' read -r ts branch; do
  [[ "$branch" == "main" ]] && continue
  (( ts >= cutoff )) || continue
  live+="$(BRANCH="$branch" scripts/env name)"$'\n'
done < <(git for-each-ref --format='%(committerdate:unix)%09%(refname:short)' refs/remotes/origin | sed 's#^\([0-9]*\)\torigin/#\1\t#')

apps=$(fly apps list --org "$ORG" --json | grep -o '"Name": *"maru-[^"]*"' | cut -d'"' -f4)
for app in $apps; do
  env="${app#maru-}"
  [[ "$env" =~ ^[a-z0-9-]+-[0-9a-f]{6}$ ]] || continue       # not a branch environment (e.g. maru-preview-pg)
  grep -qx "$env" <<<"$live" && continue
  echo "sweeping $app: branch gone or idle > ${TTL_DAYS}d"
  ENV_OVERRIDE="$env" scripts/env down || true
done
# ...then any env_* database with no app: DROP DATABASE.

Không nhánh demo nào từng được push (lần chạy từ laptop ở trên), nên dưới mắt trình quét tất cả đều đã mất; đó cũng chính là trạng thái mà một spike ai đó dựng từ laptop rồi không bao giờ push sẽ rơi vào sau một tuần. Nó tháo dỡ hai cái còn lại, gồm cả các khóa Redis mà môi trường tagline đã ghi:

$ ./scripts/sweep-envs
sweeping maru-feature-branch-envir-c65cb4: branch gone or idle > 7d
redis: deleted 0 keys under feature-branch-envir-c65cb4:*
tigris: deleted 322 objects under maru-preview/feature-branch-envir-c65cb4/
Destroyed app maru-feature-branch-envir-c65cb4
DROP DATABASE
sweeping maru-feature-profile-tagl-fa8832: branch gone or idle > 7d
redis: deleted 3 keys under feature-profile-tagl-fa8832:*
tigris: deleted 278 objects under maru-preview/feature-profile-tagl-fa8832/
Destroyed app maru-feature-profile-tagl-fa8832
DROP DATABASE

$ fly apps list --org korea-post | grep maru-
 maru-preview-pg │ korea-post │ deployed

(Một thứ nhỏ đã cắn: macOS đi kèm bash 3, không có mảng kết hợp. Phiên bản đầu của trình quét dùng nó và chết ở declare -A trên laptop của tôi. Phiên bản trên dùng danh sách phân cách bằng xuống dòng và chạy được ở mọi nơi.)

Chi phí là phần dễ chịu. Một máy suspend chỉ tốn rootfs, vài xu một tháng. Một cơ sở dữ liệu là 19 MB trên volume đã trả tiền. Redis là gói trả theo dùng của Upstash, hai mươi xu cho một trăm nghìn lệnh, ở mức test nhánh thì làm tròn thành không. Một tiền tố Tigris tốn đúng phần nó lưu. Node Postgres dùng chung là khoản cố định duy nhất, khoảng hai đô la một tháng. Hai mươi nhánh đang hoạt động tốn cỡ một shared-cpu-1x chạy liên tục, và trình quét giữ ở hai mươi thay vì hai trăm. Cả buổi demo, ba môi trường trong một giờ cộng các phần dùng chung, rẻ hơn ly cà phê tôi uống trong lúc đó.

Migration qua nhiều nhánh, không có cơ sở dữ liệu dùng chung để cảnh báo
#

Khi mỗi lập trình viên sở hữu trọn một tính năng, mỗi nhánh đều có một migration. Điều đó khiến mục này là mục quan trọng.

Đây là điều người ta bỏ lỡ khi rời thế giới môi trường dùng chung. Khi cả đội cùng triển khai vào một cơ sở dữ liệu dev, xung đột migration nổi lên ngay lập tức và đau đớn, nhưng chúng nổi lên. Một migration chạy, lần triển khai tiếp theo thất bại, ai đó sửa ngay chiều đó. Với mỗi nhánh một cơ sở dữ liệu, mỗi migration chạy hoàn hảo trong môi trường của mình, và hai cái gặp nhau lần đầu khi cái thứ hai merge vào main. Làm cẩu thả, việc này chuyển xung đột từ dev, nơi nó rẻ, sang phát hành, nơi nó không rẻ.

Bốn quy tắc, tất cả CI ép được nên không ai phải nhớ.

1. CI thất bại khi đồ thị migration có hai lá, đối với trạng thái đã merge main. makemigrations --check bắt được model lệch với migration, nhưng xung đột đáng kể là hai nhánh cùng thêm 0042_*. Kiểm tra trên nhánh đã merge vào main, không phải nhánh đơn lẻ:

git fetch origin main
git merge --no-commit --no-ff origin/main || { echo "merge conflict"; exit 1; }
python manage.py makemigrations --check --dry-run
python manage.py migrate --plan 2>&1 | grep -q "Conflicting migrations" && exit 1

Cái này tôi cố ý gây ra. Hai trong ba nhánh demo, feature/profile-tagline và feature/profile-website, mỗi cái thêm một trường vào UserProfile bằng một migration, và mỗi môi trường áp migration của mình khi khởi lên. Các cột chỉ ở đúng chỗ nên có:

env_feature_branch_envir_c65cb4      pending_email
env_feature_profile_tagl_fa8832      pending_email, tagline
env_feature_profile_webs_dcb2e7      pending_email, website

Cả hai migration đều là 0114_*. Nhánh nào cũng không thấy của nhánh kia, nên môi trường nào cũng không báo được có vấn đề. Merge cái này vào cái kia, kiểm tra sẽ báo:

$ git merge --no-commit --no-ff feature/profile-tagline
$ python manage.py makemigrations --check --dry-run
CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
  (0114_userprofile_tagline, 0114_userprofile_website in marketplace).
To fix them run 'python manage.py makemigrations --merge'

$ python manage.py makemigrations --merge --noinput
Created new merge migration marketplace/migrations/0115_merge_20260919_0324.py
    dependencies = [
        ('marketplace', '0114_userprofile_tagline'),
        ('marketplace', '0114_userprofile_website'),
    ]

Tác giả thứ hai chạy --merge đó trên nhánh mình, trong môi trường mình, không làm phiền ai, và lần env up tiếp theo áp 0115 lên bản nhân bản của họ. (Trong trường hợp này git còn báo xung đột văn bản trước, vì cả hai trường được thêm vào cùng một dòng của tệp model. Thực tế, và đáng biết rằng xung đột văn bản che mất xung đột migration cho đến khi được giải quyết.)

2. Migration tương thích ngược với code của bản phát hành trước. Trong lúc triển khai có một khoảng thời gian schema mới và code cũ cùng tồn tại, hoặc ngược lại nếu một máy rollback. Thêm cột nullable hoặc có giá trị mặc định, không bao giờ đổi tên trong một bước, không bao giờ xóa thứ bản phát hành hiện tại vẫn đọc: thêm, triển khai, backfill, đổi code, xóa ở bản phát hành sau. Expand and contract. Lý do nó quan trọng hơn ở đây là môi trường dùng một lần khiến triển khai thường xuyên hơn, nên bạn ở trong khoảng đó nhiều hơn. Staging theo tag là nơi tổng duyệt điều này.

3. CI cũng migration từ số không vào cơ sở dữ liệu rỗng. Nhân bản mẫu kiểm tra “migration của tôi có áp được lên dữ liệu thực tế không”. Chạy rỗng kiểm tra “cả chuỗi còn chạy từ số không không”, bắt được migration giả định một data migration trước đó đã điền gì đó. Ba mươi giây, và nó bắt được một lớp lỗi mà nếu không sẽ chờ đến chiếc laptop mới tiếp theo.

4. Thay đổi schema có cặp mắt thứ hai. Trong một đội nhỏ không cần tệp CODEOWNERS, nhưng cần một quy tắc: PR chạm vào migrations/ được một đồng đội review trước khi merge, và việc của người review không phải kiểm tra SQL. Là biết rằng họ, hoặc ai đó khác trong đội, đang chạm vào cùng bảng trong hai tuần này và nói ra. Quá mười mấy lập trình viên thì kiến thức ấy không còn nằm gọn trong đầu một người nữa, và lúc đó một dòng CODEOWNERS cho migrations/ mới đáng có. Không công cụ nào thay được. Với công cụ bắt hết phần cơ học, đó là việc hai phút, và là phần duy nhất của thế giới môi trường dùng chung cũ đáng giữ lại: khoảnh khắc hai người phát hiện họ sắp va nhau.

Phiên bản, để một URL nói nó đang chạy gì
#

Với một môi trường dùng chung, “staging đang có gì” là câu hỏi trên Slack. Với nhiều môi trường, nó phải là thuộc tính của môi trường, nếu không mọi báo cáo lỗi đều bắt đầu bằng khảo cổ.

  • Mỗi image mang SHA commit và ứng dụng lộ nó ra. fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=... đóng chúng vào image dưới dạng biến môi trường, và /api/version/ trả về cùng tên môi trường, ứng dụng Fly và tham chiếu image (xem ảnh chụp ở trên). Việc đầu tiên ai cũng làm với “nó hỏng trên feature-profile-tagl-fa8832” là kiểm tra nó đang chạy commit mà họ nghĩ. Môi trường demo đầu tiên được triển khai từ working tree một commit trước khi bộ công cụ được commit, và endpoint nói đúng thế: 87c061b9, không phải SHA tôi mong đợi.
  • ENVIRONMENT=$ENV có trong mọi dòng log và thẻ Sentry. Lỗi của nhánh không làm bẩn theo dõi lỗi của prod và lọc được bằng một cú bấm.
  • Nhánh có SHA, bản phát hành có tag. feature-payments-retr-3f9a1c không bao giờ có số phiên bản; nó có commit. Staging và prod có v1.42.0.
  • Ứng dụng di động mang phiên bản API tối thiểu, không phải URL. Trường URL trong menu dev là cách tester trỏ điện thoại thật vào một nhánh, và endpoint /version mang phiên bản schema API là cách ứng dụng biết nó đang nói chuyện với thứ nó hiểu được.

Điều này không giải quyết được gì
#

Nó không giải quyết hai tính năng chỉ có nghĩa khi đi cùng nhau. Một môi trường nhánh hiện một nhánh. Câu trả lời là merge cả hai sau feature flag rồi nhìn vào main, làm được bằng một môi trường thường trực bám theo main (main-dev.marucommunity.com, triển khai lại sau mỗi lần merge). Đó là môi trường phi production sống lâu duy nhất tôi giữ, và không ai triển khai lên nó; nó để xem bản phát hành tiếp theo sẽ là gì.

Nó không giải quyết các nhà cung cấp đòi callback đã đăng ký và không chấp nhận wildcard. Firebase Auth chấp nhận tên miền cha, nên marucommunity.com phủ mọi tên miền nhánh -dev. Một số nhà cung cấp OAuth và thanh toán thì không, và với họ bạn giữ một tên miền dùng chung mà Worker định tuyến tới môi trường nào vừa giành nó gần nhất. Đó là cái khóa nhỏ lên một thứ nhỏ, và tốt hơn nhiều so với cái khóa lên mọi thứ.

Nó không giải quyết phía con người của migration. Công cụ bắt xung đột cơ học. Nó không bắt được hai người trong các bạn mô hình hóa cùng một khái niệm theo cách không tương thích trong cùng hai tuần, và cách sửa duy nhất là buổi review migration mà một người trong hai nhận ra.

Tôi sẽ kiểm tra gì trong hệ thống của bạn
#

Nếu bạn có dev hoặc staging dùng chung và một luồng Slack để đặt chỗ:

  1. Một nhánh đã xong phải chờ bao lâu để ai đó ngoài tác giả thấy nó chạy? Quá một giờ là bạn đang trả giá cho cái khóa bằng thời gian giao hàng, và với một đội nhỏ thì đó là một phần lớn của đội đang chờ.
  2. Cơ sở dữ liệu dùng chung bị reset bao lâu một lần, và khi đó ai mất việc đã làm?
  3. Một lập trình viên mới, ngày đầu, có thể có URL công khai chạy nhánh của mình mà không phải xin ai bất cứ thứ gì không? Thứ họ phải xin chính là thứ cần tự động hóa trước tiên.
  4. Môi trường mới có bắt đầu từ dữ liệu thực tế không? Nếu bắt đầu rỗng, lỗi đầu tiên nó không bắt được là migration ổn trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật.
  5. Trong hạ tầng phi production của bạn có thứ gì vẫn tồn tại một tháng sau khi mọi người quên nó không? Nếu có, hãy viết trình quét trước mọi thứ khác. Rò rỉ bắt đầu từ ngày đầu tiên.

Câu hỏi trên Slack biến mất không phải vì người ta thôi hỏi, mà vì câu trả lời luôn là “có, đang dùng cái của tôi.”