跳到主要内容

server-zd 运维

server-zd 是一种 Dex Server,运行时只依赖对象存储,例如 Amazon S3 或兼容 S3 的存储。 它把 Flow 状态保存在本地存储中,每次写入都先提交到对象存储,再向调用方确认。本地磁盘 只是缓存。节点会用对象存储中最新的快照和命令日志重建它。

server-zd 提供 FlowService 和 WorkerTaskService API,只支持 拉取模式 Worker。Dex Web 需要作为单独的进程运行, server-zd 不提供它。本地开发时运行 dexcli dev,它会启动 server-zd 和 Dex Web。本页介绍 server-zd 0.1。

不支持的功能​

server-zd 用 INVALID_ARGUMENT 拒绝以下选项:

  • Flow 启动延迟,即大于零的 flow_start_delay_seconds。由 issue 995 跟踪。
  • Flow 级重试策略,即 Flow 启动选项中的 retry_policy。由 issue 996 跟踪。Step 级重试受支持。

唯一支持的 Attribute Store 是 PostgreSQL。Redis 由 issue 616 跟踪。

安装和启动​

每个 server-zd/vX.Y.Z GitHub Release 都发布 Linux amd64、Linux arm64、 macOS amd64 和 macOS arm64 的 dex-server-zd 压缩包,并附带 checksums.txt。解压前先用它校验 压缩包。Linux 压缩包需要 glibc 2.28 或更新版本。

dex-server-zd 容器镜像支持 linux/amd64 和 linux/arm64。镜像使用配置文件 /etc/dex-server-zd/config.yaml 运行 serve。进程以 UID 10001 和 GID 10001 运行。

dex-server-zd serve --config /etc/dex-server-zd/config.yaml

serve 从一个 YAML 文件读取全部配置。 示例配置 列出了每个键及其默认值。未知的键会导致启动失败。无效的值也会导致启动失败,错误信息会 给出被拒绝字段的 YAML 路径。镜像在 /etc/dex-server-zd/dex-server-zd.example.yaml 附带这份示例,仅供参考。

最小生产配置​

cluster:
nodeId: dex-zd
objectStore:
region: us-east-1
bucket: BUCKET
prefix: PREFIX
localStorage:
directory: /var/lib/dex-server-zd/store
lease:
duration: 60s
retention:
closedFlowDuration: 7days
metrics:
listen: 0.0.0.0:9090

cluster.nodeId、objectStore.bucket 和 localStorage.directory 是必填项。 把 BUCKET 和 PREFIX 换成你的 bucket 和 key 前缀。objectStore.endpoint 留空时使用 AWS 的 endpoint 解析。使用兼容 S3 的存储时,把它设为 HTTP 或 HTTPS URL。 如果该存储要求 path-style 寻址,设置 objectStore.forcePathStyle。

节点 ID 长 1 到 63 个字符,只能包含小写字母、数字和连字符,并以字母或数字开头。

一个对象存储前缀存放一个 Dex 集群,一个集群只运行一个节点。第一次启动会记录 cluster.nodeId 和 cluster.partitionCount,之后两者都不能修改。用不同的节点 ID 启动会失败,错误信息会给出已记录的节点 ID。不要让两个部署指向同一个前缀。

Kubernetes 部署​

StatefulSet 单副本​

用副本数恰好为 1 的 StatefulSet 运行 server-zd。StatefulSet 只在旧 pod 消失后才创建 替代 pod。Deployment 可能在旧 pod 仍在运行时启动新 pod。

不要强制删除 server pod。强制删除会在 Kubernetes 确认进程停止之前,把 pod 从 API 中 移除。如果节点失联,旧进程可能与替代进程同时运行。

下面的示例用 PersistentVolumeClaim 存放本地存储,并使用名为 dex-server-zd、 包含 config.yaml 的 ConfigMap。镜像是已发布的多架构 release 镜像,支持 amd64 和 arm64。

apiVersion: v1
kind: Service
metadata:
name: dex-server-zd
spec:
selector:
app: dex-server-zd
ports:
- name: grpc
port: 8801
targetPort: grpc
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: dex-server-zd
spec:
serviceName: dex-server-zd
replicas: 1
selector:
matchLabels:
app: dex-server-zd
template:
metadata:
labels:
app: dex-server-zd
spec:
serviceAccountName: dex-server-zd
terminationGracePeriodSeconds: 30
securityContext:
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
containers:
- name: dex-server-zd
image: docker.io/superdurable/dex-server-zd:0.1.3
ports:
- name: grpc
containerPort: 8801
- name: metrics
containerPort: 9090
startupProbe:
grpc:
port: 8801
periodSeconds: 5
failureThreshold: 60
readinessProbe:
grpc:
port: 8801
periodSeconds: 5
livenessProbe:
grpc:
port: 8801
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
volumeMounts:
- name: config
mountPath: /etc/dex-server-zd/config.yaml
subPath: config.yaml
readOnly: true
- name: data
mountPath: /var/lib/dex-server-zd
volumes:
- name: config
configMap:
name: dex-server-zd
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi

优雅关闭​

把 terminationGracePeriodSeconds 设为至少 30 秒。收到 SIGTERM 后,节点在大约 14 秒内 按以下顺序停止:

  1. gRPC health 报告 NOT_SERVING。
  2. 通知 Worker 重新连接,关闭 long poll。
  3. 正在进行的调用最多有 4 秒完成。
  4. 后台工作和引擎停止。
  5. 停止续约 lease,并用条件写释放 lease。
  6. 关闭 metrics 监听器和 Stream Store。
  7. 进程退出。

每一步都完成时,进程以 0 退出,使用相同 cluster.nodeId 的替代进程会立即获得 lease。 如果某一步没有按时完成或者失败,例如对象存储拒绝释放 lease,进程以 1 退出。替代进程这时 最多要等 lease.duration 再加约 1 秒。

启动期间收到 SIGTERM 会中断 lease 获取或恢复。如果节点已经持有 lease,它会在关闭预算内 停止启动并释放 lease。释放失败时进程以 1 退出,下次启动可能要等待旧 lease 过期。如果 SIGTERM 到达时某次 lease 写入的结果仍然未知,进程以 0 退出,不会释放这次写入,下次启动 可能要等待它过期。

