GCP Cloud Scheduler 事务邮件 + Rovela(2026):Cloud Functions/Run cron 与按窗口的幂等键
用 Google Cloud Scheduler(unix-cron/groc、HTTP 或 Pub/Sub、OIDC)触发 Cloud Functions 或 Cloud Run,把 ROVELA_API_KEY 放在 Secret Manager,并用按窗口的 Idempotency-Key(含 X-CloudScheduler-ScheduleTime)调用 POST /emails 发送摘要与运维报告。
Cloud Scheduler 负责 cron — Rovela 负责投递
Google Cloud Scheduler 是托管的 unix-cron(或 groc)任务调度器。一个 Job 可以推送 HTTP 请求、发布到 Pub/Sub,或打到 App Engine handler。这种形态适合摘要、运维报告,以及正文依赖 触发时刻实时状态(注册数、失败任务、未付发票)的事务邮件——而不是你能提前几小时用 Rovela scheduled_at 冻住的内容。
当 Cloud Functions 或 Cloud Run handler 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。GCP 负责调度、重试与密钥注入(环境变量或 Secret Manager)。你的函数负责聚合数据,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好 Cloud Scheduler → Cloud Functions / Cloud Run → Rovela,让它:
- 从环境变量和/或 Secret Manager 读取
ROVELA_API_KEY、MAIL_FROM与收件人(绝不写死在仓库) - 按当前调度窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key调用 Rovela 发送(也可派生自X-CloudScheduler-ScheduleTime) - 在 Scheduler 重试下仍不重复发信
Rovela 不会创建 Cloud Scheduler Job、IAM 绑定或 Cloud Run 服务。 Scheduler 管 cron → 调用;你的 handler 管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列路径区分:
- AWS EventBridge Scheduler 事务邮件 — 六段 AWS cron /
rate/at+ Lambda,不是 GCP 五段 unix-cron - Netlify Scheduled Functions 事务邮件 — Netlify UTC cron + 30 秒函数限制
- Fly Scheduled Machines 事务邮件 — 粗粒度
hourly/daily/weekly/monthly - Render Cron 事务邮件 — 必须退出的 Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被跳过
- Vercel Cron 摘要邮件 — 对生产路由发 HTTP
GET+CRON_SECRET - GitHub Actions 事务邮件 — 工作流 /
run_id在 CI 事件上通知 - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
Scheduler 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
unix-cron / groc 与 timeZone | Cloud Scheduler(cron 格式) |
| HTTP / Pub/Sub / App Engine 目标 | Cloud Scheduler(Job 资源) |
| 调用 Cloud Run / Functions 的 OIDC | Scheduler Job(oidcToken)+ invoker IAM |
ROVELA_API_KEY 的环境变量或 Secret Manager | Cloud Run / Functions + Secret Manager |
| 按窗口查询 DB / API | 你的 handler |
| 渲染 HTML + text | 你的 handler |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = gcp-{kind}-{yyyy-mm-dd}(或小时 / 周) | 你的 handler |
Rovela 返回 200 表示 已接受并入队,不等于「已经进收件箱」(Quick Start)。
决定邮件任务形态的 GCP 约束
结合 Google Cloud 文档,发信调度请把下面当作硬约束:
- 五段 unix-cron(分 时 日 月 星期),或可读的 groc(
every day 09:00、1st friday of quarter 9:00),通过gcloud/ REST API 设置(cron 格式)。 - IANA
timeZone。 默认 UTC。调度按挂钟时间;夏令时可能跳过或重复某个本地小时——对精确节拍 Google 建议用 UTC(时区说明)。 - 同一时间只有一次执行。 第 n+1 次不会在第 n 次结束前开始;handler 偏慢会推迟下一次(Job 资源)。
- 目标类型。
httpTarget、pubsubTarget或appEngineHttpTarget。对 Cloud Run / HTTPS Functions,用带 OIDC 的 HTTP Job(不要用 OAuth——OAuth 只给*.googleapis.com)(HTTP 鉴权、按调度跑 Cloud Run)。 - HTTP 确认。 成功是 HTTP 2xx。超时与非 2xx 计为失败尝试,按
retryConfig重试(HttpTarget)。 attemptDeadline。 HTTP 目标默认 3 分钟,允许 15 秒–30 分钟。Pub/Sub 忽略该字段(Job)。retryConfig.retryCount。0(默认)表示失败后等到下次调度再跑;非零(最大 5)按指数退避重试(minBackoffDuration默认 5s,maxBackoffDuration默认 1h,maxDoublings默认 5)(RetryConfig)。- HTTP/App Engine 目标上的稳定头。 Cloud Scheduler 会设置
X-CloudScheduler: true、X-CloudScheduler-JobName,以及对 unix-cron Job 的X-CloudScheduler-ScheduleTime(RFC3339)。该调度时刻头在 重试间保持不变,本意就是请求去重(HttpTarget headers)。 - Cloud Run 要求鉴权。 部署时选择需要身份验证;给 Scheduler 服务账号
roles/run.invoker(using Scheduler)。 - 出站 SMTP 不合适。 请用 443 上的 HTTPS
POST /emails(Email API vs SMTP)。 - 密钥只留在服务端。 从 Secret Manager 挂载 Rovela
sending_access密钥(或钉死版本的环境变量密钥)——绝不要塞进浏览器包(Authentication、Cloud Run secrets)。
何时用 Scheduler + POST /emails(而不是 scheduled_at)
在这些情况下用 Cloud Scheduler → Functions / Run → 即时发送:
- HTML 必须包含触发时刻的 新鲜 指标
- 你已在 GCP 跑计算,希望 IAM、Cloud Logging 与调度同属一个项目
- 需要真正的 crontab(
Europe/London工作日早上、每 15 分钟)或 groc 短语 - 一封(或少量)事务邮件发给运行时查出的运维 / 创始人 / 客户地址
正文已经正确、只需要「稍后再投」且不必跑任务时,用 Rovela scheduled_at(定时发送邮件)。Scheduler 与 scheduled_at 解决不同问题;本文是 GCP 路径。
前置条件
- Rovela 中已 验证发信域名。未验证的
from会400(Quick Start)。 - 形如
re_…的 API 密钥。handler 上优先用sending_access(Authentication)。 - 能出站 HTTPS、超时足够覆盖查询 + 一次 Rovela 往返的 Cloud Functions(第 2 代)或 Cloud Run(让 Scheduler
attemptDeadline≥ 该超时)。 - 密钥:
ROVELA_API_KEY的 Secret Manager,以及MAIL_FROM、DIGEST_TO(或在代码里查收件人)。给 运行时 服务账号roles/secretmanager.secretAccessor。 - 专用的 Scheduler 客户端服务账号,具备
roles/run.invoker(Cloud Run)或 HTTPS Function 上对应的 invoker 角色(using Scheduler)。 - 调度表达式——例如
--time-zone=Europe/London下的0 9 * * MON-FRI,或every 1 hours。
在 Cloud Run / Functions 上放密钥
把 Rovela 密钥存进 Secret Manager,再暴露为环境变量(环境变量注入请钉死版本)或挂载卷:
printf '%s' 're_your_sending_access_key' | \
gcloud secrets create rovela-api-key --data-file=-
gcloud run services update daily-digest-email \
--region=europe-west1 \
--update-secrets=ROVELA_API_KEY=rovela-api-key:1 \
--update-env-vars=MAIL_FROM='Acme Reports <reports@mail.example.com>',DIGEST_TO=founders@example.com,DIGEST_KIND=daily
生产发信请保持 sending_access —— 不要用 full_access(Authentication)。绝不要把 API 密钥打进日志。轮换:新增 secret 版本 → 更新服务 → 确认一次成功触发 → 停用旧版本。
创建 Scheduler Job(HTTP → Cloud Run)
控制台(快路径)
- 以 需要身份验证 部署 Cloud Run(文档)。
- 为 Scheduler 创建服务账号,并授予该服务
roles/run.invoker。 - Cloud Scheduler → 创建作业 → 频率 + 时区 → 目标 HTTP → 服务 URL → 方法
POST→ 鉴权头 OIDC 令牌 → 该服务账号 → Audience = 服务 URL(不含查询参数)。
gcloud(本地时区 cron)
PROJECT=my-gcp-project
REGION=europe-west1
SERVICE_URL=$(gcloud run services describe daily-digest-email \
--region="$REGION" --format='value(status.url)')
SA_EMAIL=scheduler-digest@$PROJECT.iam.gserviceaccount.com
gcloud run services add-iam-policy-binding daily-digest-email \
--region="$REGION" \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/run.invoker"
gcloud scheduler jobs create http daily-digest-london \
--location="$REGION" \
--schedule="0 9 * * MON-FRI" \
--time-zone="Europe/London" \
--uri="$SERVICE_URL/digest" \
--http-method=POST \
--headers="Content-Type=application/json" \
--message-body='{"kind":"daily","market":"uk"}' \
--oidc-service-account-email="$SA_EMAIL" \
--oidc-token-audience="$SERVICE_URL" \
--attempt-deadline=180s \
--max-retry-attempts=3 \
--min-backoff=30s \
--max-backoff=3600s
基于 GCP 文档的要点:
- 五段 cron:这里是伦敦挂钟时间工作日 09:00(cron 格式)。
- Job URI 带查询参数时,OIDC audience 应是 不含 查询参数的服务 URL(HTTP 鉴权)。
--attempt-deadline=180s对齐 HTTP 默认三分钟;聚合很慢就提高(最大 30 分钟)。--max-retry-attempts对应retryConfig.retryCount(0–5)。开启重试就要按 至少一次 进入 handler 设计——这正是 RovelaIdempotency-Key必需的原因。- 强制试跑:
gcloud scheduler jobs run daily-digest-london --location="$REGION"。
Pub/Sub 目标(可选扇出)
若多个 worker 要响应同一次 tick,把 Scheduler 指向主题,再让 Function / Run 订阅:
gcloud scheduler jobs create pubsub hourly-ops-tick \
--location="$REGION" \
--schedule="0 * * * *" \
--time-zone="UTC" \
--topic=cron-ops \
--message-body='{"kind":"hourly"}'
Pub/Sub 投递仍是至少一次。订阅方继续使用按窗口的 Rovela 幂等键。Scheduler 以 Google APIs 服务账号发布;Job 上的 Pub/Sub attemptDeadline 会被忽略(Job)。
最小 Node.js Cloud Run / Functions handler
// index.mjs — Cloud Run(或 Functions gen2 HTTP)Node.js 20+
import http from 'node:http'
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name) {
const v = process.env[name]
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10)
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13)
}
function windowKeyFromHeaders(headers, kind) {
const scheduleTime = headers['x-cloudscheduler-scheduletime']
if (scheduleTime) {
const stamp = String(scheduleTime).replace(/[:.]/g, '-').replace(/Z$/i, 'Z')
return `gcp-${kind}:${stamp}`
}
return kind === 'hourly'
? `gcp-hourly-ops:${utcHourKey()}`
: `gcp-daily-digest:${utcDayKey()}`
}
async function buildDailyDigest(day) {
const stats = {
signups: 12,
paid: 3,
incidents: 1,
failedJobs: 0,
}
const subject = `Acme daily digest — ${day}`
const text = [
`Signups: ${stats.signups}`,
`Paid: ${stats.paid}`,
`Open incidents: ${stats.incidents}`,
`Failed jobs: ${stats.failedJobs}`,
].join('\n')
const html = `
<h1>Daily digest — ${day}</h1>
<ul>
<li>Signups: <b>${stats.signups}</b></li>
<li>Paid: <b>${stats.paid}</b></li>
<li>Open incidents: <b>${stats.incidents}</b></li>
<li>Failed jobs: <b>${stats.failedJobs}</b></li>
</ul>
<p><a href="https://app.example.com/admin">Open admin</a></p>
`
return { subject, html, text, stats }
}
async function sendTransactional({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body
}
async function handleDigest(req, res) {
if (req.headers['x-cloudscheduler'] !== 'true' && process.env.REQUIRE_SCHEDULER_HEADER === '1') {
res.writeHead(403).end('scheduler only')
return
}
let payload = {}
try {
const chunks = []
for await (const c of req) chunks.push(c)
if (chunks.length) payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
payload = {}
}
const kind = payload.kind || process.env.DIGEST_KIND || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
const idempotencyKey = windowKeyFromHeaders(req.headers, kind)
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `gcp-${kind}-digest`,
day,
platform: 'gcp-cloud-scheduler',
job: String(req.headers['x-cloudscheduler-jobname'] || ''),
},
})
console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true, emailId: result.id }))
}
const server = http.createServer((req, res) => {
if (req.method === 'POST' && (req.url === '/' || req.url === '/digest')) {
handleDigest(req, res).catch((err) => {
console.error(err)
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: false, error: 'handler_failed' }))
})
return
}
res.writeHead(404).end()
})
server.listen(Number(process.env.PORT || 8080))
Rovela 的 Idempotency-Key(最长 256 字符)会让 同一 发送在 24 小时 内的重试返回原始响应,而不是再发一封(Send Email API)。在 GCP 上这很重要,因为:
- Scheduler 可能按
retryCount对失败的 HTTP 尝试做退避重试 - 同一日历窗口内的手动
gcloud scheduler jobs run可能再次进入 handler X-CloudScheduler-ScheduleTime在重试间稳定——优先用它(或日历窗口),不要用uuid()/ 请求 id
推荐键:
| 键 | 适用场景 |
|---|---|
gcp-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要(0 9 * * *) |
gcp-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 * * * *) |
gcp-{kind}:{X-CloudScheduler-ScheduleTime} | 最贴近 Scheduler 自带去重头 |
gcp-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
gcp-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发 许多 个性化摘要,按收件人循环并使用不同键(或对最多 100 条非模板消息用 POST /emails/batch——batch 会拒绝 template / topic_id(Send Email API))。控制并发;遇到 429 退避并用 同一 键重试。
Python 备选(Functions / Run)
# main.py
import json
import os
import urllib.request
from datetime import datetime, timezone
from flask import Flask, request, jsonify
ROVELA_URL = "https://api.rovela.dev/emails"
app = Flask(__name__)
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise RuntimeError(f"missing env {name}")
return v
def utc_day_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def window_key(kind: str) -> str:
schedule_time = request.headers.get("X-CloudScheduler-ScheduleTime")
if schedule_time:
stamp = schedule_time.replace(":", "-").replace(".", "-")
return f"gcp-{kind}:{stamp}"
return f"gcp-daily-digest:{utc_day_key()}"
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = "\n".join(
[
f"Signups: {stats['signups']}",
f"Paid: {stats['paid']}",
f"Open incidents: {stats['incidents']}",
f"Failed jobs: {stats['failedJobs']}",
]
)
html = f"""
<h1>Daily digest — {day}</h1>
<ul>
<li>Signups: <b>{stats['signups']}</b></li>
<li>Paid: <b>{stats['paid']}</b></li>
<li>Open incidents: <b>{stats['incidents']}</b></li>
<li>Failed jobs: <b>{stats['failedJobs']}</b></li>
</ul>
"""
return {"subject": subject, "html": html, "text": text, "stats": stats}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
@app.post("/")
@app.post("/digest")
def digest():
body = request.get_json(silent=True) or {}
kind = body.get("kind") or os.environ.get("DIGEST_KIND") or "daily"
day = utc_day_key()
digest_body = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
result = send_transactional(
to=to,
subject=digest_body["subject"],
html=digest_body["html"],
text=digest_body["text"],
idempotency_key=window_key(kind),
tags={
"kind": f"gcp-{kind}-digest",
"day": day,
"platform": "gcp-cloud-scheduler",
"job": request.headers.get("X-CloudScheduler-JobName", ""),
},
)
return jsonify({"ok": True, "emailId": result.get("id")})
发信任务:HTTP 还是 Pub/Sub
| HTTP 目标 | Pub/Sub 目标 | |
|---|---|---|
| 典型落点 | Cloud Run / HTTPS Function URL | 主题 → push/pull 订阅方 |
| 到落点的鉴权 | OIDC(Run/Functions)或 OAuth(*.googleapis.com) | Scheduler 以 Google APIs SA 发布 |
| 确认模型 | attemptDeadline 内 HTTP 2xx | Pub/Sub ack;Job 的 attemptDeadline 忽略 |
| 去重提示 | X-CloudScheduler-ScheduleTime 头 | 消息属性 / 你自己的窗口键 |
| 更适合 | 单一 handler、路径简单 | 扇出、缓冲、多消费者 |
两条路径都仍需要按窗口的 Rovela Idempotency-Key。
故障排查
| 现象 | 检查什么 |
|---|---|
Rovela 400 点名域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | Secret Manager / 环境变量里的 ROVELA_API_KEY 缺失或错误 |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| 重试后重复发信 | 键必须按窗口稳定(或用 X-CloudScheduler-ScheduleTime),不能每次 uuid() |
| Scheduler 标记 DEADLINE_EXCEEDED | 提高 attemptDeadline 和/或 Cloud Run 超时;缩小查询 |
| Cloud Run 返回 403 | Scheduler SA 缺少 roles/run.invoker;OIDC audience 不匹配 |
| 夏令时「缺」或「双」本地小时 | 挂钟行为预期——精确节拍请用 UTC |
| 代码里要投递状态 | 优先 Rovela webhooks;GET /emails/:id/events 需要 full_access,而 sending_access 只能发信 |
非 2xx 时按原样记录 Rovela 的 message 字段(Quick Start)。生产投递状态优先用 webhook,而不是轮询(Send Email API、Authentication)。
检查清单
- 已验证 Rovela 发信域名;
sending_access密钥在 Secret Manager / 环境变量中,不在 Git - 有意识地选择了 Cloud Scheduler cron 或 groc +
timeZone(精确节拍用 UTC) - HTTP Job + OIDC → Cloud Run / Functions(或 Pub/Sub → 订阅方);invoker IAM 已到位
attemptDeadline与 handler 超时覆盖查询 + Rovela 往返;理解retryCount≤ 5 意味着至少一次- 触发时实时聚合;尽可能同时设置 HTML + text
Idempotency-Key对齐调度窗口(gcp-…或 Scheduler 调度时刻头)- 用 Cloud Logging 看 handler;用 Rovela 控制台或 webhook 看投递
相关阅读
- AWS EventBridge Scheduler 事务邮件
- Netlify Scheduled Functions 事务邮件
- Fly Scheduled Machines 事务邮件
- Render Cron 事务邮件
- Railway Cron 事务邮件
- Vercel Cron 摘要邮件
- GitHub Actions 事务邮件
- 定时发送邮件
- Email API vs SMTP
- Send Email API
- Authentication
- 创建 Cloud Scheduler 作业
- Cron 格式与时区
- HTTP 目标鉴权
- 按调度运行 Cloud Run
- Cloud Run secrets
- Job REST 资源