← 返回博客

Kubernetes CronJob 事务邮件 + Rovela(2026):五段 cron、Secret 环境变量与按窗口的幂等键

把 Kubernetes CronJob(batch/v1、五段 cron + timeZone)接到 Rovela POST /emails:用 Secret/secretKeyRef 存放 sending_access,Job Pod 启动即退出,并用按窗口的 Idempotency-Key 避免重试重复发摘要。

更新于 2026年11月5日
Kubernetes CronJob 事务邮件 + Rovela(2026):五段 cron、Secret 环境变量与按窗口的幂等键

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 串起来,做到:

  1. 把 ROVELA_API_KEY、MAIL_FROM、收件人放进 Secret / ConfigMap——绝不写进镜像或公开 Git
  2. 提供会启动、发送、退出的一次性容器命令(Job 不是长期 Deployment)
  3. 按当前调度窗口聚合摘要 / 报告
  4. 经 Rovela 发送,并使用按窗口作用域的 Idempotency-Key
  5. 在 Job 重试、concurrencyPolicy 重叠、手动 kubectl create job --from=cronjob/… 时,仍不重复发信

Rovela 不会创建 CronJob、Job、Pod 或 Secret。 Kubernetes 负责 schedule → Job → Pod 启动/退出;你的容器负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分:

也不同于碰巧发信的长期 Deployment / 队列消费者——那些进程常驻。本文是 batch/v1 CronJob → Job Pod → 退出 → Rovela 这条路径。

Kubernetes CronJob 做什么,Rovela 发什么

关注点负责方
五段 cron + 可选 .spec.timeZoneCronJob(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 更清晰。

前置条件

  1. Rovela 上已验证的发信域名。未验证的 from 域名会 400(Quick Start)。
  2. 形如 re_… 的 API key。Job Pod 优先 sending_access(Authentication)。
  3. 支持 batch/v1 CronJob、且 Job Pod 可出站 HTTPS 的集群。
  4. 存放 ROVELA_API_KEY、MAIL_FROM、DIGEST_TO 的 Secret(及可选 ConfigMap)。
  5. 容器命令在成功接受(或空窗口故意 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

  1. 构建/推送镜像;应用 Secret + CronJob。
  2. 确认 Secret 只以 env 注入(用 secretKeyRef 时不会在 kubectl describe 里直接显示明文)。
  3. 等下一次触发,或从 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 401ROVELA_API_KEY 错误或缺失修正 Secret;确认 sending_access(Authentication)
Rovela 400 提到域名MAIL_FROM 域名未验证在 Rovela Domains 验证(Quick Start)
Rovela 429限流退避并用同一 Idempotency-Key 重试
CronJob 从不创建 Jobsuspend: true、非法 schedule、或时区校验失败kubectl describe cronjob;看 Events 是否有 UnknownTimeZone
重复摘要同窗口重试 / 手动 Job 没有稳定键使用按窗口的键;Idempotency-Key 在 24h 内折叠重复
Pod CrashLoop / Job 失败命令不退出、OOM、或出站被拦发完就退出;提高内存;放行 api.rovela.dev:443
Secret 出现在 Gitkey 写在 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);Job backoffLimit 有界
  • concurrencyPolicy: Forbid(或文档化 Allow + 幂等键)
  • 容器在发送后(或空窗口故意 no-op 后)退出
  • Idempotency-Key 按调度窗口作用域(日 / 小时 / 15 分钟桶)
  • kubectl logs / Job 状态显示成功;可选对失败 Job 告警
  • NetworkPolicy 允许出站到 Rovela HTTPS
  • 可选:对摘要收件人配置 Rovela email.bounced / email.complained webhook

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。