Cloudflare Pages 与 Workers 安全退役教程
删除 Cloudflare Pages 项目或 Worker 脚本是不可逆操作,但它不一定会同时移除域名、路由、CI、Access 策略和数据资源。安全退役的目标不是让 Dashboard 中的项目消失,而是停止新发布和生产流量,保存必要的审计信息,只删除经过确认的对象,并分别验证控制面和公网入口。
本文依据 2026-09-17 可访问的 Cloudflare 官方文档编写。没有登录真实账户、执行 Wrangler、调用删除 API 或验证边缘网络传播,验证等级为 documented。所有命令都必须先替换占位符,并在目标账户中由具备审批权限的操作者复核。
先区分要删除的对象
| 层级 | Pages | Workers | 是否可能被共享 |
|---|---|---|---|
| 执行对象 | Pages project | Worker script | 通常否 |
| 发布历史 | Pages deployments | Worker versions 和 deployments | 与对应项目或脚本关联 |
| 流量入口 | 自定义域名、pages.dev、Access、DNS | Routes、Custom Domains、workers.dev、DNS | 可能 |
| 数据与消息 | Functions 使用的 KV、D1、R2 等 | KV、D1、R2、Queues、Durable Objects 等 | 经常可能 |
| 发布系统 | Git 集成、Deploy Hooks、外部 CI | Workers Builds、外部 CI、Cron 和外部生产者 | 可能 |
删除项目或脚本,不代表外围对象已经自动清理。KV namespace、R2 bucket、D1 database、Queue 或 Durable Object namespace 可能被其他应用复用,不应跟随单个 Worker 直接销毁。
退役前检查
先为本次操作建立一份只含非秘密信息的清单:
- Cloudflare Account ID、Pages 项目名、Worker 脚本名和 Zone ID。
- 生产域名、预览域名、Routes、Custom Domains 和 DNS 记录。
- 当前生产 deployment、历史 deployment、Worker version 和 deployment。
- 绑定资源的名称、类型、数据所有者、备份状态和其他引用者。
- Git 集成、Deploy Hook、CI 工作流、Cron、Webhook 和消息生产者。
- Secrets 的名称和轮换责任人;不要导出或记录 Secret 值。
然后停止所有自动发布和外部调用。若旧站点或脚本可能暴露凭证,先轮换凭证、切走域名流量,再进入删除步骤。
准备凭证
Pages deployment 列表使用 Pages Read 或 Pages Write;删除 deployment、Custom Domain 和项目需要 Pages Write。账户 Zone 枚举使用 Zone Read,DNS 盘点与删除分别使用 DNS Read、DNS Write。Access 应用盘点与删除分别使用 Access: Apps and Policies Read、Access: Apps and Policies Write。Worker route 盘点与删除分别使用 Workers Routes Read、Workers Routes Write;Worker Domains、脚本 subdomain 和脚本盘点使用 Workers Scripts Read 或 Write,删除脚本需要 Workers Scripts Write。撤销 Account Token 需要 Account API Tokens Write,撤销 User Token 需要 API Tokens Write。
Token 的 Account 与 Zone 范围应限制到目标资源。跨 Zone 盘点使用单独的账户级只读凭证,至少具备 Zone Read、DNS Read、Workers Routes Read、Workers Scripts Read 和 Access: Apps and Policies Read;删除操作换回只覆盖已确认对象的最小范围写凭证。命令示例还要求 curl 7.76+、jq 1.6+ 和当前版 Wrangler;版本或 JSON 字段不符时停止,不要边删除边改脚本。
在受控 shell 中注入一次性 Token,不把值写进脚本、Markdown、日志或命令参数:
set -euo pipefail
umask 077
export CLOUDFLARE_ACCOUNT_ID='<cloudflare-account-id>'
printf 'Cloudflare API Token: ' >&2
IFS= read -rs CLOUDFLARE_API_TOKEN
printf '\n' >&2
export CLOUDFLARE_API_TOKEN
cf_api() {
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}"
printf 'Authorization: Bearer %s\n' "$CLOUDFLARE_API_TOKEN" |
curl --fail-with-body -sS --header @- "$@"
}
cf_json() {
local response
response="$(cf_api "$@")" || return
printf '%s' "$response" |
jq -e '.success == true and ((.errors // []) | length == 0)' >/dev/null || return
printf '%s' "$response"
}
cf_delete() {
local url="$1" body_file status
body_file="$(mktemp)"
chmod 600 "$body_file"
if ! status="$(
printf 'Authorization: Bearer %s\n' "$CLOUDFLARE_API_TOKEN" |
curl -sS -o "$body_file" -w '%{http_code}' --header @- -X DELETE "$url"
)"; then
rm -f -- "$body_file"
return 1
fi
case "$status" in
2??) ;;
*) rm -f -- "$body_file"; return 1 ;;
esac
if test -s "$body_file"; then
jq -e '.success == true and ((.errors // []) | length == 0)' \
"$body_file" >/dev/null || { rm -f -- "$body_file"; return 1; }
fi
rm -f -- "$body_file"
}
cf_expect_absent() {
local status
status="$(
printf 'Authorization: Bearer %s\n' "$CLOUDFLARE_API_TOKEN" |
curl -sS -o /dev/null -w '%{http_code}' --header @- "$1"
)" || return
test "$status" = '404' || {
echo "目标仍可读取或返回了非预期状态:HTTP ${status}" >&2
return 1
}
}
urlencode() {
jq -rn --arg value "$1" '$value | @uri'
}
# 仅用于不返回 Secret 值的列表 API;输出为权限 0600 的 JSON Lines。
collect_nonsecret_list() {
local base_url="$1" output="$2" separator='?' page=1
local expected_pages='' expected_count='' response page_file
test ! -s "$output" || { echo "输出文件不是空文件:$output" >&2; return 1; }
case "$base_url" in *\?*) separator='&' ;; esac
: > "$output"
chmod 600 "$output"
while :; do
response="$(cf_json "${base_url}${separator}page=${page}")"
page_file="$(mktemp)"
printf '%s' "$response" > "$page_file"
unset response
local returned_page current_pages current_count reported_count page_items
returned_page="$(jq -r '.result_info.page' "$page_file")"
current_pages="$(jq -r '.result_info.total_pages' "$page_file")"
current_count="$(jq -r '.result_info.total_count' "$page_file")"
reported_count="$(jq -r '.result_info.count' "$page_file")"
page_items="$(jq '.result | length' "$page_file")"
for value in "$returned_page" "$current_pages" "$current_count" "$reported_count" "$page_items"; do
case "$value" in ''|null|*[!0-9]*) echo '列表 API 分页元数据无效' >&2; return 1 ;; esac
done
test "$returned_page" -eq "$page"
test "$reported_count" -eq "$page_items"
if test "$page" -eq 1; then
expected_pages="$current_pages"
expected_count="$current_count"
else
test "$current_pages" -eq "$expected_pages"
test "$current_count" -eq "$expected_count"
fi
jq -c '.result[]' "$page_file" >> "$output"
rm -f -- "$page_file"
test "$page" -ge "$expected_pages" && break
page=$((page + 1))
done
local actual_count unique_count
actual_count="$(jq -s 'length' "$output")"
unique_count="$(jq -s '[.[].id] | unique | length' "$output")"
test "$actual_count" -eq "$expected_count"
test "$unique_count" -eq "$expected_count"
}
assert_pages_deployment_absent() {
local deployment_id="$1" raw
raw="$(mktemp)"
collect_nonsecret_list "${API_BASE}/deployments?per_page=100" "$raw"
jq -s -e --arg id "$deployment_id" \
'([.[] | select(.id == $id)] | length) == 0' "$raw" >/dev/null
rm -f -- "$raw"
}
assert_pages_project_absent() {
local project_name="$1" raw
raw="$(mktemp)"
collect_nonsecret_list \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects?per_page=100" \
"$raw"
jq -s -e --arg name "$project_name" \
'([.[] | select(.name == $name)] | length) == 0' "$raw" >/dev/null
rm -f -- "$raw"
}
assert_worker_script_absent() {
local script_name="$1" response
response="$(cf_json \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts")" || return
printf '%s' "$response" | jq -e --arg id "$script_name" '
(.result | type) == "array" and
all(.result[]; ((.id // "") | length) > 0) and
([.result[].id] | unique | length) == (.result | length) and
([.result[] | select(.id == $id)] | length) == 0
' >/dev/null
}cf_api 通过标准输入向 curl 提供 Header,避免把 Token 明文展开到 curl 的进程参数;环境变量仍只应存在于受控 shell,高敏感场景应由 Secret Manager 注入。cf_json 要求非空 Cloudflare JSON envelope 的 success 为 true 且 errors 为空;两个失败点都显式 return,不依赖 Bash 在命令替换中继承 errexit。
删除端点可能返回空的 2xx,因此统一使用 cf_delete:HTTP 非 2xx 必定失败;空 2xx 视为删除请求成功;非空响应才校验 Cloudflare envelope。请求成功只说明服务端接受了删除,后面的完整列表复核仍是必做步骤。
打开一个新的 bash --noprofile --norc 会话,从“准备凭证”开始按顺序执行本文代码,并保持在同一会话中;不要把后续代码块当作互不相关的 shell。入口的 set -euo pipefail 会让 HTTP、JSON 或完整性校验失败立即结束该会话。collect_nonsecret_list 只用于官方声明 page、per_page 的非秘密列表 API,会遍历 result_info.total_pages、逐页核对计数并要求 ID 唯一数等于 total_count。Pages Domains、Worker Domains 与 Worker Scripts 不使用这个通用分页器,而使用各自端点的专用校验。
三个 assert_*_absent 函数把官方列表作为删除后控制面验收依据:deployment 和 Pages project 会遍历全部分页,Worker Scripts 列表则按官方无分页参数的完整响应核对 ID。cf_expect_absent 只保留给已经在目标环境验证过单对象 GET 行为的操作者作为附加观测;单次 404 不能替代列表验收,也不应在缺少官方状态码契约时写成必然结果。
退役 Pages 项目
1. 暂停发布并核对项目
暂停 Git 集成、Deploy Hooks 和外部 CI,再列出项目与 deployment:
PROJECT_NAME='<pages-project-name>'
npx wrangler pages project list
npx wrangler pages deployment list \
--project-name "$PROJECT_NAME" \
--json在执行任何删除前,核对项目名称、生产域名、当前生产 deployment 和 deployment 总数。清单与预期不一致时立即停止。
2. 切换自定义域名与 Access
Pages Custom Domain、DNS 记录和 Access 应用是三类对象。先列出项目关联的 Custom Domains:
PAGES_DOMAINS_BASE="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}/domains"
PAGES_DOMAINS_AUDIT="pages-${PROJECT_NAME}-domains.json"
pages_domains_response="$(cf_json "$PAGES_DOMAINS_BASE")"
printf '%s' "$pages_domains_response" | jq -e '
.result_info.total_count == (.result | length) and
([.result[].id] | unique | length) == .result_info.total_count
' >/dev/null
printf '%s' "$pages_domains_response" |
jq '[.result[] | {id, name, status, verification_data, validation_data}]' \
> "$PAGES_DOMAINS_AUDIT"
unset pages_domains_response后续 PAGES_DOMAIN、DNS、Access 和解绑步骤必须对清单中的每个 Custom Domain 分别执行;示例中的单个变量不代表只处理第一条。
为每个域名确认替代站点已经可用。使用覆盖目标 Zone 的 DNS Read 查询精确 hostname,记录当前 DNS ID、类型和目标;若 Token 看不到目标 Zone,停止退役并使用有完整只读范围的盘点凭证:
PAGES_DOMAIN='<confirmed-pages-custom-domain>'
PAGES_ZONE_ID='<zone-id-containing-the-domain>'
ENCODED_DOMAIN="$(urlencode "$PAGES_DOMAIN")"
DNS_RECORDS_RAW="$(mktemp)"
DNS_RECORDS_URL="https://api.cloudflare.com/client/v4/zones/${PAGES_ZONE_ID}/dns_records?name=${ENCODED_DOMAIN}&per_page=100"
collect_nonsecret_list "$DNS_RECORDS_URL" "$DNS_RECORDS_RAW"
jq -s -e --arg host "$PAGES_DOMAIN" 'all(.[]; .name == $host)' \
"$DNS_RECORDS_RAW" >/dev/null
jq -s '[.[] | {id, name, type, content, proxied, ttl}]' "$DNS_RECORDS_RAW"
rm -f -- "$DNS_RECORDS_RAW"在 DNS 中把生产记录切换到替代入口并完成业务验收。若退役方案是删除记录,则由具备 DNS Write 的凭证使用上一步确认的 ID 删除,随后按相同 hostname 再次查询;不要根据域名猜测记录 ID:
DNS_RECORD_ID='<confirmed-dns-record-id>'
cf_delete \
"https://api.cloudflare.com/client/v4/zones/${PAGES_ZONE_ID}/dns_records/${DNS_RECORD_ID}"
DNS_RECHECK_RAW="$(mktemp)"
collect_nonsecret_list "$DNS_RECORDS_URL" "$DNS_RECHECK_RAW"
jq -s -e --arg id "$DNS_RECORD_ID" --arg host "$PAGES_DOMAIN" '
all(.[]; .name == $host) and ([.[] | select(.id == $id)] | length) == 0
' "$DNS_RECHECK_RAW" >/dev/null
rm -f -- "$DNS_RECHECK_RAW"无论项目是否配置 Custom Domain,都要扫描 Account 级 Access,才能覆盖 <project>.pages.dev 和 Preview hostname。对于 Custom Domains,先完整枚举账户 Zone,以最长域名后缀确定可归属的 Zone;外部 DNS 域名可能没有账户内 Zone,此时 zone_id 为 null,仍保留 Account 级扫描和外部 DNS 验收:
ACCOUNT_ACCESS_BASE="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/access/apps"
ENCODED_ACCOUNT_ID="$(urlencode "$CLOUDFLARE_ACCOUNT_ID")"
PAGES_ZONES_RAW="$(mktemp)"
collect_nonsecret_list \
"https://api.cloudflare.com/client/v4/zones?account.id=${ENCODED_ACCOUNT_ID}&per_page=50" \
"$PAGES_ZONES_RAW"
PAGES_DOMAIN_ZONES="pages-${PROJECT_NAME}-domain-zones.json"
jq -n \
--slurpfile domains "$PAGES_DOMAINS_AUDIT" \
--slurpfile zones "$PAGES_ZONES_RAW" '
[
$domains[0][] |
.name as $host |
([$zones[] |
. as $zone |
select($host == $zone.name or ($host | endswith("." + $zone.name)))] |
sort_by(.name | length) |
reverse |
.[0]) as $zone |
{
hostname: $host,
zone_id: ($zone.id // null),
zone_name: ($zone.name // null)
}
]
' > "$PAGES_DOMAIN_ZONES"
rm -f -- "$PAGES_ZONES_RAW"
ACCESS_APPS_ALL="$(mktemp)"
: > "$ACCESS_APPS_ALL"
ACCESS_SCOPE_RAW="$(mktemp)"
collect_nonsecret_list "${ACCOUNT_ACCESS_BASE}?per_page=100" "$ACCESS_SCOPE_RAW"
jq -c --arg delete_base "$ACCOUNT_ACCESS_BASE" \
'. + {_delete_base: $delete_base}' "$ACCESS_SCOPE_RAW" >> "$ACCESS_APPS_ALL"
rm -f -- "$ACCESS_SCOPE_RAW"
while IFS= read -r zone_id; do
test -n "$zone_id"
ZONE_ACCESS_BASE="https://api.cloudflare.com/client/v4/zones/${zone_id}/access/apps"
ACCESS_SCOPE_RAW="$(mktemp)"
collect_nonsecret_list "${ZONE_ACCESS_BASE}?per_page=100" "$ACCESS_SCOPE_RAW"
jq -c --arg delete_base "$ZONE_ACCESS_BASE" \
'. + {_delete_base: $delete_base}' "$ACCESS_SCOPE_RAW" >> "$ACCESS_APPS_ALL"
rm -f -- "$ACCESS_SCOPE_RAW"
done < <(jq -r '[.[].zone_id | select(. != null)] | unique[]' "$PAGES_DOMAIN_ZONES")
PAGES_DEV_DOMAIN="${PROJECT_NAME}.pages.dev"
PAGES_ACCESS_AUDIT="pages-${PROJECT_NAME}-access-apps.json"
jq -s \
--slurpfile custom_domains "$PAGES_DOMAIN_ZONES" \
--arg pages_dev "$PAGES_DEV_DOMAIN" '
def access_hostname:
tostring |
ascii_downcase |
sub("^[a-z][a-z0-9+.-]*://"; "") |
(split("/")[0] // "") |
(split(":")[0] // "") |
rtrimstr(".");
def label_matches($pattern; $host_label):
($pattern | split("*")) as $parts |
if ($parts | length) == 1 then
$pattern == $host_label
else
($host_label | startswith($parts[0])) and
($host_label | endswith($parts[1])) and
(($host_label | length) >= (($parts[0] | length) + ($parts[1] | length)))
end;
def access_pattern_matches_host($pattern; $host):
($pattern | access_hostname | split(".")) as $pattern_labels |
($host | access_hostname | split(".")) as $host_labels |
($pattern_labels | length) == ($host_labels | length) and
([range(0; ($pattern_labels | length)) |
label_matches($pattern_labels[.]; $host_labels[.])] | all);
[
.[] |
([.domain // ""] + [.destinations[]? | (.uri // .hostname // .name // "")]) as $targets |
select(any($targets[];
(access_hostname) as $pattern |
any($custom_domains[0][].hostname;
. as $custom |
access_pattern_matches_host($pattern; $custom)) or
access_pattern_matches_host($pattern; $pages_dev) or
(($pattern | endswith("." + ($pages_dev | ascii_downcase | rtrimstr(".")))) and
(($pattern | length) > ($pages_dev | length)))
)) |
{id, name, domain, destinations, type, _delete_base}
] | unique_by([.id, ._delete_base])
' "$ACCESS_APPS_ALL" > "$PAGES_ACCESS_AUDIT"
rm -f -- "$ACCESS_APPS_ALL"候选清单保留 Access hostname 中的通配符并按 label 匹配:*.example.com 只覆盖一个子域层级,不覆盖 apex 或多层子域;api*.example.com 只在同一 label 内做部分匹配。Pages Preview hostname 另外以 .<project>.pages.dev 边界纳入候选,避免相似项目名误报。
为每个候选建立一条决策记录,保存为 pages-<project>-access-decisions.json 数组。每条至少包含 id、delete_base、decision、verified: true 和非空 evidence;decision 只能是 migrated、retained 或 deleted。下面的门禁要求每个 id + scope 恰好对应一条已验证决策:
PAGES_ACCESS_DECISIONS="pages-${PROJECT_NAME}-access-decisions.json"
assert_pages_access_decisions_complete() {
jq -e --slurpfile candidates "$PAGES_ACCESS_AUDIT" '
. as $decisions |
($decisions | type) == "array" and
($decisions | length) == ($candidates[0] | length) and
all($decisions[];
.verified == true and
((.evidence // "") | length) > 0 and
(.decision == "migrated" or .decision == "retained" or .decision == "deleted")) and
all($candidates[0][];
. as $candidate |
([$decisions[] |
select(.id == $candidate.id and
.delete_base == $candidate._delete_base)] | length) == 1)
' "$PAGES_ACCESS_DECISIONS" >/dev/null
}
assert_pages_access_decisions_complete逐项核对 public destinations 后,按应用 ID 决定迁移、保留或删除。需要删除时使用 Access: Apps and Policies Write,并从候选记录复制正确的 Account 或 Zone _delete_base;删除 Pages 项目不会替你清理独立的 Access 应用:
ACCESS_APP_ID='<confirmed-access-application-id>'
ACCESS_DELETE_BASE='<confirmed-_delete_base-from-candidate>'
cf_delete "${ACCESS_DELETE_BASE}/${ACCESS_APP_ID}"
ACCESS_RECHECK_RAW="$(mktemp)"
collect_nonsecret_list "${ACCESS_DELETE_BASE}?per_page=100" "$ACCESS_RECHECK_RAW"
jq -s -e --arg id "$ACCESS_APP_ID" \
'([.[] | select(.id == $id)] | length) == 0' "$ACCESS_RECHECK_RAW" >/dev/null
rm -f -- "$ACCESS_RECHECK_RAW"
assert_pages_access_decisions_complete流量切换验收后,才从 Pages 项目解绑旧 Custom Domain。普通域名可直接作为路径参数;包含特殊字符的值必须先做 URL 编码:
ENCODED_PAGES_DOMAIN="$(urlencode "$PAGES_DOMAIN")"
cf_delete "${PAGES_DOMAINS_BASE}/${ENCODED_PAGES_DOMAIN}"
pages_domains_response="$(cf_json "$PAGES_DOMAINS_BASE")"
printf '%s' "$pages_domains_response" | jq -e --arg domain "$PAGES_DOMAIN" '
.result_info.total_count == (.result | length) and
([.result[].id] | unique | length) == .result_info.total_count and
([.result[] | select(.name == $domain)] | length) == 0
' >/dev/null
unset pages_domains_response重新检查 DNS,确认没有记录继续指向旧 Pages 项目。<project>.pages.dev 属于项目入口,不能当成普通 DNS 记录迁走;删除项目后它将不再提供旧站点,因此必须提前决定替代地址或明确接受下线。
3. 保存完整审计清单
set -euo pipefail
API_BASE="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}"
AUDIT_FILE="pages-${PROJECT_NAME}-deployments.json"
umask 077
PARTS_DIR="$(mktemp -d)"
trap 'rm -rf "$PARTS_DIR"' EXIT
page=1
expected_pages=''
expected_count=''
while :; do
page_file="${PARTS_DIR}/page-${page}.json"
response="$(cf_json "${API_BASE}/deployments?per_page=100&page=${page}")"
# 原始 deployment 对象可能含 env_vars.value 等敏感字段;只落盘审计白名单。
printf '%s' "$response" | jq '{
success,
result_info: {
page: .result_info.page,
per_page: .result_info.per_page,
count: .result_info.count,
total_count: .result_info.total_count,
total_pages: .result_info.total_pages
},
result: [.result[] | {
id,
environment,
created_on,
modified_on,
url,
aliases: (.aliases // [])
}]
}' > "$page_file"
unset response
test "$(jq -r '.success' "$page_file")" = 'true'
returned_page="$(jq -r '.result_info.page' "$page_file")"
current_pages="$(jq -r '.result_info.total_pages' "$page_file")"
current_count="$(jq -r '.result_info.total_count' "$page_file")"
reported_count="$(jq -r '.result_info.count' "$page_file")"
page_items="$(jq '.result | length' "$page_file")"
for value in "$returned_page" "$current_pages" "$current_count" "$reported_count" "$page_items"; do
case "$value" in
''|null|*[!0-9]*) echo '分页响应包含无效计数' >&2; exit 1 ;;
esac
done
test "$returned_page" -eq "$page"
test "$reported_count" -eq "$page_items"
if test "$page" -eq 1; then
expected_pages="$current_pages"
expected_count="$current_count"
else
test "$current_pages" -eq "$expected_pages"
test "$current_count" -eq "$expected_count"
fi
case "$expected_pages" in
''|null|*[!0-9]*) echo '响应缺少有效的 total_pages' >&2; exit 1 ;;
esac
if test "$expected_count" -eq 0; then
test "$expected_pages" -eq 0
break
fi
test "$expected_pages" -ge 1
test "$page" -ge "$expected_pages" && break
page=$((page + 1))
done
actual_count="$(jq -s '[.[].result[]] | length' "${PARTS_DIR}"/page-*.json)"
unique_count="$(jq -s '[.[].result[].id] | unique | length' "${PARTS_DIR}"/page-*.json)"
test "$actual_count" -eq "$expected_count" || {
echo "部署清单不完整:期望 ${expected_count} 条,实际 ${actual_count} 条" >&2
exit 1
}
test "$unique_count" -eq "$expected_count" || {
echo "部署 ID 不唯一或分页期间集合发生变化:期望 ${expected_count} 个,实际 ${unique_count} 个" >&2
exit 1
}
jq -s --argjson total_count "$expected_count" \
'{result: [.[].result[]], result_info: {count: ([.[].result[]] | length), total_count: $total_count}}' \
"${PARTS_DIR}"/page-*.json > "$AUDIT_FILE"
# 连续执行第二次完整采集,只保存 ID,并与第一次排序后的 ID 集合比较。
FIRST_IDS="${PARTS_DIR}/first.ids"
SECOND_IDS="${PARTS_DIR}/second.ids"
jq -r '.result[].id' "$AUDIT_FILE" | sort > "$FIRST_IDS"
: > "$SECOND_IDS"
page=1
while :; do
response="$(cf_json "${API_BASE}/deployments?per_page=100&page=${page}")"
printf '%s' "$response" | jq -e \
--argjson page "$page" \
--argjson pages "$expected_pages" \
--argjson count "$expected_count" '
.success == true and
.result_info.page == $page and
.result_info.total_pages == $pages and
.result_info.total_count == $count and
.result_info.count == (.result | length)
' >/dev/null
printf '%s' "$response" | jq -r '.result[].id' >> "$SECOND_IDS"
unset response
test "$expected_pages" -eq 0 && break
test "$page" -ge "$expected_pages" && break
page=$((page + 1))
done
sort -u "$SECOND_IDS" -o "$SECOND_IDS"
test "$(wc -l < "$SECOND_IDS")" -eq "$expected_count"
cmp -s "$FIRST_IDS" "$SECOND_IDS" || {
echo '两次完整采集的 deployment ID 集合不同,停止删除并重新采集' >&2
exit 1
}这一步使用 REST API 遍历 result_info.total_pages,逐页核对页码、总页数、总条数与本页条数,并要求 deployment ID 唯一数等于 result_info.total_count。随后立即完成第二次全分页采集并比较排序后的 ID 集合;页数、总数或 ID 集合变化都会中止。两次连续快照仍不能证明远端永远不变,因此开始前必须先停掉所有发布源,删除期间继续小批次复查。脚本不会把完整 deployment 对象写入磁盘,只保留 ID、环境、时间、URL 和 alias;umask 077 让清单仅对当前用户可读写。完成审批留存后,把清单移入受控审计存储,并按组织保留周期删除本地副本。Wrangler 的单次 JSON 输出可用于人工核对,但不作为高 deployment 项目的完整快照。
最终 JSON 只是部署清单,不是可恢复的站点备份。需要保留的源码和构建产物应存在 Git 或受控制品仓库中,动态数据应使用相应产品的备份流程。
4. 删除历史 deployment
Cloudflare 当前已知问题说明:Pages 项目超过 100 个 deployments 时可能无法直接删除,需要先逐个删除 deployment。先从清单中选择一个已经确认的非生产 deployment:
DEPLOYMENT_ID='<confirmed-non-production-deployment-id>'
API_BASE="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}"
npx wrangler pages deployment delete "$DEPLOYMENT_ID" \
--project-name "$PROJECT_NAME"
assert_pages_deployment_absent "$DEPLOYMENT_ID"--force 会跳过确认,并允许删除带 alias 的 deployment。它不会把未知 ID 变得安全,也不会绕过当前生产 deployment 的保护。只有确认目标和 alias 影响后才使用:
npx wrangler pages deployment delete "$DEPLOYMENT_ID" \
--project-name "$PROJECT_NAME" \
--force
assert_pages_deployment_absent "$DEPLOYMENT_ID"大量清理时采用小批次:每批删除后重新获取 page=1 或重新运行 Wrangler 列表,确认当前生产 deployment 仍然存在。这里的第一页查询只用于观察删除进度,不能替代上一步已完成的全分页审计快照。不要并发提交大量删除请求;遇到 HTTP 429 时停止加压,按服务端重试信息降低速率。
也可以通过 Pages API 列表和删除单个 deployment:
cf_json "${API_BASE}/deployments?per_page=100&page=1" |
jq '.result[] | {id, environment, created_on, url, aliases}'
cf_delete "${API_BASE}/deployments/${DEPLOYMENT_ID}"
assert_pages_deployment_absent "$DEPLOYMENT_ID"5. 删除 Pages 项目
再次列出 deployments,确认历史 deployment 已按计划清理,并重新核对项目名:
assert_pages_access_decisions_complete
npx wrangler pages project delete "$PROJECT_NAME" --yes
assert_pages_project_absent "$PROJECT_NAME"REST API 的等价操作是:
assert_pages_access_decisions_complete
cf_delete \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/${PROJECT_NAME}"
assert_pages_project_absent "$PROJECT_NAME"项目删除没有通用恢复接口。删除前应已经有替代站点或明确的下线窗口,不能把 deployment 清单当作恢复方案。
退役 Worker 脚本
1. 先移走流量和生产者
停止 Workers Builds、外部 CI、Cron、Webhook 和 Queue 生产者。将生产域名与 route 切换到替代服务或维护响应,并确认 DNS、Access 和上游缓存的预期行为。
Workers 的 version 是不可变代码快照,deployment 决定正在服务流量的 version。仅看到新 version 存在,不代表流量已经切换;退役前在 Dashboard 的 Worker Deployments 页面核对当前 deployment、流量比例和对应 version。
2. 盘点三类流量入口和绑定
Worker 可能同时暴露 Zone Routes、Custom Domains 和 workers.dev。使用覆盖整个账户的 Zone Read 凭证,通过账户过滤完整枚举可见 Zone;若安全团队确认的账户 Zone 数与 API 的 total_count 不一致,说明凭证可见性不完整,必须停止退役:
SCRIPT_NAME='<worker-script-name>'
ACCOUNT_SUBDOMAIN='<account-workers-dev-subdomain>'
test -n "$ACCOUNT_SUBDOMAIN" && test "$ACCOUNT_SUBDOMAIN" != '<account-workers-dev-subdomain>'
ENCODED_ACCOUNT_ID="$(urlencode "$CLOUDFLARE_ACCOUNT_ID")"
ZONES_RAW="$(mktemp)"
ZONES_AUDIT="worker-${SCRIPT_NAME}-account-zones.json"
collect_nonsecret_list \
"https://api.cloudflare.com/client/v4/zones?account.id=${ENCODED_ACCOUNT_ID}&per_page=50" \
"$ZONES_RAW"
jq -s '[.[] | {id, name, status, account: {id: .account.id, name: .account.name}}]' \
"$ZONES_RAW" > "$ZONES_AUDIT"
rm -f -- "$ZONES_RAW"
RELATED_ZONE_IDS=()
while IFS= read -r zone_id; do
RELATED_ZONE_IDS+=("$zone_id")
done < <(jq -r '.[].id' "$ZONES_AUDIT")
ROUTES_AUDIT="worker-${SCRIPT_NAME}-routes.jsonl"
: > "$ROUTES_AUDIT"
chmod 600 "$ROUTES_AUDIT"
for ZONE_ID in "${RELATED_ZONE_IDS[@]}"; do
routes_response="$(cf_json "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes")"
printf '%s' "$routes_response" | jq -e '.success == true' >/dev/null
printf '%s' "$routes_response" |
jq -c --arg zone "$ZONE_ID" --arg script "$SCRIPT_NAME" \
'.result[] | select(.script == $script) | {zone_id: $zone, id, pattern, script}' \
>> "$ROUTES_AUDIT"
unset routes_response
doneACCOUNT_SUBDOMAIN 是 Dashboard 中 Workers 的账户级 workers.dev 子域,不是 Account ID。它同时用于生产地址 <worker>.<account-subdomain>.workers.dev 和 Preview 地址 <prefix>-<worker>.<account-subdomain>.workers.dev 的 Access 盘点;无法确认该值时停止,不要省略这两类入口。
ZONES_AUDIT 是账户级候选 Zone 的权威输入,ROUTES_AUDIT 记录所有可见 Zone 中指向旧脚本的 route。任一 Zone 查询返回 403、失败或缺少预期权限都会因 set -euo pipefail 中止;不要删掉失败的 Zone 后继续。
然后使用账户级 Worker Domains API 的 service 服务端过滤完整列出指向该脚本的 Custom Domains,并单独检查脚本的 workers.dev 状态:
WORKER_DOMAINS_URL="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/domains"
ENCODED_SCRIPT_NAME="$(urlencode "$SCRIPT_NAME")"
WORKER_DOMAINS_AUDIT="worker-${SCRIPT_NAME}-domains.json"
worker_domains_response="$(cf_json "${WORKER_DOMAINS_URL}?service=${ENCODED_SCRIPT_NAME}")"
printf '%s' "$worker_domains_response" | jq -e --arg script "$SCRIPT_NAME" '
.result_info.count == (.result | length) and
([.result[].id] | unique | length) == .result_info.count and
.result_info.total_count >= .result_info.count and
([.result[] | select(.service != $script)] | length) == 0
' >/dev/null
printf '%s' "$worker_domains_response" |
jq '[.result[] | {id, hostname, service, environment, zone_id}]' \
> "$WORKER_DOMAINS_AUDIT"
unset worker_domains_response
cf_json \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${SCRIPT_NAME}/subdomain" |
jq '.result | {enabled, previews_enabled}'将 Custom Domain 和每条 route pattern 的 hostname 合并为入口清单。随后为涉及的每个 Zone 建立一次完整 DNS 索引;精确 hostname 也必须从全量索引中同时找同名记录和可能覆盖它的 wildcard owner,不能只调用 ?name=<exact-host>:
WORKER_HOSTS_AUDIT="worker-${SCRIPT_NAME}-hosts.json"
jq -n \
--slurpfile routes "$ROUTES_AUDIT" \
--slurpfile domains "$WORKER_DOMAINS_AUDIT" \
--slurpfile zones "$ZONES_AUDIT" '
[
($routes[] |
.zone_id as $zone |
{
hostname: (.pattern | sub("^https?://"; "") | split("/")[0] | ascii_downcase),
zone_id: $zone,
zone_name: ([$zones[0][] | select(.id == $zone) | .name][0]),
source: "route"
}),
($domains[0][] |
.zone_id as $zone |
{
hostname,
zone_id: $zone,
zone_name: ([$zones[0][] | select(.id == $zone) | .name][0]),
source: "worker-domain"
})
] | unique_by([.hostname, .zone_id])
' > "$WORKER_HOSTS_AUDIT"
jq -e 'all(.[]; (.zone_name // "") | length > 0)' "$WORKER_HOSTS_AUDIT" >/dev/null
DNS_ZONE_CACHE_DIR="$(mktemp -d)"
while IFS= read -r zone_id; do
test -n "$zone_id"
dns_raw="${DNS_ZONE_CACHE_DIR}/${zone_id}.jsonl"
collect_nonsecret_list \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?per_page=100" \
"$dns_raw"
done < <(jq -r '.[].zone_id' "$WORKER_HOSTS_AUDIT" | sort -u)
WORKER_DNS_AUDIT="worker-${SCRIPT_NAME}-dns.jsonl"
: > "$WORKER_DNS_AUDIT"
while IFS=$'\t' read -r host zone_id zone_name; do
test -n "$host" && test -n "$zone_id" && test -n "$zone_name"
dns_raw="${DNS_ZONE_CACHE_DIR}/${zone_id}.jsonl"
if [[ "$host" == \*.* ]]; then
suffix="${host#\*}"
jq -s -c --arg host "$host" --arg zone "$zone_id" --arg suffix "$suffix" '{
hostname: $host,
zone_id: $zone,
exact_records: [],
ancestor_records: [],
wildcard_candidates: [.[] |
select(.name | startswith("*.")) |
{id, name, type, content, proxied, ttl}],
records: [.[] |
select((.name | startswith("*.")) or (.name | endswith($suffix))) |
{id, name, type, content, proxied, ttl}] | unique_by(.id),
requires_resolution_review: true
}' "$dns_raw" >> "$WORKER_DNS_AUDIT"
elif [[ "$host" == \** ]]; then
suffix="${host#\*}"
jq -s -c --arg host "$host" --arg zone "$zone_id" --arg suffix "$suffix" '{
hostname: $host,
zone_id: $zone,
exact_records: [],
ancestor_records: [],
wildcard_candidates: [.[] |
select(.name | startswith("*.")) |
{id, name, type, content, proxied, ttl}],
records: [.[] |
select((.name | startswith("*.")) or (.name | endswith($suffix))) |
{id, name, type, content, proxied, ttl}] | unique_by(.id),
requires_resolution_review: true
}' "$dns_raw" >> "$WORKER_DNS_AUDIT"
else
jq -s -c \
--arg host "$host" \
--arg zone "$zone_id" \
--arg zone_name "$zone_name" '
[.[] | select(.name == $host) |
{id, name, type, content, proxied, ttl}] as $exact |
[.[] |
. as $record |
select(
($record.name | startswith("*.") | not) and
$record.name != $host and
$record.name != $zone_name and
($host | endswith("." + $record.name))
) |
{id, name, type, content, proxied, ttl}] as $ancestors |
[.[] |
. as $record |
select(
($record.name | startswith("*.")) and
($host | endswith($record.name | ltrimstr("*"))) and
(($host | length) > (($record.name | ltrimstr("*")) | length))
) |
{id, name, type, content, proxied, ttl}] as $wildcards |
{
hostname: $host,
zone_id: $zone,
exact_records: $exact,
ancestor_records: $ancestors,
wildcard_candidates: $wildcards,
records: (($exact + $ancestors + $wildcards) | unique_by(.id)),
requires_resolution_review:
(($ancestors | length) > 0 or ($wildcards | length) > 0)
}' "$dns_raw" >> "$WORKER_DNS_AUDIT"
fi
done < <(jq -r '.[] | [.hostname, .zone_id, .zone_name] | @tsv' "$WORKER_HOSTS_AUDIT")
rm -rf -- "$DNS_ZONE_CACHE_DIR"Route pattern 可以带 http://、https:// 或不带 scheme;代码先移除开头的 scheme,再提取 hostname。*.example.com 只匹配子域,*example.com 还匹配根域以及官方规则允许的其他同后缀 hostname。两类 route wildcard 都会保守纳入 Zone 中的 wildcard DNS owner;精确 route 也会纳入能覆盖该 hostname 的 wildcard owner。
Cloudflare wildcard DNS 默认可覆盖多层 hostname,精确 DNS 记录或 delegation 会取得优先权;Advanced nameservers 对 empty non-terminal 的行为还不同。精确 hostname 的审计因此分开保存 exact_records、Zone apex 以下的 ancestor_records 和 wildcard_candidates。requires_resolution_review: true 表示脚本不能静态确定有效记录,操作者必须结合 nameserver 类型、最接近祖先、精确记录优先级和代理状态完成 DNS 查询验证,不能把候选并集当成实际解析结果。
若某个 hostname pattern 得到零条 DNS 记录,route 配置对象仍可能存在,但没有适用的 Cloudflare 代理 DNS 记录时不能承载请求。此时先复核 Zone、通配规则与 DNS 分页,再以 no-record 保存确认结果。若记录存在但全部 proxied: false,单独记为 unproxied 并保存代理状态复查证据;不要把它混写成零记录,也不要把零记录解释成仍有未托管 hostname 流量。
Access 也要同时扫描 Account 和所有相关 Zone。除了 route 与 Custom Domain,还要显式加入 <worker>.<account-subdomain>.workers.dev 和 <prefix>-<worker>.<account-subdomain>.workers.dev。候选规则使用 hostname 等值或以 .、- 为边界的后缀匹配,不用会误命中相似脚本名的宽泛子串匹配;同时覆盖 worker、preview_worker、all_workers、all_preview_workers,后两类即使没有脚本名也必须人工确认:
WORKER_ACCESS_ALL="$(mktemp)"
: > "$WORKER_ACCESS_ALL"
ACCOUNT_ACCESS_BASE="https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/access/apps"
ACCESS_SCOPE_RAW="$(mktemp)"
collect_nonsecret_list "${ACCOUNT_ACCESS_BASE}?per_page=100" "$ACCESS_SCOPE_RAW"
jq -c --arg delete_base "$ACCOUNT_ACCESS_BASE" \
'. + {_delete_base: $delete_base}' "$ACCESS_SCOPE_RAW" >> "$WORKER_ACCESS_ALL"
rm -f -- "$ACCESS_SCOPE_RAW"
for ZONE_ID in "${RELATED_ZONE_IDS[@]}"; do
ZONE_ACCESS_BASE="https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/access/apps"
ACCESS_SCOPE_RAW="$(mktemp)"
collect_nonsecret_list "${ZONE_ACCESS_BASE}?per_page=100" "$ACCESS_SCOPE_RAW"
jq -c --arg delete_base "$ZONE_ACCESS_BASE" \
'. + {_delete_base: $delete_base}' "$ACCESS_SCOPE_RAW" >> "$WORKER_ACCESS_ALL"
rm -f -- "$ACCESS_SCOPE_RAW"
done
WORKER_ACCESS_AUDIT="worker-${SCRIPT_NAME}-access-apps.json"
jq -s \
--slurpfile targets "$WORKER_HOSTS_AUDIT" \
--slurpfile dns_audit "$WORKER_DNS_AUDIT" \
--arg script "$SCRIPT_NAME" \
--arg account_subdomain "$ACCOUNT_SUBDOMAIN" \
--arg account_access_base "$ACCOUNT_ACCESS_BASE" '
def access_hostname:
tostring |
ascii_downcase |
sub("^[a-z][a-z0-9+.-]*://"; "") |
(split("/")[0] // "") |
(split(":")[0] // "") |
rtrimstr(".");
def label_matches($pattern; $host_label):
($pattern | split("*")) as $parts |
if ($parts | length) == 1 then
$pattern == $host_label
else
($host_label | startswith($parts[0])) and
($host_label | endswith($parts[1])) and
(($host_label | length) >= (($parts[0] | length) + ($parts[1] | length)))
end;
def access_pattern_matches_host($pattern; $host):
($pattern | access_hostname | split(".")) as $pattern_labels |
($host | access_hostname | split(".")) as $host_labels |
($pattern_labels | length) == ($host_labels | length) and
([range(0; ($pattern_labels | length)) |
label_matches($pattern_labels[.]; $host_labels[.])] | all);
([$targets[0][] | .hostname | select(startswith("*") | not)] +
[$dns_audit[] | .records[].name | select(startswith("*.") | not)] +
[(($script | ascii_downcase) + "." + ($account_subdomain | ascii_downcase) + ".workers.dev")] |
map(ascii_downcase | rtrimstr(".")) |
unique) as $exact_hosts |
([$targets[0][] | select(.hostname | startswith("*")) | .zone_id] +
[$dns_audit[] |
select(any(.records[]?; .name | startswith("*."))) |
.zone_id] |
unique) as $wildcard_zone_ids |
("-" + ($script | ascii_downcase) + "." +
($account_subdomain | ascii_downcase) + ".workers.dev") as $preview_suffix |
[
.[] |
. as $app |
([.domain // ""] + [.destinations[]? | (.uri // .hostname // .name // .service // "")]) as $strings |
select(
any($strings[];
(access_hostname) as $pattern |
any($exact_hosts[]; . as $host | access_pattern_matches_host($pattern; $host)) or
(($pattern | endswith($preview_suffix)) and
(($pattern | length) > ($preview_suffix | length)))
) or
(
($wildcard_zone_ids | length) > 0 and
any($strings[]; (access_hostname | length) > 0) and
(
$app._delete_base == $account_access_base or
any($wildcard_zone_ids[];
. as $zone |
$app._delete_base ==
("https://api.cloudflare.com/client/v4/zones/" + $zone + "/access/apps"))
)
) or
any(.destinations[]?;
((.type == "worker" or .type == "preview_worker") and
((.name // .service // "") == $script)) or
.type == "all_workers" or
.type == "all_preview_workers"
)
) |
{id, name, domain, destinations, type, _delete_base}
]' "$WORKER_ACCESS_ALL" > "$WORKER_ACCESS_AUDIT"
rm -f -- "$WORKER_ACCESS_ALL"Worker Access 候选以 Custom Domain、精确 route hostname、DNS 审计中的具体非 wildcard hostname 和生产 workers.dev hostname 为精确目标;Access 通配符按 label 规则与这些目标匹配。Preview URL 另以 -<worker>.<account-subdomain>.workers.dev 边界纳入候选。
当 route 或适用 DNS owner 含 wildcard 时,有限的具体 hostname 样本不能证明两个 pattern 没有交集。代码因此保守纳入对应 Zone 的全部 public-hostname Access 应用,并在存在账户级 wildcard 风险时纳入 Account 级 public-hostname 应用,交给操作者逐项排除;它不会把“样本未命中”当成无关联证据。
为每个 WORKER_HOSTS_AUDIT 条目建立一条 DNS 决策,为每个 WORKER_ACCESS_AUDIT 条目建立一条 Access 决策。分别保存为 worker-<script>-dns-decisions.json 和 worker-<script>-access-decisions.json 数组;DNS 记录至少包含 hostname、zone_id、decision、verified: true、evidence,Access 记录至少包含 id、delete_base、decision、verified: true、evidence。DNS decision 可以是 migrated、retained、deleted、no-record 或 unproxied,Access 不使用后两项;evidence 记录后置查询或业务验收位置,不得写入 Secret。
同时在当前 Wrangler 配置和 Dashboard 中核对 Service Bindings、KV、D1、R2、Queues 和 Durable Objects。未确认共享关系的资源一律保留。
3. 完成流量入口切换
删除脚本前必须满足以下硬验收点:
- 指向旧脚本的 route 和 Custom Domain 已删除,或已经重新指向通过验收的替代脚本。
workers.dev是否继续开放、禁用或随脚本删除已有明确方案。- 生产域名与替代入口返回预期响应,监控中不再有需要旧脚本处理的业务流量。
- 外部 CI、Cron、Webhook、Queue 生产者和 Service Binding 调用方已经停止或切换。
对 RELATED_ZONE_IDS 中每个 Zone 完成流量切换,再从对应列表取得 route ID;不要根据 URL pattern 猜测,也不要复用另一个 Zone 的 ID:
ZONE_ID='<zone-id-from-the-inventory>'
ROUTE_ID='<confirmed-worker-route-id>'
cf_delete \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes/${ROUTE_ID}"
routes_response="$(cf_json "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/workers/routes")"
printf '%s' "$routes_response" | jq -e --arg id "$ROUTE_ID" \
'([.result[] | select(.id == $id)] | length) == 0' >/dev/null
unset routes_responseCustom Domain 已经切换到替代服务或明确下线后,按账户级清单返回的 ID 解绑,并重新查询确认旧脚本不再出现:
WORKER_DOMAIN_ID='<confirmed-worker-domain-id>'
cf_delete "${WORKER_DOMAINS_URL}/${WORKER_DOMAIN_ID}"
worker_domains_response="$(cf_json "${WORKER_DOMAINS_URL}?service=${ENCODED_SCRIPT_NAME}")"
printf '%s' "$worker_domains_response" | jq -e --arg domain_id "$WORKER_DOMAIN_ID" '
.result_info.count == (.result | length) and
([.result[].id] | unique | length) == .result_info.count and
.result_info.total_count >= .result_info.count and
([.result[] | select(.id == $domain_id)] | length) == 0
' >/dev/null
unset worker_domains_response若 workers.dev 查询仍显示 enabled: true,先在 Dashboard 的 Worker Domains & Routes 中禁用,或在该脚本的 Wrangler 配置中设置后执行一次受审批的配置发布:
workers_dev = false
preview_urls = false应用配置后执行远端入口硬校验和决策记录完整性门禁。下面的第一个函数会重新读取所有已确认 Zone 的 routes,并要求服务端按 service 过滤后的 Worker Domains count 为零;total_count 可能仍包含账户中的其他 Worker Domains,不能把它误当成当前脚本的命中数。第二个函数只检查每个 DNS hostname 和 Access 候选是否恰好有一条格式完整的决策记录,它不能替代远端查询或业务验收:
WORKER_DNS_DECISIONS="worker-${SCRIPT_NAME}-dns-decisions.json"
WORKER_ACCESS_DECISIONS="worker-${SCRIPT_NAME}-access-decisions.json"
assert_worker_routes_and_domains_retired() {
local zone_id routes_response worker_domains_response
for zone_id in "${RELATED_ZONE_IDS[@]}"; do
routes_response="$(cf_json \
"https://api.cloudflare.com/client/v4/zones/${zone_id}/workers/routes")"
printf '%s' "$routes_response" | jq -e --arg script "$SCRIPT_NAME" \
'([.result[] | select(.script == $script)] | length) == 0' >/dev/null
unset routes_response
done
worker_domains_response="$(cf_json \
"${WORKER_DOMAINS_URL}?service=${ENCODED_SCRIPT_NAME}")"
printf '%s' "$worker_domains_response" | jq -e --arg script "$SCRIPT_NAME" '
.result_info.count == (.result | length) and
([.result[].id] | unique | length) == .result_info.count and
.result_info.total_count >= .result_info.count and
.result_info.count == 0 and
([.result[] | select(.service != $script)] | length) == 0
' >/dev/null
unset worker_domains_response
}
assert_worker_decision_records_complete() {
jq -e \
--slurpfile hosts "$WORKER_HOSTS_AUDIT" \
--slurpfile dns_audit "$WORKER_DNS_AUDIT" '
. as $decisions |
($decisions | type) == "array" and
($decisions | length) == ($hosts[0] | length) and
($dns_audit | length) == ($hosts[0] | length) and
all($decisions[];
. as $decision |
([$dns_audit[] |
select(.hostname == $decision.hostname and
.zone_id == $decision.zone_id)]) as $audits |
($audits | length) == 1 and
$decision.verified == true and
(($decision.evidence // "") | length) > 0 and
($decision.decision == "migrated" or $decision.decision == "retained" or
$decision.decision == "deleted" or $decision.decision == "no-record" or
$decision.decision == "unproxied") and
(if $decision.decision == "no-record" then
($audits[0].records | length) == 0 and
$audits[0].requires_resolution_review == false
elif $decision.decision == "unproxied" then
($audits[0].exact_records | length) > 0 and
all($audits[0].exact_records[]; .proxied == false)
else true end)) and
all($hosts[0][];
. as $host |
([$decisions[] |
select(.hostname == $host.hostname and .zone_id == $host.zone_id)] | length) == 1)
' "$WORKER_DNS_DECISIONS" >/dev/null
jq -e --slurpfile candidates "$WORKER_ACCESS_AUDIT" '
. as $decisions |
($decisions | type) == "array" and
($decisions | length) == ($candidates[0] | length) and
all($decisions[];
.verified == true and
((.evidence // "") | length) > 0 and
(.decision == "migrated" or .decision == "retained" or .decision == "deleted")) and
all($candidates[0][];
. as $candidate |
([$decisions[] |
select(.id == $candidate.id and
.delete_base == $candidate._delete_base)] | length) == 1)
' "$WORKER_ACCESS_DECISIONS" >/dev/null
}
assert_worker_routes_and_domains_retired
subdomain_response="$(cf_json \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${SCRIPT_NAME}/subdomain")"
printf '%s' "$subdomain_response" | jq -e \
'.result.enabled == false and .result.previews_enabled == false' >/dev/null
unset subdomain_response
assert_worker_decision_records_complete任一 Zone 仍有目标 script route、服务端过滤结果 count 非零、enabled 或 previews_enabled 不是 false,以及 DNS/Access 决策缺失、重复或未验证,都会让命令失败。决策文件中的 verified: true 是操作者对记录的声明,不是脚本生成的远端证据;填写前必须完成对应 DNS 后置查询、Access 应用重新列出和公网 URL 业务验收,并把查询时间与结果位置写入 evidence。只要 route、Custom Domain、workers.dev、Preview URL 或业务验收仍显示旧脚本可达,就不能继续删除。
4. 删除脚本
完成流量硬验收和绑定盘点后,先使用普通删除:
cf_delete \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${SCRIPT_NAME}"
assert_worker_script_absent "$SCRIPT_NAME"
assert_worker_routes_and_domains_retired
assert_worker_decision_records_complete如果 API 因 Service Binding、Durable Object 或其他 binding 拒绝删除,先回到依赖清单确认影响范围。force=true 会把关联的 Service Binding、Durable Object 或其他 binding 连同脚本一起删除,不只是跳过检查。使用前必须逐项记录将被删除的关联对象、数据保留要求和其他消费者,并由资源所有者确认:
cf_delete \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts/${SCRIPT_NAME}?force=true"
assert_worker_script_absent "$SCRIPT_NAME"
assert_worker_routes_and_domains_retired
assert_worker_decision_records_completeWorkers API 的 force 与 Pages deployment 的 --force 含义不同。前者执行关联删除,不能因为普通删除失败就直接追加。删除后重新查询脚本、routes、Custom Domains 和调用方,确认控制面对象与流量入口都符合退役方案。
5. 处置绑定资源
KV、D1、R2、Queues、Durable Objects、Service Bindings 和 Secrets 都按独立资源处置。只有确认没有其他消费者、满足数据保留要求并完成单独审批后才删除;共享资源保持不动。Custom Domain、Workers Domain、DNS 和 Access 也使用各自的管理路径,不因脚本删除而推定已经清理。
分层验收
退役后的验收至少分为四层:
- 控制面:Pages 项目或 Worker 脚本不再出现在目标账户中。
- 发布面:Git 集成、CI、Deploy Hook 和 Workers Builds 不再尝试创建部署。
- 流量面:生产域名、route、Custom Domain、
pages.dev或workers.dev按退役方案返回新站点、维护响应或不可访问状态。 - 数据面:应保留的共享资源仍可被合法消费者使用,应删除的资源有独立审批和结果记录。
API 列表中对象消失,不等于所有边缘节点、DNS 缓存和客户端缓存已经立即收敛。对公网入口记录验证时间、地区、URL 和响应;若结果异常,保持凭证已轮换和域名已切走的安全状态,再向 Cloudflare 提供证据。
常见失败
| 现象 | 判断 | 处理 |
|---|---|---|
| Pages 项目 deployments 超过 100,项目删除失败 | Pages 已知限制 | 逐个清理历史 deployment,再删除项目 |
| 提示 active production deployment | 当前生产 deployment 受保护 | 保留它,或先完成生产切换再重新清理 |
| deployment 不存在 | ID 已删除或清单已变化 | 重新获取列表,不继续复用旧 ID |
HTTP 403 | Token 权限或资源范围不足 | 核对 Pages、Zone、DNS、Access、Workers Scripts、Workers Routes 和 Account/Zone 范围;任何盘点盲区都阻断删除 |
HTTP 429 | 删除请求触发限流 | 降低并发,遵循服务端重试信息 |
| Worker 删除被 binding 阻止 | 存在关联依赖 | 核对共享关系;不要自动切换到 force=true |
| 控制面已删除但公网仍可访问 | DNS、路由、缓存或边缘传播尚未收敛 | 分别检查入口,记录证据并升级处理 |
恢复边界
Pages project、Pages deployment 和 Worker script 的删除都不是常规回滚动作。可行的恢复通常是从源码和受控制品重新部署,在删除前保存的配置清单基础上重建域名、路由、变量和绑定。
若退役过程中发现共享资源、名称错误或流量尚未切换,应停止后续删除。尚未删除的域名、route、绑定和数据资源可以重新接回替代服务;已经删除的项目或脚本只能按新部署处理。
最终检查清单
- [ ] 已暂停自动发布、Cron、Webhook 和消息生产者。
- [ ] 已核对 Account ID、Zone ID、项目名、脚本名和生产域名。
- [ ] 已保存非秘密的 deployment、route、绑定和配置清单。
- [ ] 已确认源码、制品和动态数据的保留位置。
- [ ] Pages Custom Domain 已切换并解绑,DNS 与 Access 已分别决策。
- [ ] Pages 历史 deployment 已按小批次清理并重新列出确认。
- [ ] 账户 Zone 清单已完整核对,所有可见 Zone 的 Worker routes 均已保存并复查。
- [ ] Worker 的 route、Custom Domain 和共享资源已分别决策。
- [ ] Worker DNS hostname 与 Access 候选均有唯一、已验证的决策记录。
- [ ] 删除脚本前已验证替代入口,并重新列出 routes、Custom Domains、
workers.dev与 Preview URL 状态。 - [ ]
force只在理解对应产品的额外影响后使用。 - [ ] 控制面、发布面、流量面和数据面均有验收记录。
- [ ] 已记录不可恢复对象和必要的重建步骤。
撤销操作凭证
只有前面的退役路径和最终检查清单全部完成后,才进入本节。先在 Cloudflare Dashboard 撤销一次性 Token。也可以由另一份管理凭证调用 API:管理 Account Token 的凭证需要 Account API Tokens Write,管理 User Token 的凭证需要 API Tokens Write。路径中的 TOKEN_ID 是 Token ID,不是 Token 明文。安全读入管理凭证,并定义独立调用函数:
TOKEN_ID='<one-time-token-id>'
printf 'Cloudflare token-management credential: ' >&2
IFS= read -rs TOKEN_MANAGEMENT_TOKEN
printf '\n' >&2
cf_token_api() {
: "${TOKEN_MANAGEMENT_TOKEN:?TOKEN_MANAGEMENT_TOKEN is required}"
printf 'Authorization: Bearer %s\n' "$TOKEN_MANAGEMENT_TOKEN" |
curl --fail-with-body -sS --header @- "$@"
}
cf_token_json() {
local response
response="$(cf_token_api "$@")" || return
printf '%s' "$response" |
jq -e '.success == true and ((.errors // []) | length == 0)' >/dev/null || return
printf '%s' "$response"
}
cf_token_delete() {
local url="$1" body_file status
body_file="$(mktemp)"
chmod 600 "$body_file"
if ! status="$(
printf 'Authorization: Bearer %s\n' "$TOKEN_MANAGEMENT_TOKEN" |
curl -sS -o "$body_file" -w '%{http_code}' --header @- -X DELETE "$url"
)"; then
rm -f -- "$body_file"
return 1
fi
case "$status" in
2??) ;;
*) rm -f -- "$body_file"; return 1 ;;
esac
if test -s "$body_file"; then
jq -e '.success == true and ((.errors // []) | length == 0)' \
"$body_file" >/dev/null || { rm -f -- "$body_file"; return 1; }
fi
rm -f -- "$body_file"
}根据目标 Token 的归属只执行下面匹配的一条,不要把两个端点依次尝试:
# Account Token:管理凭证需要 Account API Tokens Write
cf_token_delete \
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/tokens/${TOKEN_ID}"# User Token:管理凭证需要 API Tokens Write
cf_token_delete \
"https://api.cloudflare.com/client/v4/user/tokens/${TOKEN_ID}"确认 Dashboard 显示已撤销或 API 返回成功后,再清除当前 shell 中的操作凭证和资源标识。unset 只删除本地变量,不会撤销 Cloudflare 服务端的 Token:
unset CLOUDFLARE_API_TOKEN
unset CLOUDFLARE_ACCOUNT_ID
unset TOKEN_ID
unset TOKEN_MANAGEMENT_TOKEN结束前确认两件事:一次性 Token 已在服务端撤销;服务端成功确认后,当前 shell 中的 Token 与资源标识变量已经清除。
参考资料
- Pages Known Issues: Delete a project with a high number of deployments
- Pages API
- Wrangler Pages commands
- Delete Pages project API
- Delete Pages deployment API
- List Pages deployments API
- Delete Worker script API
- List Workers routes API
- Delete Workers route API
- Workers Routes
- Workers versions and deployments
- Delete Account Token API
- Delete User Token API
- List Pages domains API
- Delete Pages domain API
- Pages custom domains
- List Worker domains API
- Delete Worker domain API
- Get Worker subdomain API
- Workers.dev
- Workers Preview URLs
- Cloudflare Access self-hosted applications
- Cloudflare Access Application paths
- List zones API
- List DNS records API
- Delete DNS record API
- Wildcard DNS records
- List Access applications API
- Delete Access application API
- List Pages projects API
- List Worker scripts API