本地存储卷​

本地存储是缓存,因为每次已确认的写入都已经在对象存储中。卷的类型只影响重启耗时。

使用 emptyDir 时,新 pod 会恢复最新的快照,再重放快照之后写入的日志。emptyDir 在同一个 pod 内的容器重启后仍然保留,但不会带到新 pod。使用 PersistentVolumeClaim 时,新 pod 会复用本地存储,只重放停止之后写入的日志。无论哪种方式,重启后的第一个快照都会把完整的 本地 checkpoint 上传到对象存储。

把卷挂载到一个父目录,例如 /var/lib/dex-server-zd,再把 localStorage.directory 设为其子目录,例如 /var/lib/dex-server-zd/store。节点无法复用现有本地存储时, 会把该目录重命名为带 .discarded- 后缀的同级目录,然后重建。打开存储时 RocksDB 报告 存储损坏、缺少文件或格式不兼容,或者存储无法用保留的日志更新时,就会这样处理。如果该目录 就是挂载点,重命名会失败。

导致节点无法打开存储的其他错误会让启动失败,并把目录原样保留。例如另一个进程正打开着 这个存储,或者权限错误。停止另一个进程或修正权限后再重启。如果存储本身无法读取,例如发生 磁盘错误,请停止节点,移走或删除存储目录,然后重启。节点随后会从对象存储重建存储。

比对象存储中的元数据更新的存储也会被移到一旁,节点会记录一条 error 日志。这个副本可能是 元数据发生回退的唯一本地证据。

每次启动成功后,节点会删除较旧的被移到一旁的目录。它只保留最新的 localStorage.retainedSetAsideStoreCount 个用于诊断,默认是 1。本次启动移到一旁的副本 总是算作最新的。设为 0 表示一个都不保留。节点这时会立即删除所有副本,包括比元数据更新的 存储的副本。节点会在日志中记录每个被删除的路径。删除失败只会记录一条警告,不会让启动失败。 节点只删除它为这个存储移到一旁的目录。另一个进程持有当前存储的锁时,节点从不把它移到一旁, 也从不删除它。节点不会再打开被移到一旁的副本。清理时不会检查是否有其他工具正打开着某个 被移到一旁的副本。

server-zd v0.1.2 及更早的版本从不删除被移到一旁的目录。升级后第一次成功启动时,只会保留最新 的副本。如果想保留更早的副本,请在升级前调大这个数量,或者把它们移出父目录。这些版本在任何 打开错误之后都会把存储移到一旁,之后的版本只在上面列出的情况下这样做。

卷的容量应能容纳当前状态,加上 localStorage.maxWalBytes 的 write-ahead log (默认 256 MiB),再加上大批量重放后尚未压缩的文件。重放后节点会压缩存储,目录会回到 接近当前状态的大小。

还要为保留的移到一旁的副本预留空间。每个副本最大可能等于节点把它移到一旁时的存储大小。 启动成功后,卷上最多保留 localStorage.retainedSetAsideStoreCount 个副本。一次把存储 移到一旁的启动,在成功之前会多占一个副本。把存储移到一旁之后失败的启动,例如反复崩溃重启, 每次都会再增加一个副本,直到某次启动成功。使用默认值 1 时,要按重建后的存储旁边至少有两个 旧副本来规划。

如果想用 emptyDir 替代 claim 模板,把 volumeClaimTemplates 换成下面的卷:

      volumes:
- name: data
emptyDir:
sizeLimit: 20Gi

健康检查​

8801 端口在 api.listen 上提供标准的 gRPC health 服务。启动期间,节点等待 lease 和 重放日志时,health 报告 NOT_SERVING。恢复完成后报告 SERVING。

三种 probe 都使用 gRPC health:

  • startupProbe 在第一次 SERVING 之前挡住 liveness probe。failureThreshold × periodSeconds 至少要等于 lease.duration + 1 s + 最坏情况下的恢复时间。lease 这一部分用于应对上一个 owner 没有释放 lease 的情况。恢复时间随当前状态和日志尾部增长。 空存储的冷恢复用时约 4 s,有真实数据时要预留更多。示例允许 5 分钟。
  • readinessProbe 在节点不提供服务时挡住流量。
  • livenessProbe 重启不再响应的节点。不要用 TCP probe 做 liveness:卡住的进程仍可能通过 内核的连接队列接受 TCP 连接,却不提供任何服务。

正在服务的节点不会在不退出的情况下回到 NOT_SERVING,因为丢失 lease 会结束进程。所以在 gRPC health 上做 liveness 不会造成反复重启。

这组 probe 适用于单副本,这是目前支持的拓扑。standby 副本在接管之前一直是 NOT_SERVING,永远过不了这个 startup probe。standby 需要改用 TCP 或 exec 的 startup probe 和 liveness probe。

发生致命错误后,例如 lease 丢失或提交失败,进程会自行退出。启动期间,对象存储读取遇到 暂时性错误或结果未知的错误时,节点会按带抖动的指数退避重试。 recovery.startupReadRetryBudget 为每次读取设置从首次暂时性错误或结果未知的错误开始计算的实际时间窗口, 默认 30 秒,且必须大于零。退避等待和后续读取都计入该次读取的窗口。并发读取不共享时间, 之后的独立读取会获得新的窗口。窗口到期时,错误会指出操作、对象键和最后一次错误。 必需对象缺失、数据损坏、访问被拒绝或 lease 所有权丢失时,启动不会重试读取。 lease 有效期结束或关闭请求可能更早终止重试。恢复期间重试读取时,lease 仍会续约。 该窗口限制的是每次读取,而不是整个启动过程。获得 lease 之后,启动没有总体时间上限。 持续的故障仍会在一次读取的窗口内使启动失败;读取较慢但仍有进展时,续约会让 lease 保持有效。 startup probe 限制启动的总时长。 如果启动时无法访问对象存储,条件写入自检也会使进程以 1 退出。启动失败时进程以 1 退出, Kubernetes 随后按退避策略重启容器。

Lease​

