Kubernetes CronJob 事务邮件 + Rovela(2026):五段 cron、Secret 环境变量与按窗口的幂等键
把 Kubernetes CronJob(batch/v1、五段 cron + timeZone)接到 Rovela POST /emails:用 Secret/secretKeyRef 存放 sending_access,Job Pod 启动即退出,并用按窗口的 Idempotency-Key 避免重试重复发摘要。
Kubernetes CronJob 负责调度 —— Rovela 负责投递邮件
Kubernetes CronJob(batch/v1,自 v1.21 起稳定)会按重复的五段 cron 调度(分 时 日 月 星期)创建一次性 Job,并可选用 .spec.timeZone(自 v1.27 起稳定)。每个 Job 启动 Pod,容器跑到完成(restartPolicy: Never 或 OnFailure)后退出。这种「启动→退出」形态适合摘要、运维日报等正文依赖触发时刻实时状态的事务邮件——注册数、失败 Job、未付发票——而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当 Job 容器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Kubernetes 负责 CronJob 调度、Job/Pod 生命周期,以及存放 sending_access 的 Secret(或环境变量)。你的容器负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南把 Kubernetes CronJob → Rovela 串起来,做到:
- 把
ROVELA_API_KEY、MAIL_FROM、收件人放进 Secret / ConfigMap——绝不写进镜像或公开 Git - 提供会启动、发送、退出的一次性容器命令(Job 不是长期 Deployment)
- 按当前调度窗口聚合摘要 / 报告
- 经 Rovela 发送,并使用按窗口作用域的
Idempotency-Key - 在 Job 重试、
concurrencyPolicy重叠、手动kubectl create job --from=cronjob/…时,仍不重复发信
Rovela 不会创建 CronJob、Job、Pod 或 Secret。 Kubernetes 负责 schedule → Job → Pod 启动/退出;你的容器负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分:
- DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob(≥15 分钟 cron +time_zone),不是batch/v1CronJob - Heroku Scheduler Email — 免费 Scheduler add-on,粗粒度 10 分钟/每小时/每天的 one-off dyno
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB(秒字段在前)+ App Settings / Key Vault
- GCP Cloud Scheduler Email — Cloud Scheduler + HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler Email — 六段 AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions Email — Netlify UTC cron + 较短函数时限
- Fly Scheduled Machines Email — 粗粒度 Machine 间隔
- Render Cron Transactional Email — 必须退出的 Render Cron Job 进程
- Railway Cron Transactional Email — Railway cron 服务
- Vercel Cron Digest Email — 对生产路由发 HTTP
GET+CRON_SECRET - GitHub Actions Transactional Email — workflow /
run_id在 CI 事件上通知 - Scheduled Email Sending — 正文已确定时用 Rovela
scheduled_at
也不同于碰巧发信的长期 Deployment / 队列消费者——那些进程常驻。本文是 batch/v1 CronJob → Job Pod → 退出 → Rovela 这条路径。
Kubernetes CronJob 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
五段 cron + 可选 .spec.timeZone | CronJob(CronJob 概念、API) |
| 约按调度创建 Job;Pod 跑到完成 | CronJob → Job → Pod |
Secret / env 存放 ROVELA_API_KEY(sending_access) | Secret + secretKeyRef / envFrom(Secrets、注入凭证) |
| 按窗口查询 DB / API | 你的容器 |
| 渲染 HTML + text | 你的容器 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = k8s-cron-{kind}:{yyyy-mm-dd}(或小时 / 调度桶) | 你的容器 |
Rovela 返回 200 表示已接受并入队,不等于「已进收件箱」(Quick Start)。
塑造发信 Job 的 CronJob 约束
按 Kubernetes 文档,发信用的 CronJob 应把这些当作硬条件:
.spec.schedule是五段 cron(也可用@daily等宏)。例如0 13 * * *表示在 CronJob 时区内每天 13:00(schedule syntax)。.spec.timeZone(自 v1.27 稳定)填写 tz 数据库时区名,如Etc/UTC、America/New_York。未设置时跟随 kube-controller-manager 本地时区。不要在.spec.schedule里写CRON_TZ/TZ——不受支持且会被拒绝(time zones)。- Job 模板 Pod 的
restartPolicy只能是Never或OnFailure。 短 HTTPS 发送优先Never,失败由 Job 的backoffLimit起新 Pod,再用Idempotency-Key折叠重复(Jobs)。 concurrencyPolicy:Allow(默认)可并行;Forbid在上一 Job 未完成时跳过本轮;Replace取消正在跑的 Job 再开新的(concurrency policy)。摘要场景通常Forbid+ 按窗口的幂等键最稳妥。- Job 应幂等。 文档写明在某些情况下 CronJob 可能为同一调度创建两个 Job(或不创建)——发信路径必须能安全处理重复触发(Job creation)。Rovela 的
Idempotency-Key是邮件侧的一半契约。 startingDeadlineSeconds: 可选的迟到启动截止;错过计为失败。小于约 10 秒可能对不上控制器约 10 秒的检查间隔(limitations)。- 历史保留:
successfulJobsHistoryLimit(默认 3)、failedJobsHistoryLimit(默认 1)保留已完成 Job 便于排查。 - 名称长度: CronJob 名不超过 52 字符,以便控制器追加后缀后 Job 名仍 ≤ 63(CronJob)。
- 密钥只留在服务端。 优先把 Rovela
sending_access放进 Secret——绝不烤进镜像层或提交到 Git 的 ConfigMap(Authentication)。 - 出站 SMTP 不合适。 优先 Pod 内 443 上的 HTTPS
POST /emails(Email API vs SMTP)。确认 NetworkPolicy / 出站允许api.rovela.dev:443。
何时用 CronJob + POST /emails(而不是 scheduled_at)
适合 CronJob → 实时发送 的场景:
- HTML 必须在触发时带上新鲜指标
- 工作负载已在 Kubernetes 上,希望共用同一套 RBAC、Secret、
kubectl/ 指标流水线 - 五段 cron(加可选
timeZone)就够用 - 发往运维 / 创始人 / 运行时查到的客户地址的少量事务邮件
正文已经正确、只想「稍后再投」且不必起 Pod 时,用 Rovela scheduled_at(Scheduled Email Sending)。CronJob 与 scheduled_at 解决不同问题;本文是 Kubernetes 这条路径。
只有需要常驻进程(队列消费者)时才用长期 Deployment / worker。日历摘要用跑完就退出的 CronJob 更清晰。
前置条件
- Rovela 上已验证的发信域名。未验证的
from域名会400(Quick Start)。 - 形如
re_…的 API key。Job Pod 优先sending_access(Authentication)。 - 支持
batch/v1CronJob、且 Job Pod 可出站 HTTPS 的集群。 - 存放
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO的 Secret(及可选 ConfigMap)。 - 容器命令在成功接受(或空窗口故意 no-op)后以
0退出。
Manifest:Secret + CronJob
先创建 Secret(不要提交明文 key):
kubectl create secret generic rovela-sending \
--from-literal=ROVELA_API_KEY='re_your_sending_access_key' \
--dry-run=client -o yaml | kubectl apply -f -
最小 CronJob(按需改镜像、命名空间、调度):
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-digest
labels:
app: daily-digest
spec:
schedule: "0 13 * * *" # 在下方 timeZone 的 13:00
timeZone: America/New_York # 需集群支持(自 v1.27 稳定)
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 300
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app: daily-digest
spec:
restartPolicy: Never
containers:
- name: send-digest
image: ghcr.io/your-org/digest-mailer:1.0.0
imagePullPolicy: IfNotPresent
command: ["node", "bin/send-daily-digest.js"]
env:
- name: MAIL_FROM
value: "Acme Reports <reports@mail.example.com>"
- name: DIGEST_TO
value: "founders@example.com"
- name: DIGEST_KIND
value: daily
- name: ROVELA_API_KEY
valueFrom:
secretKeyRef:
name: rovela-sending
key: ROVELA_API_KEY
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
应用:
kubectl apply -f cronjob-daily-digest.yaml
kubectl get cronjob daily-digest
每 15 分钟运维心跳示例(UTC):
spec:
schedule: "*/15 * * * *"
timeZone: Etc/UTC
concurrencyPolicy: Forbid
生产发信优先 sending_access——不要用 full_access(Authentication)。不要打印 API key。轮换时:新建 Rovela key → 更新 Secret → 下一个 Job 会带上新值(Secret 注入的 env 在 Pod 启动时固定)。
一次性 Node.js 脚本(按窗口的 Idempotency-Key)
在 Job 容器里执行:
// bin/send-daily-digest.js
'use strict';
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); // YYYY-MM-DD
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}
function windowKey(kind) {
if (kind === 'hourly') return `k8s-cron-hourly-ops:${utcHourKey()}`;
if (kind === 'fifteen') {
const d = new Date();
const bucket = Math.floor(d.getUTCMinutes() / 15) * 15;
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(bucket).padStart(2, '0');
return `k8s-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
}
return `k8s-cron-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// 换成你对 Postgres / Redis / API 的查询
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; // { object: 'email', id }
}
async function main() {
const kind = process.env.DIGEST_KIND || 'daily';
const day = utcDayKey();
const digest = await buildDailyDigest(day);
const to = requireEnv('DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKey(kind);
console.log(
JSON.stringify({
kind,
day,
idempotencyKey,
// never log ROVELA_API_KEY
}),
);
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `k8s-cron-${kind}-digest`,
day,
platform: 'kubernetes-cronjob',
},
});
console.log(JSON.stringify({ ok: true, emailId: result.id }));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Rovela 的 Idempotency-Key(最长 256 字符)让同一发送在 24 小时内重试时返回原响应,而不是再发一封(Send Email API)。在 Kubernetes 上这很关键,因为:
- Job 的
backoffLimit可能在容器非 0 退出后起新 Pod - 罕见情况下 CronJob 可能为同一调度创建多个 Job(Job creation)
- 手动
kubectl create job --from=cronjob/daily-digest manual-$(date +%s)可能与定时触发重叠 concurrencyPolicy: Allow(或Replace碰上慢 Job)可能再次进入同一日历窗口
推荐键:
| 键 | 适用 |
|---|---|
k8s-cron-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
k8s-cron-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 * * * *) |
k8s-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | 每 15 分钟心跳(mm 取 00/15/30/45) |
k8s-cron-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
k8s-cron-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发大量个性化摘要,按收件人循环并换新键(或对最多 100 条非模板消息用 POST /emails/batch——batch 会拒绝 template / topic_id(Send Email API))。控制并发;遇到 429 退避并用同一键重试。保持墙钟时间短,好让 Pod 干净退出。
可选:从 Job 注解 batch.kubernetes.io/cronjob-scheduled-timestamp(RFC3339,Kubernetes v1.32 起)取控制器原定触发时间,而不是迟到 Pod 里的「现在」。
Python 替代
#!/usr/bin/env python3
# bin/send_daily_digest.py
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
ROVELA_URL = "https://api.rovela.dev/emails"
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise SystemExit(f"missing env {name}")
return v
def utc_day_key(now: datetime | None = None) -> str:
now = now or datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d")
def window_key(kind: str) -> str:
now = datetime.now(timezone.utc)
if kind == "hourly":
return f"k8s-cron-hourly-ops:{now.strftime('%Y-%m-%dT%H')}"
if kind == "fifteen":
bucket = (now.minute // 15) * 15
return f"k8s-cron-fifteen-ops:{now.strftime('%Y-%m-%dT%H')}:{bucket:02d}"
return f"k8s-cron-daily-digest:{utc_day_key(now)}"
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = (
f"Signups: {stats['signups']}\n"
f"Paid: {stats['paid']}\n"
f"Open incidents: {stats['incidents']}\n"
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>
<p><a href="https://app.example.com/admin">Open admin</a></p>
"""
return {"subject": subject, "html": html, "text": text}
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("utf-8"),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"Rovela {e.code}: {body}") from e
def main() -> None:
kind = os.environ.get("DIGEST_KIND", "daily")
day = utc_day_key()
digest = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
idem = window_key(kind)
print(json.dumps({"kind": kind, "day": day, "idempotencyKey": idem}))
result = send_transactional(
to=to,
subject=digest["subject"],
html=digest["html"],
text=digest["text"],
idempotency_key=idem,
tags={
"kind": f"k8s-cron-{kind}-digest",
"day": day,
"platform": "kubernetes-cronjob",
},
)
print(json.dumps({"ok": True, "emailId": result.get("id")}))
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
sys.exit(1)
把容器 command 设为 ["python", "bin/send_daily_digest.py"](或镜像里的解释器路径)。
挂上并验证 CronJob
- 构建/推送镜像;应用 Secret + CronJob。
- 确认 Secret 只以 env 注入(用
secretKeyRef时不会在kubectl describe里直接显示明文)。 - 等下一次触发,或从 CronJob 建一次性 Job:
kubectl create job --from=cronjob/daily-digest daily-digest-manual-1
kubectl logs job/daily-digest-manual-1 -f
kubectl get jobs -l app=daily-digest
应能看到 Pod 启动、带 idempotencyKey 的 JSON 日志、退出码 0,以及 Rovela emailId。若要对照 webhook,可保存该 id。
失败模式与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
Rovela 401 | ROVELA_API_KEY 错误或缺失 | 修正 Secret;确认 sending_access(Authentication) |
Rovela 400 提到域名 | MAIL_FROM 域名未验证 | 在 Rovela Domains 验证(Quick Start) |
Rovela 429 | 限流 | 退避并用同一 Idempotency-Key 重试 |
| CronJob 从不创建 Job | suspend: true、非法 schedule、或时区校验失败 | kubectl describe cronjob;看 Events 是否有 UnknownTimeZone |
| 重复摘要 | 同窗口重试 / 手动 Job 没有稳定键 | 使用按窗口的键;Idempotency-Key 在 24h 内折叠重复 |
| Pod CrashLoop / Job 失败 | 命令不退出、OOM、或出站被拦 | 发完就退出;提高内存;放行 api.rovela.dev:443 |
| Secret 出现在 Git | key 写在 ConfigMap 或明文 YAML | 用 Secret + secretKeyRef;可考虑 sealed-secrets / external secrets |
| 重叠发送 | concurrencyPolicy: Allow 碰上慢 Job | 优先 Forbid;缩短墙钟时间 |
生产清单
-
Rovela 发信域名已验证;
MAIL_FROM匹配该域 -
ROVELA_API_KEY为sending_access,存于 Secret,经secretKeyRef/envFrom注入 -
CronJob
batch/v1,五段schedule,在控制器支持时显式设置timeZone -
Pod
restartPolicy: Never(或OnFailure);JobbackoffLimit有界 -
concurrencyPolicy: Forbid(或文档化 Allow + 幂等键) - 容器在发送后(或空窗口故意 no-op 后)退出
-
Idempotency-Key按调度窗口作用域(日 / 小时 / 15 分钟桶) -
kubectl logs/ Job 状态显示成功;可选对失败 Job 告警 - NetworkPolicy 允许出站到 Rovela HTTPS
-
可选:对摘要收件人配置 Rovela
email.bounced/email.complainedwebhook
FAQ
能在 schedule 字符串里写 CRON_TZ=America/New_York 吗?
不能——Kubernetes 会拒绝 .spec.schedule 内的 TZ / CRON_TZ。请用 .spec.timeZone(time zones)。
该从 Job Pod 里用 Rovela scheduled_at 吗?
通常不必。若 CronJob 已在发送时刻带着实时正文触发,直接 POST /emails。只有「现在接受、稍后再投、且不必起 Pod」时才用 scheduled_at(Scheduled Email Sending)。
摘要用 CronJob、Deployment 还是普通 Job?
日历摘要用 CronJob。单次手动发送用一次性 Job。持续队列消费用 Deployment——不要拿来做「每天一封」。
sending_access 能做什么?
足以通过 POST /emails / batch 入队。读消息、改期、取消需要 full_access 或仪表盘会话(Send Email API、Authentication)。Job Pod 保持 sending_access。
200 表示创始人已经收到摘要了吗?
否——只表示 Rovela 已接受并入队。投递状态看 GET /emails/:id、events 或 webhook(Quick Start)。
为什么 Kubernetes 说 Job 必须幂等?
因为罕见情况下 CronJob 可能为同一调度创建两个 Job(或跳过一次)。发信路径——尤其是 Rovela Idempotency-Key——必须能容忍(Job creation)。
把调度交给集群,把投递交给 Rovela
Kubernetes CronJob 提供一流的五段 cron(加可选 timeZone)与短命 Job Pod。你的容器负责实时摘要与按窗口的 Idempotency-Key。Rovela 负责 HTTPS 接受、DKIM 与投递。把发送密钥放在 Secret 里、干净退出,就能在集群内发事务摘要,而不必自建 SMTP 或邮件栈。
下一步:验证域名,创建 sending_access key,应用 Secret + CronJob,部署一次性镜像,用 kubectl logs 确认第一轮 Job。