簡化工作要求

Cloud Scheduler 會在 HTTP 和 App Engine 要求中加入工作識別資訊和排程時間 HTTP 標頭,讓您簡化重試執行作業,並使目標處理常式冪等。Cloud Scheduler 採用「至少一次」傳遞機制,因此簡化要求可避免處理常式或下游 Google Cloud API 在逾時或網路暫時中斷時,多次處理相同的排定執行作業。

要求簡化的運作方式

在下列情況下,同一排定執行作業可能會出現重複的工作要求:

  • 長時間執行的目標:目標服務的回應時間超過工作的嘗試期限 (attemptDeadline),導致 Cloud Scheduler 逾時,並在第一次嘗試仍在執行時重試工作。如果是 HTTP 目標,attemptDeadline 預設為 3 分鐘,最多可設為 30 分鐘。請根據目標的逾時時間調整 attemptDeadline,如果執行時間可能超過 30 分鐘,請以非同步方式處理長時間執行的工作。
  • 網路確認遺失或暫時性 Proxy 重試:目標接收並處理要求,但暫時性網路中斷或 Proxy 中斷連線,導致 Cloud Scheduler 無法收到 HTTP 2xx 回應。即使將 retryCount 設為 0,至少傳送一次的機制仍可能導致目標在暫時性傳輸失敗期間收到重複的要求。

如要讓工作處理常式具備冪等性,並簡化重試的要求,請使用 Cloud Scheduler 自動在 HTTP 和 App Engine 目標要求中加入的 HTTP 標頭:

標頭 說明
X-CloudScheduler-JobName projects/PROJECT_ID/locations/LOCATION_ID/jobs/JOB_ID 的簡短工作 ID (JOB_ID)。
X-CloudScheduler-ScheduleTime

如果是以 unix-cron 格式指定的工作,則排定的執行時間會以世界標準時間 (UTC) 的偏移量表示,格式則依據 RFC 3339,例如 2026-10-16T12:00:00Z 或 2026-10-16T05:00:00-07:00。在同一排定執行作業的所有重試嘗試中,這個值會保持不變。

對於使用替代 groc 排程格式 (例如 every 5 minutes) 的工作,不保證會顯示這個標頭。如果您手動觸發工作 (例如執行 gcloud scheduler jobs run,或在 Google Cloud 控制台中按一下「Force a job run」(強制執行工作)),這個標頭會使用下次排定執行的時間戳記,或可能省略。因此,標頭簡化功能會略過即將執行的排定作業,以及在排定時間前觸發的任何其他手動執行作業。

要求驗證目標端點

X-CloudScheduler-* 標頭未經驗證,可由任何呼叫端設定。請勿使用這些標頭進行驗證。請改為根據目標類型保護端點:

使用不區分大小寫的標頭查詢

標頭的大小寫可能因 HTTP 版本和基礎架構而異 (例如 HTTP/1.1 中的 X-Cloudscheduler-Jobname,或 HTTP/2 環境 (例如 Cloud Run) 中的小寫 x-cloudscheduler-jobname 和 x-cloudscheduler-scheduletime)。在處理常式中,一律使用不區分大小寫的標頭查詢。

區分不同專案或區域的工作 ID

Cloud Scheduler 不會自動傳送專案 ID 或區域標頭。單一處理常式端點可能會收到來自多個專案或區域的要求,這些專案或區域共用相同的 JOB_ID。在這種情況下,請在 HttpTarget.headers、AppEngineHttpTarget.headers、目標網址路徑或查詢參數中傳遞專案 ID 或區域,並將這些值納入簡化鍵。

在 HTTP 處理常式中簡化要求

在處理常式中實作要求簡化功能之前,請先完成下列設定必要條件:

  • 請以 unix-cron 格式設定工作排程 (例如 */5 * * * *,而非 every 5 minutes),讓 Cloud Scheduler 傳送 X-CloudScheduler-ScheduleTime 標頭。
  • 設定支援交易或不可部分完成條件式寫入的永久資料儲存庫,例如 Firestore、Spanner 或 Memorystore。在集合或資料表 (例如 expire_at 欄位) 設定存留時間 (TTL) 政策,在工作重試時間範圍 (maxRetryDuration) 結束後,刪除舊的簡化記錄。Firestore TTL 會以非同步方式刪除過期記錄,通常在 24 到 72 小時內完成。如要讓重試作業在 TTL 清理前,從當機的執行個體回收執行作業,請一併儲存租約到期時間戳記 (例如 lease_expires,設定為至少目標的要求逾時和 attemptDeadline),並在交易中評估。