节点在对象存储中持有一个 lease 对象。lease.duration 默认是 10 秒。节点在时长的 三分之一处续约。

dex-server-zd dev 把 lease.duration 默认设为 30 秒,除非它的覆盖配置文件设置了 这个值。dev 节点没有备用节点,所以更长的 lease 不会增加故障切换时间。它还能让节点撑过更长 的主机停顿。dev 节点对数据目录中的 dev.lock 持有排他锁,所以同一目录上的第二个 dev 进程会启动失败。持有这个锁期间,仍被持有的 lease 一定属于已经停止的进程。dev 节点随后会立即 接管 lease 并提升 epoch,所以重启不需要等待,崩溃之后也一样。在不支持文件锁的文件系统上,dev 节点会记录一条警告,并等待 lease 过期,最多 31 秒。默认的 dev.durabilityMode 是 processCrashSafe,它在这类崩溃或 kill 之后保留每个已确认的写入,但不能承受主机崩溃或 断电。strict 还能承受主机崩溃。在 relaxed 模式下异常退出之后,dev 节点会拒绝启动, 直到你删除它的数据目录。参见本地开发。

数据安全不依赖这个时长。每次获得 lease 都会提升节点 epoch,每次日志写入都是条件写。 两者一起把丢失 lease 的进程隔离在外。

时长影响崩溃后的接管时间。优雅关闭会释放 lease,替代进程可以立即启动。崩溃、SIGKILL 或 Kubernetes 节点丢失之后,替代进程要等旧 lease 过期,再多等 1 秒。这段等待最多是 lease.duration 加 1 秒,之后才开始恢复。

时长对对象存储故障只有部分影响:

  • 一次续约尝试因暂时性错误失败时(例如 5xx 或限流响应),节点会在退避后重试。
  • 只要后面还能再容纳一次尝试,每次尝试都会在时长的六分之一(1 到 5 秒之间)后被中断。 最后一次尝试可以用完剩余的全部时间,所以 lease 较长时,它可能超过 5 秒。
  • 只有下一次退避能在续约截止时间(当前 lease 期限结束前 100 毫秒)之前结束,重试才会继续。 没有重试的余地,或者最后一次尝试失败时,节点停止。
  • 被中断的续约如果实际已经写入,并且后面的某次尝试找到了它,它仍然有效。后面的尝试在自己的 写入失败后读取 lease,从而找到它。节点只在续约截止时间之前采用这次写入。
  • 续约输给另一个持有者,或者遇到永久性存储错误时,节点立即停止。
  • 对象存储在 10 秒内没有接受一次日志写入时,节点也会停止。这个限制是固定的,与 lease 时长无关。

因此,较长的 lease 能让空闲节点撑过更长时间的对象存储停滞或主机停顿。以上任一情况停止后, 进程都以 1 退出。

单副本部署建议把 lease.duration 设为 60 秒。这时续约每小时大约发出 180 次 PUT 请求,10 秒时约为 1,080 次。时长最好不超过约 120 秒。更长的 lease 省下的请求很少, 崩溃后的不可用时间却会继续增长。

默认情况下,如果另一个进程持有 lease,启动中的节点会等待。把 lease.failIfHeld 设为 true 可以改为直接启动失败。

对象存储​

凭证​

objectStore.credentialsSource 选择凭证:

值凭证
defaultChain(默认)AWS SDK 默认的凭证提供链
environmentDEX_ZD_OBJECT_STORE_ACCESS_KEY_ID、DEX_ZD_OBJECT_STORE_SECRET_ACCESS_KEY,以及可选的 DEX_ZD_OBJECT_STORE_SESSION_TOKEN
anonymous不签名的请求,用于本地模拟器或允许匿名访问的 bucket

在 Amazon EKS 上,保持 defaultChain,并给 pod 的 service account 一个 IAM role, 例如通过 IAM Roles for Service Accounts 或 EKS Pod Identity。

只限前缀的 IAM 策略​