如要在 HTTP 處理常式中,只處理一次每個排定的執行作業,請完成下列步驟:

  1. 從傳入的要求標頭讀取 X-CloudScheduler-JobName 和 X-CloudScheduler-ScheduleTime,並將其雜湊為固定長度的簡化鍵 (run_id)。
  2. 在資料儲存庫中執行交易,檢查並宣告簡化金鑰的記錄:
    • 如果記錄存在且 status 為 completed,請立即傳回 HTTP 200 OK (或 204 No Content) 狀態碼,而不執行工作邏輯,讓 Cloud Scheduler 停止重試。
    • 如果記錄存在、status 為 in_progress,且租約 (lease_expires) 尚未到期,請傳回可重試的錯誤狀態碼 (例如 409 Conflict),讓 Cloud Scheduler 保持重試政策有效,以防執行中的嘗試失敗。請避免從 App Engine 目標傳回 503 Service Unavailable,因為 App Engine 會將 503 視為系統錯誤,並在不計入 retryCount 的情況下重試要求。
    • 如果記錄不存在或租約已到期,請寫入記錄,並將 status 設為 in_progress、不重複的嘗試 ID (attempt_id)、租約到期時間戳記 (lease_expires) 和存留時間到期時間戳記 (expire_at)。
  3. 宣告記錄後,請執行工作邏輯。在交易中,請先確認記錄的 attempt_id 仍與目前嘗試相符,再修改記錄:

    • 如果工作邏輯成功,請將 status 更新為 completed,並傳回 HTTP 200 OK 狀態碼。
    • 如果工作邏輯失敗,且錯誤可以重試,請先刪除簡化記錄,再傳回 5xx 狀態碼,這樣 Cloud Scheduler 就能重試執行,不必等待租約到期。

HTTP 處理常式範例

下列 Python 範例 (適用於 Cloud Run 或 Cloud Run functions) 使用 Firestore 交易和租約來宣告執行作業,並簡化重試的工作要求。

這個範例不適用於正式環境。部署前,請先查看並視需要調整:

import datetime
import hashlib
import uuid

import flask
import functions_framework
from google.cloud import firestore

# Longer than the work takes, and at least the job's attempt deadline.
LEASE = datetime.timedelta(minutes=5)
# Keep records past the job's retry window. A TTL policy on expire_at deletes them.
RETENTION = datetime.timedelta(days=1)

db = firestore.Client()


def run_job_logic() -> None:
    """Does the scheduled work. Replace the body with your own task."""


@firestore.transactional
def claim_run(
    transaction: firestore.Transaction,
    run_ref: firestore.DocumentReference,
    attempt_id: str,
    now: datetime.datetime,
) -> str:
    """Claims the run unless it's done or another attempt holds the lease."""
    snapshot = run_ref.get(transaction=transaction)
    if snapshot.exists:
        run = snapshot.to_dict()
        if run["status"] == "completed":
            return "completed"
        if run["lease_expires"] > now:
            return "in_progress"
    transaction.set(
        run_ref,
        {
            "status": "in_progress",
            "attempt_id": attempt_id,
            "lease_expires": now + LEASE,
            "expire_at": now + RETENTION,
        },
    )
    return "claimed"


@firestore.transactional
def finish_run(
    transaction: firestore.Transaction,
    run_ref: firestore.DocumentReference,
    attempt_id: str,
    succeeded: bool,
) -> None:
    """Completes or releases the run, if this attempt still holds the claim."""
    snapshot = run_ref.get(transaction=transaction)
    if not snapshot.exists or snapshot.to_dict().get("attempt_id") != attempt_id:
        return  # A retry claimed the run after this attempt's lease expired.
    if succeeded:
        transaction.update(run_ref, {"status": "completed"})
    else:
        transaction.delete(run_ref)