共享 bucket 时,设置非空的 objectStore.prefix,然后只给 server 的 role 授予下面的 操作。把 BUCKET 和 PREFIX/ 换成 bucket 和配置的前缀,并保留末尾的斜杠。

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::BUCKET",
"Condition": { "StringLike": { "s3:prefix": "PREFIX/*" } }
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::BUCKET/PREFIX/*"
}
]
}

server 只在自己的前缀下列举对象,检查对象是否存在时也是如此。有了这个授权,AWS S3 对 不存在的对象返回 404。更窄的授权可能返回 403。只要允许带前缀的列举,server 能处理这 两种响应。

s3:ListBucket 的条件必须使用 StringLike,并带上上面示例中末尾的通配符。不支持 精确匹配前缀的 StringEquals 授权,也不支持没有 s3:ListBucket 的策略。前缀非空时, 启动会先在前缀下列举。列举被拒绝时,启动失败,错误信息以下面的文字开头:

startup requires s3:ListBucket with StringLike s3:prefix "PREFIX/*"

启动时还会在前缀下写入并删除一个探测对象,用来确认条件写可用。不需要整个 bucket 的 访问权限,也不需要任何 bucket 管理操作。

硬删除 Attribute 的值默认使用主 bucket 和前缀。如果要把它们放在别处,为 objectStore.hardDeleteStore 单独配置 endpoint、region、bucket、 prefix 和 credentialsSource。它的 environment 凭证使用后缀相同的 DEX_ZD_HARD_DELETE_STORE_ 变量。对那个 bucket 和前缀授予同样的策略。

加密​

server-zd 不发送服务端加密的请求头,因此使用 bucket 的默认加密。默认的 SSE-KMS 可以 正常工作。条件写使用 If-None-Match 和 If-Match,server 把 ETag 当作不透明的值, 不要求 ETag 是 MD5 摘要。

使用客户管理的 KMS key 时,给 role 授予该 key 的 kms:GenerateDataKey 和 kms:Decrypt,并在 key policy 中也允许这两个操作。

版本控制和生命周期规则​

不开版本控制的 bucket 可以正常使用。server-zd 不使用对象版本。

如果 bucket 开启了版本控制,server 删除的每个对象都会留下一个非当前版本,包括旧的日志 对象和快照,存储只增不减。添加一条让非当前版本过期的生命周期规则。硬删除 Attribute 值 的非当前版本也会保留已删除的值,直到该版本过期。

无论是否开启版本控制,都要添加一条在 1 到 7 天后中止未完成 multipart upload 的规则。 崩溃可能留下未完成的上传,而只限前缀的 role 无法列举或清理它们。

下面的生命周期配置包含这两条规则。不开版本控制的 bucket 去掉第二条。

{
"Rules": [
{
"ID": "dex-server-zd-abort-incomplete-uploads",
"Status": "Enabled",
"Filter": { "Prefix": "PREFIX/" },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 2 }
},
{
"ID": "dex-server-zd-expire-noncurrent-versions",
"Status": "Enabled",
"Filter": { "Prefix": "PREFIX/" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 7 },
"Expiration": { "ExpiredObjectDeleteMarker": true }
}
]
}

保留期​

retention.closedFlowDuration 设置已关闭的 Flow run 保留多久,默认 7 天。超过这段 时间后,run 及其保留的数据可以被删除。值写成时长,例如 72h 或 30days。

每个 run 在启动时记录这个值。新值只对节点用它重启之后启动的 run 生效。已经启动的 run 保留自己记录的时长。

从 server-zd v0.1.8 起,对已关闭 run 调用事务型或加锁的 RPC,会在 Worker 执行处理函数前返回 NOT_FOUND 和 FLOW_NOT_EXISTS。使用已记录的请求 ID 重试时,会返回先前的结果。其他 RPC 仍会在尚未清除的 已关闭 run 上执行处理函数,因此可以读取该 run。处理函数返回的任何写入都会被 server-zd 拒绝。

History 窗口​

server-zd 为每个 run 保留最近的一段 history 窗口。窗口包含 FlowStart 事件和大约最新的 10,000 个 semantic event。更早的事件按每批 1,000 个清理。为 time travel 保留的被替换值超过 256 MiB 时,窗口也会缩小。

History 读取(例如 dexcli flow history --all)只返回保留的事件。 Time travel 只能以窗口内的点为目标。Time travel 到开头总是可用。

每个 history 页面还会报告窗口清理掉的事件 ID 范围。Step method 事件会记录启动其 Step execution 的事件 ID。Execute 事件还会记录该 Step 的 WaitFor 完成事件 ID。客户端可以用这些 ID 区分已被清理的关联事件和位于其他页面的关联事件。

Key默认值含义
history.windowSemanticEvents10000窗口保留的最新 semantic event 的大致数量。窗口超过这个值加一个清理批次时才开始清理。一个 semantic event 可能显示为多行 CLI history。
history.pruneChunkSize1000每次清理的旧 semantic event 数。不得超过 windowSemanticEvents。
history.maxRetainedSupersededBytes268435456(256 MiB)窗口内为 time travel 保留的被替换值的最大字节数。

每个 run 在启动时记录这些值。新值只对节点用它重启之后启动的 run 生效。

限制​

server-zd 执行以下限制。被限制拒绝的请求会立即失败,不会等待容量。请求 ID 限制从不拒绝请求,而是淘汰最早的请求 ID。

Key默认值含义
values.maxValueBytes16777216(16 MiB)单个应用值序列化后的最大大小,例如 Attribute 值或 Channel 消息。更大的值会被拒绝。
api.grpcMaxMessageBytes33554432(32 MiB)收发的单条 gRPC 消息的最大大小。必须大于 values.maxValueBytes。
limits.maxFlowStateBytes67108864(64 MiB)一个 Flow run 的存储预算。它计入当前状态、Attribute 和 Channel 的值,以及为 time travel 保留的被替换值。server-zd 只对 Flow 启动、Client Attribute 写入、Client Channel 发布、Step heartbeat 和 Step 完成检查它,并且只在这条命令增加 run 的当前存储、而 run 的总存储超出预算时检查。这样的 Flow 启动或 Client 写入以 RESOURCE_EXHAUSTED 失败。这样的 Step heartbeat 或完成会让 attempt 失败,不会重试。其他写入(例如事务型 RPC 写入)不受检查,所以这个预算并不限制 run 的全部状态。
limits.maxFlowIdBytes1000Flow ID 的最大长度,以 UTF-8 字节计。
limits.maxRequestIdsPerRun1000一个 run 为去重保留的最新请求 ID 数。使用更早请求 ID 的重试会作为新请求执行。
limits.maxLongPollWaitersPerFlow1000一个 Flow 上的最大并发 wait 数,例如 WaitForStepCompletion 和 WaitForAttributeMatch 调用。超出的 wait 以 RESOURCE_EXHAUSTED 失败。
limits.maxLongPollWaitersPerNode100000节点上的最大并发 wait 数。超出的 wait 以 RESOURCE_EXHAUSTED 失败。
limits.maxInFlightRpcsPerNode1000节点上同时进行的共享事务型或加锁 RPC 任务的最大数量。超出时,新的请求 ID 以 RESOURCE_EXHAUSTED 失败。

每个 run 在启动时记录 values.maxValueBytes、limits.maxFlowStateBytes 和 limits.maxRequestIdsPerRun。新值只对节点用它重启之后启动的 run 生效。

性能调优​

默认值适合大多数节点。只有负载测试显示出瓶颈时才修改这些 key。它们改变吞吐量和内存用量,不改变 Flow 的结果。例外是 commit.maxUnappliedBytes:它限制已接受命令的积压量,所以值越小,负载下以 UNAVAILABLE 失败的请求越多。每个 key 在节点启动时生效。

Key默认值含义
commit.applierLanes8 与进程可用 CPU 数中的较大者,最多为 cluster.partitionCount 和 1024并行把已提交命令应用到本地存储的 lane 数。同一个 partition 总是使用同一个 lane,所以它的命令按顺序应用。应用命令需要读写本地存储,所以即使是小主机也至少使用 8 个 lane。显式设置的值必须在 1 到 cluster.partitionCount 与 1024 中的较小者之间。无法读取 CPU 数时,默认值为 8。
commit.applierQueueFrames64每个 lane 排队的已提交 partition 批次数。lane 的队列满时,提交路径会等待它。
commit.maxBatchRecords10000一个日志批次中的最大命令数。批次达到这个数量、commit.sealAtBytes 或 commit.batchWindow 时封口。
commit.maxUnappliedBytes67108864(64 MiB)节点已接受但尚未在本地应用的命令的编码字节数。放不下的命令以 UNAVAILABLE 失败。必须至少为 api.grpcMaxMessageBytes 与 32 MiB 中的较小者,这样最大的命令也能放下。
localStorage.writeBufferBytes67108864(64 MiB)每个 column family 的单个 RocksDB memtable 大小。RocksDB 为六个 column family 各保留最多两个 memtable,所以 memtable 最多使用这个值的 12 倍(默认 768 MiB),另加 localStorage.blockCacheBytes。write-ahead log 达到 localStorage.maxWalBytes 时会强制 flush,所以要和这个值一起调大。取值范围为 64 KiB 到 localStorage.maxWalBytes。
localStorage.maxBackgroundJobs进程可用 CPU 数的一半,取值范围为 2 到 1024同时运行的 RocksDB flush 和 compaction 的最大数量。RocksDB 为每个 job 启动一个线程。显式设置的值必须在 1 到 1024 之间。
localStorage.maxSubcompactions1一次 RocksDB compaction 可以拆分成的线程数。更大的值可以减少持续写入时的写入停顿。

节点在启动时会记录解析后的 applierLanes 和 maxBackgroundJobs 值。

Attribute Store 同步​

server-zd v0.1.3 或更高版本可以把 Attribute 同步到 PostgreSQL。表契约见 自定义 Attribute Store。Flow 用 syncToAttributeStore 标记 Attribute,并在 FlowConfig 中列出一个已配置的 store 名称。server-zd 随后把该 Attribute 写入这个 store 的表。

行为​

  • 只支持 type: postgres。其他 store 类型无法通过校验,因此启动会失败。
  • Step、RPC 和 SetAttributes 的提交从不等待表写入。
  • 行通常在提交后一秒内出现。应把这张表视为最终一致。
  • store 不可用时,Flow 继续运行。store 恢复后,server-zd 用最新的值写入积压的数据。
  • 首次标记后经过 syncRetryPolicy.totalDuration 仍未写入的 entry 会被丢弃。之后对同一 Attribute 的写入照常同步。
  • Time travel 之后,server-zd 会用 time travel 之后的状态重写该 run 同步过的每一行。
  • 每个数据库都需要一张 fencing 表。
  • server-zd v0.1.3 关闭 Flow 时不等待同步的 Attribute,行可能在 Flow 完成之后才写入。
  • server-zd v0.1.4 或更高版本会等待。Step 决策使 Flow 完成或失败、Step 重试耗尽或 Flow 超时时,Step 立即停止,但 run 保持 RUNNING,直到所有待写入的值都写完或被丢弃。 WaitForFlow 到那时才返回。在等待结束前已写入的 entry 对应的行已在表中。被丢弃的 entry 不能保证该行的值,因为同一 run 之前的写入可能已经提交。如果 dexzd_attribute_sync_dropped_entries_total 增加,请将受影响的行与 Flow 的 Attribute 对比,并重新写入这些 Attribute。
  • 关闭等待期间,SetAttributes、PublishToChannel 以及既非事务性也不带锁的 RPC 照常接受。它们同步的写入会延长等待, 且不会启动新的 Step。等待期间发起的事务性或带锁的 RPC、WaitForStepCompletion、WaitForAttribute 和 DeleteChannelMessage 调用返回 FAILED_PRECONDITION。
  • 在 server-zd v0.1.4 或更高版本中,自然关闭还会等待在它之前已被接纳的所有事务性或带锁的 RPC, 即使没有配置 Attribute Store 也是如此。这类 RPC 仍会提交,它同步的写入也会加入等待。 这些 RPC 提交、失败或超时之后,关闭才会被记录。
  • syncRetryPolicy.totalDuration 限制的是每个待写入值的重试时长,而不是整个关闭。PostgreSQL 故障期间,关闭会一直等到待写入的值被丢弃。关闭等待期间接受的每个同步写入都会开始自己的重试窗口,从而延长等待。
  • StopFlow 和 Terminate 从不等待。StopFlow 一提交,run 就会关闭,即使关闭正在等待也是如此; 待写入的值在关闭后继续同步。之后对该 run 的 SetAttributes 返回 NOT_FOUND。从 server-zd v0.1.5 起, 只有请求中的每项写入都是把当前存储的 hard-delete Attribute 设为 null 时,才会被接受。 此例外仅适用于尚未清除的 run。该操作会删除存储的对象,但不会重新打开 run。run 的 Flow history 会在关闭之后记录这次擦除。Flow 需要的数据请在停止之前写入。
  • 已不在配置中的 store 的待写入值会被立即丢弃,并计入 dexzd_attribute_sync_dropped_entries_total。

配置​

attributeStore:
stores:
orders:
type: postgres
dsn: ""
tableName: public.orders
rowKey: order-{order_id}
columnNaming: snake_case
attributeMaps:
Items:
tableName: public.order_items
instanceKeyColumn: item_key
fencingTableName: dexzd_attribute_sync_fencing
schemaSyncInterval: 1m
syncBatchSize: 100
syncAttemptTimeout: 30s
syncRetryPolicy:
initialInterval: 100ms
backoffCoefficient: 2.0
maximumInterval: 30s
maximumAttempts: 0
totalDuration: 1h

时长、syncBatchSize 或重试策略字段 显式设为零时,表示使用默认值。 示例配置 带注释地列出了这一节。

键默认值说明
stores空把 FlowConfig 可以列出的每个 store 名称映射到一个 PostgreSQL 目标。为空时关闭同步,并拒绝 FlowConfig 列出的每个 store 名称。
stores.NAME.type无必须是 postgres。
stores.NAME.dsn空PostgreSQL 连接字符串。为空时,server-zd 改为读取环境变量。日志会隐去它。
stores.NAME.tableName无主表,写成 table 或 schema.table。
stores.NAME.rowKey、columnNaming 和 attributeMaps空见自定义 Attribute Store。
stores.NAME.fencingTableNamedexzd_attribute_sync_fencing只存在于 server-zd。见 Fencing 表。
schemaSyncInterval1m两次表 schema 刷新之间的时间,带 90–110% 的 jitter。写入未知的列会立即刷新 schema。
syncBatchSize100一个事务最多写入一个 run 的多少个 Attribute 和 AttributeMap instance。待写入的值更多时,该 run 使用多个事务。
syncAttemptTimeout30s一个写事务的时长上限。见写入超时。
syncRetryPolicy.initialInterval100ms第一次写入失败后的等待时间。
syncRetryPolicy.backoffCoefficient2每次写入失败后,等待时间乘以这个系数。必须是有限的正数。
syncRetryPolicy.maximumInterval30s两次尝试之间的最长等待时间。不能小于 initialInterval。
syncRetryPolicy.maximumAttempts0,不限一个 run 的 entry 连续写入失败达到这个次数后,这些 entry 会被丢弃。计数保存在内存中,重启后重新计数。
syncRetryPolicy.totalDuration1h首次标记后经过这段时间仍未写入的 entry 会被丢弃。

把 dsn 留空,可以让连接字符串不出现在配置文件中。server-zd 这时读取 DEX_ZD_ATTRIBUTE_STORE_DSN_ 加上大写的 store 名称。ASCII 字母和数字以外的每个字符都会 换成下划线。例如 store orders 对应的变量是 DEX_ZD_ATTRIBUTE_STORE_DSN_ORDERS。 两处都没有 DSN 的 store 无法通过校验。

Fencing 表​

每个写事务都会把一行 fencing token 提升到节点的 epoch。因此,丢失 lease 的节点 无法覆盖更新的值。

每个数据库都需要这张表,表名由 fencingTableName 指定。一张表可以服务数据库中的所有 store 和所有集群。server-zd 从不创建或修改表。启动前,以表 owner 的身份创建这张表,然后把 它的 SELECT、INSERT 和 UPDATE 权限授予 server-zd 连接时使用的 role。把 dex 换成那个 role。

CREATE TABLE dexzd_attribute_sync_fencing (
store_name text NOT NULL,
cluster_id text NOT NULL,
scope text NOT NULL,
fencing_token bigint NOT NULL,
updated_at timestamptz NOT NULL,
PRIMARY KEY (store_name, cluster_id, scope)
);
GRANT SELECT, INSERT, UPDATE ON dexzd_attribute_sync_fencing TO dex;

已经永久停止的集群,其行可以删除。不要删除正在运行的集群的行。

启动时会连接每个 store 并检查它的表。任何失败都会导致启动失败,包括 store 不可达和缺少 fencing 表。缺少 fencing 表时,错误信息会给出 CREATE TABLE 语句。 启动时不检查授权。缺少授权时,fencing 表会显示为不存在,或者每次写入都因权限错误失败。

写入超时​

一个写事务最多持续 syncAttemptTimeout 和 lease.duration 的四分之一中较小的那个。 lease.duration 的四分之一同时也是 lease margin。只有剩余的 lease 有效期大于事务超时 加 margin 时,才会开始一次写入。PostgreSQL 的 statement_timeout 等于事务超时。因此, 在截止时间前一刻发出的 COMMIT 仍会在 lease 结束前完成。

事务超时加 margin 最多是 lease.duration 的一半,少于开始续约时剩余的三分之二有效期, 所以写入很少需要等待续约。默认的 10 秒 lease 下,一个事务最多持续 2.5 秒。60 秒 lease 下,最多持续 15 秒。

重试和丢弃的 entry​

失败的写入会按照 syncRetryPolicy 的 backoff 重试,直到 totalDuration,。失败的类型决定哪些内容进入 backoff:

  • Store。 连接失败或整个 store 的其他失败,会让该 store 的每个 run 都进入 backoff。
  • Run。 由写入的值或与其他会话的冲突导致的失败只让该 run 进入 backoff,其他 Flow 继续同步。 例如违反约束、值超出列的长度、死锁和序列化失败。
  • 超时。 事务超过事务超时(例如等待行锁时),同样只让该 run 进入 backoff。

超时之后,该 run 的重试会把每个事务的语句数减半,最少一条。超时之后,一条语句在同一个事务中写入 该 run 的所有普通 Attribute,所以主行不会只更新一部分。每个 AttributeMap instance 是一条语句,所以超时 run 的各个 instance 可能落在不同的事务中。每个提交成功的 事务都会被确认,因此重试只写入剩下的部分。

同一个 store 有三个 run 超时,且其间没有任何提交时,整个 store 进入 backoff。store backoff 之后的重试会先处理失败的那个 run 以外的其他 run。同一 Flow ID 的较新 run 会等待较旧的 run, 因此行的写入保持 run 的顺序。

首次标记后经过 syncRetryPolicy.totalDuration 仍未写入的 entry 会被丢弃,并输出 error log。之后对同一 Attribute 的写入照常同步。dexzd_attribute_sync_dropped_entries_total 统计被丢弃的 entry,也统计被 store 以不可重试错误拒绝的 entry。

监控​

指标含义
dexzd_attribute_sync_backlog_entries截至上一次扫描,等待写入的 entry 数
dexzd_attribute_sync_oldest_pending_age_seconds最早的待写入 entry 的重试窗口已持续的时间,没有时为 0
dexzd_attribute_sync_write_failures_total,带 failure_kind失败的写事务。failure_kind 取 retryable 或 non_retryable。不可重试的失败会丢弃其 entry。
dexzd_attribute_sync_dropped_entries_total因重试策略耗尽或被 store 拒绝而丢弃的 entry
dexzd_attribute_sync_fenced_writes_total因更新的 owner 提升了 fencing token 而被拒绝的写入
# Dropped Attribute Sync entries
increase(dexzd_attribute_sync_dropped_entries_total[10m]) > 0

# Attribute Sync entries pending for more than 5 minutes
dexzd_attribute_sync_oldest_pending_age_seconds > 300

本地开发​

dex-server-zd dev 在本地运行一个使用内嵌对象存储的节点。它的 --attribute-store-config 参数只读取 YAML 文件中的 attributeStore 一节。 这一节至少要定义一个 store。--config 覆盖文件不能同时设置 attributeStore。

dex-server-zd dev --data-dir ./zd-data --attribute-store-config ./attribute-store.yaml

数据库仍然需要这些表和 fencing 表。dexcli dev --attribute-store-config 会把文件传给这个参数,需要 dexcli v1.19.0 或更高版本。

指标​

设置 metrics.listen 后,节点会在独立于 api.listen 的监听器上提供明文 HTTP GET /metrics。默认值为空,表示关闭。这个 endpoint 没有身份验证,不要公开暴露。 在 Kubernetes 中,把它绑定到 0.0.0.0:9090,并用 NetworkPolicy 限制访问。

响应使用 OpenMetrics 文本格式。节点恢复期间或等待 lease 时,指标同样可用。每个 label 的取值都来自一个封闭集合。没有任何 series 带 Flow、Flow type、partition、tenant 或 key label。

关注点Metric family 和 label
API 请求和错误dexzd_api_requests_total,带 rpc_class 和 code
API 延迟dexzd_api_request_latency_seconds,带 rpc_class;只统计 unary 调用
Step 失败dexzd_step_attempt_failures_total,带 durability 和 failure_kind;dexzd_step_retries_exhausted_total,带 durability
Flow 关闭dexzd_flow_closes_total,带 close_status;dexzd_flow_end_to_end_latency_seconds
派发延迟dexzd_step_schedule_to_start_latency_seconds
Workerdexzd_worker_stream_events_total,带 event;dexzd_matching_worker_streams;dexzd_matching_queued_tasks
提交延迟dexzd_commit_latency_seconds
对象存储dexzd_object_store_requests_total,带 operation 和 object_kind;dexzd_object_store_request_errors_total,带 operation
Lease 和节点状态dexzd_node_role,带 role;dexzd_lease_epoch;dexzd_lease_renewals_total,带 outcome
恢复dexzd_recovery_last_duration_seconds;dexzd_recovery_last_replayed_bytes
Timerdexzd_timer_lag_seconds
快照dexzd_snapshots_total,带 outcome;dexzd_snapshot_last_duration_seconds
本地磁盘dexzd_local_storage_bytes
Attribute Store 同步见监控

rpc_class 取 flow_write、flow_read、search、worker_poll 或 worker_respond。code 是 snake case 形式的 gRPC 状态码,例如 ok 或 unavailable。role 取 starting、standby、recovering、serving 或 draining。

Worker 通过一个持久的 stream 轮询,因此没有空轮询计数。 dexzd_worker_stream_events_total 统计每次任务投递和每次 stream 关闭。long poll 的 等待时间计入 flow_read 延迟。

# Failed API calls by class and code
sum by (rpc_class, code) (rate(dexzd_api_requests_total{code!="ok"}[5m]))

# A node that is not serving
dexzd_node_role{role="serving"} == 0

# Failed lease renewals
increase(dexzd_lease_renewals_total{outcome="error"}[10m]) > 0

完整目录,包括类型、histogram bucket 和 Linux 进程指标,见 server-zd README。

Pull Worker 分发​

Worker 的搭建和容量规划见拉取模式 Worker。 server-zd 以单节点运行,因此自己拥有所有 Worker 队列。Worker 和 Client 连接到 api.listen, 默认是 0.0.0.0:8801。

matching:
dispatchWait: 10s
stickyWait: 5s
maxConcurrentTasksCap: 256
maxStreams: 100000
maxQueueLength: 256
maxFlowTypes: 256
maxFlowTypeLength: 256
pingInterval: 20s
键默认值含义
matching.dispatchWait10s为一次 attempt 寻找 Worker 的总时间。
matching.stickyWait5s回退到 Flow type 队列之前,尝试已连接 sticky Worker 的时间。不得超过 dispatchWait。没有连接上一个 app Worker ID 的 stream 时,server-zd 立即使用 Flow type 队列。
matching.maxConcurrentTasksCap256节点上一个 app Worker ID 最多可拥有的 stream 数。
matching.maxStreams100000节点上已注册 Worker stream 的上限。
matching.maxQueueLength256队列名称的最大字符数。
matching.maxFlowTypes256一次 Worker 注册中 Flow type 的最大数量。
matching.maxFlowTypeLength256已注册 Flow type 的最大字符数。
matching.pingInterval20s空闲 Worker stream 上 Ping 帧的间隔。

如果在 dispatchWait 内没有可用的 Worker,Step 会收到可重试的 unavailable 错误,并消耗一次 Step 重试。RPC 调用方会直接收到 Unavailable 错误。

当前 SDK 在任务运行期间每 20 秒发送一次上行 Pong 帧。请为长连接 HTTP/2 stream 配置负载均衡器和 代理,并把它们的空闲超时设置得高于 20 秒和 matching.pingInterval,同时留出网络延迟的余量。 使用旧版 SDK 的 Worker 需要高于最长任务时长的空闲超时。请在公共入口保持双向 streaming。

Stream​

用 streamStore.backend 控制 best-effort Stream,取值为 disabled(默认) 或 memory。memory Stream 存在于 server 进程中,重启后为空。backend 为 disabled 时,写入 Stream 的 消息会被丢弃,但 Step 不会失败。

身份验证​

公共 API 身份验证默认关闭。配置 auth.authenticators 后,每个 FlowService 和 WorkerTaskService 调用都需要 bearer 凭证。gRPC health 不需要凭证。authenticator 按顺序尝试。

auth:
authenticators:
- kind: jwt
issuer: https://issuer.example.com
audiences: ["dex"]
jwksURL: ""
allowInsecureJWKSURL: false
caFile: ""
- kind: staticTokens
file: /etc/dex-server-zd/tokens.yaml

allowInsecureJWKSURL 允许在可信网络上使用明文 HTTP 的 jwksURL。jwksURL 为空时,通过 issuer 的 OpenID 配置发现 key set。caFile 把一个 PEM bundle 加到系统根证书中。

静态 token 文件只列出 SHA-256 哈希,绝不保存明文 token:

tokens:
- sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

启动时文件必须有效。修改会在一秒内生效。格式错误的更新会保留上一次有效的内容。文件 为空或缺失时,所有 token 都会失效。

server 只在调用开始时检查一次凭证。已经打开的 pull Worker stream 在 token 过期或被 吊销后仍保持打开。Worker 重新连接时需要有效的凭证。

api.listen 提供明文 gRPC,bearer 凭证会以明文在网络上传输。在 server 前面终止 TLS,或者只允许可信的调用方访问网络。

Server 与 SDK 的协议兼容性​

server-zd 和每个 SDK 版本都声明闭区间形式的协议范围。两个范围重叠时 Worker 才能启动。 Worker 会协商出双方都支持的最高协议,然后同步 Attribute 索引,最后绑定 WorkerTaskService。 server 和 SDK 的制品版本只用于诊断,不决定兼容性。

新 SDK 保留旧协议时可以使用旧 server;新 server 也可以在协议范围仍然重叠时使用旧 SDK。 破坏性的 server 版本会提高最低协议。升级 server 之前,先停止旧 Worker 及其流量,升级后再恢复流量。 也可以使用相互隔离的蓝绿环境,使协议范围不相交的部署永远不会互相通信。运行中的 Worker 在 server 升级后不会重新协商,因此破坏性变更之前必须显式停止它们。

协议兼容性只覆盖 Worker 到 server 的 API。它不能替代 open Flow 的兼容性规则,包括 Flow type 名称、Step 图和已持久化的 payload。

备份和恢复​

对象存储前缀是唯一的数据源。本地存储从它重建。外部搜索投影在启动后从本地存储 重建。使用 search.backend: postgres 时,恢复较旧的前缀可能导致数据库中的 Attribute 注册表版本高于恢复后的注册表版本。此时启动会报 Attribute slot 注册表不匹配错误。 重新启动前,配置新的 search.postgresSchema 或新的 PostgreSQL 数据库,让搜索投影和 slot 分配从恢复后的注册表重建。 配置了 objectStore.hardDeleteStore 时,它的前缀存放硬删除 Attribute 的值, 也要用同样的方式保护。

用 bucket 版本控制或复制到另一个 bucket 来保护前缀。使用版本控制时,非当前版本的过期 时间决定了能恢复到多久以前。复制是异步的,副本可能落后于源。

恢复步骤:

  1. 停止 server-zd。例如,把 StatefulSet 缩到 0 个副本,并等待 pod 终止。
  2. 把前缀下的所有对象恢复到同一个时间点,并删除该时间点之后写入的对象。
  3. 清空本地存储目录。使用 PersistentVolumeClaim 时,删除目录内容或删除该 claim。 使用 emptyDir 时,pod 终止后它就已经不存在了。
  4. 启动 server-zd。它会用恢复后的前缀中最新的快照和日志进行恢复。

恢复时间点之后确认的写入会丢失。

PostgreSQL 搜索​

选择 search.backend: postgres,即可在 PostgreSQL 15 或更新版本上使用索引搜索, 无需安装扩展。它不依赖 pg_search、btree_gin 或其他扩展。 如果需要的索引 Attribute 数量超过 PostgreSQL 槽位上限,请选择 ParadeDB。

search:
backend: postgres
postgresConnectionString: "postgres://USER:PASSWORD@HOST:5432/DATABASE"
postgresConnectionPoolSize: 10
postgresSchema: public
readYourWritesWait: 2s

服务端通过 PostgreSQL 协议连接 postgresConnectionString,不会绑定或发布这个地址。 该字段为空时,读取 DEX_ZD_POSTGRES_CONNECTION_STRING。 连接支持与 paradeDbConnectionString 相同的 TLS 选项,序列化配置时会隐藏连接字符串。 连接池大小默认为 10,schema 默认为 public。

每个租户有 10 个 Keyword 槽位,Int、Datetime、Double、Bool、KeywordArray 和 Text 各有 3 个槽位。槽位按注册顺序分配,服务端上的应用共用 registry。 系统字段(包括 DexWorkQueuePermissions)不占用槽位。 某种类型的槽位用满后,再注册 Attribute 会返回 FAILED_PRECONDITION。 如果已有 registry 超过这些上限,服务端也会拒绝启动。 registry 没有删除操作,用满后需要使用新的 server。

索引 Keyword 值以及 KeywordArray 中的每个元素最多包含 1024 个 UTF-8 字节。 新的写入超过上限时返回 INVALID_ARGUMENT。 如果被拒绝的是 Worker 的 Step 结果,该 Step 会失败。 对于此前已提交、存储类型与注册的 Keyword 或 KeywordArray 类型一致的超长值, 投影会跳过对应文档并记录 skipped document;Flow 状态仍然保持已提交。 未注册的索引键不会触发这种跳过。

新注册的 Attribute 在槽位回填完成前使用现有 JSONB 查询路径。 回填期间结果正确,但查询可能较慢。回填分批提交,重启后会继续, 不会暂停其他分区的投影。未注册的索引键也使用 JSONB。 IS NULL、IS NOT NULL 和否定条件根据已存储的键是否存在判断, 即使存储类型与注册类型不同也如此。Int 和 Double 值保持不同类型。 KeywordArray 下存储的标量字符串按一个元素匹配。 一种类型不一致情况采用更严格的结果:Keyword 的肯定条件不匹配已存储的 KeywordArray。 文档仍可搜索,否定条件保持 builtin evaluator 的结果。 != 和 NOT IN 等否定条件通常无法使用索引。 显式 ORDER BY StartTime 和 ORDER BY CloseTime 会对所有匹配行排序, 开销随匹配行数量增加。

Text 匹配查询中的所有 term,不按 BM25 相关度排序。 投影是异步的,两种外部搜索后端共用 readYourWritesWait。

schema 会记录搜索后端。使用不同后端启动时,服务端会报错。 在 PostgreSQL 和 ParadeDB 之间切换,需要指定新的 database 或 schema。 服务端会自动从已提交状态重建搜索投影。 已有的 ParadeDB 第三版 schema 只增加后端标记,不清空数据,也不改变版本号。 接受 schema 版本 3 的旧版服务端仍可使用它。PostgreSQL 使用 schema 版本 4,旧版服务端会拒绝它。