@functions_framework.http
def handle_scheduled_job(request: flask.Request) -> tuple[str, int]:
    """Runs a job's work once per scheduled run, even if the run is delivered twice."""
    job_name = request.headers.get("X-CloudScheduler-JobName")
    schedule_time = request.headers.get("X-CloudScheduler-ScheduleTime")
    if not job_name or not schedule_time:
        return "error: missing Cloud Scheduler headers", 400

    # Retries keep the same schedule time. A forced run uses the next
    # scheduled time, so that scheduled run is then skipped.
    run_id = hashlib.sha256(f"{job_name}/{schedule_time}".encode()).hexdigest()
    run_ref = db.collection("scheduler_runs").document(run_id)
    now = datetime.datetime.now(datetime.timezone.utc)
    attempt_id = uuid.uuid4().hex

    status = claim_run(db.transaction(), run_ref, attempt_id, now)
    if status == "completed":
        return "Duplicate run skipped", 200
    if status == "in_progress":
        # A non-2xx response keeps retries going in case that attempt fails.
        return "Run in progress", 409

    try:
        run_job_logic()
    except Exception:
        # Releases the claim so that a retry can process the run.
        finish_run(db.transaction(), run_ref, attempt_id, succeeded=False)
        raise
    finish_run(db.transaction(), run_ref, attempt_id, succeeded=True)
    return "Run processed", 200

避免向 Google Cloud API 目標重複傳送要求

Cloud Scheduler 透過 HTTP 直接叫用 Google Cloud API 端點 (*.googleapis.com) 時,目標 API 會忽略 X-CloudScheduler-JobName 和 X-CloudScheduler-ScheduleTime 標頭。舉例來說,呼叫 workflowexecutions.googleapis.com 啟動 Workflows 執行作業、呼叫 run.googleapis.com 執行 Cloud Run 工作,或呼叫 cloudbuild.googleapis.com 執行 Cloud Build 自動建構觸發條件,都會在每次重試時啟動新的執行作業。

為確保正式環境的可靠性,請在修改狀態的最下游服務中強制執行冪等性。如果只在上游層簡化要求,重試時間範圍會縮短,但如果下游要求成功,且 HTTP 回應在網路中遺失,上游呼叫端就無法判斷作業是否完成,且可能仍會重試要求。

排定 Google Cloud API 目標時,為避免重複執行,請搭配使用下列方法,確保端對端冪等性:

  • 透過冪等 Proxy 轉送要求:不要直接從 Cloud Scheduler 呼叫 *.googleapis.com 端點,而是部署自己的 Cloud Run 服務或 Cloud Run 函式 Proxy,並設定 Cloud Scheduler 叫用該 Proxy。在 Proxy 中,實作本文「簡化 HTTP 處理常式中的要求」一節所述的 Python 和 Firestore 原子交易式宣告模式。

    Proxy 會讀取 X-CloudScheduler-JobName 和 X-CloudScheduler-ScheduleTime、簡化重試的要求,並將簡化鍵做為冪等性權杖,在要求酬載中傳遞 (例如在啟動 Workflows 執行作業時,於 argument 欄位中傳遞)。

    要求對 Proxy 進行驗證 (例如使用 --no-allow-unauthenticated 部署),並設定 Cloud Scheduler 工作,使用 OIDC 權杖進行驗證。在專屬服務帳戶下執行 Proxy,並僅授予最低限度的必要 Identity and Access Management (IAM) 角色 (例如目標工作流程的 roles/workflows.invoker 和簡化記錄的 roles/datastore.user)。

  • 在下游服務中強制執行冪等性:使用冪等性權杖,在執行狀態變更步驟之前,在 Firestore 等資料儲存庫中執行原子或交易檢查。將權杖傳遞至服務叫用的任何下游 HTTP 端點或 Cloud Run 函式,讓這些目標也能簡化重試作業。如需使用 Workflows、Firestore 交易和 Cloud Run functions 的端對端範例,請參閱 Google Cloud網誌文章「使用 Workflows 時只發出一次 API 呼叫」。