Docker と Kubernetes
プロセスは 1 つ、ボリュームは 1 つ、バックアップするファイルも 1 つです。このページでいちばん長い節は、自分以外の誰かが届くようになる前に何を設定するかについての節です。
コンテナ 1 つ
リリースはまだありません。
このサイトのコマンドが指すイメージ、チャート、パッケージは予定している配布物であり、どれも公開されていません。今動くのはソースからのビルドで、Rust と Node が必要です。
ビルドして実行
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo mcpd はストレージを組み込んだ単一のプロセスです。横で動かすデータベース も、サイドカーも、静的ファイルサーバーもありません。取り込みも、クエリ API も、インターフェースも、すべて 1 つのポートで提供されます。
docker run -p 4317:4317 -v mcpd-data:/data -e MCPD_DATA=/data/mcpd.db ghcr.io/mcpd/mcpd:0.1.0
二度読む価値があるのはボリュームの部分です。これが無いと SQLite の
ファイルはコンテナの書き込み可能レイヤーに置かれ、コンテナが消えると
トレースも消えます。MCPD_DATA を明示的に設定しているのも
同じ理由です。デーモンが書くパスとマウントしたパスがずれても、mcpd は
動き続け、ボリュームは空のままになります。それは、データが必要になった
日に初めて気付く類の失敗です。
タグは固定してください。latest は、誰も選んでいない時刻に
再起動をアップグレードに変えてしまいます。どのタグも
linux/amd64 と linux/arm64 向けにビルドされ、
uid 10001 で動きます。イメージの中で /data を所有して
いるのはこの uid です。
ポートと、そこで応答するもの
すべてが 4317 にあります。デプロイがコンテナ 1 つで済むのはそのおかげ ですが、ingress のルールやネットワークポリシーを書くときには知って おく必要があります。パスごとにリスクが同じではないからです。
| パス | 内容 | 必要なキー |
|---|---|---|
/v1/traces, /v1/logs, /v1/metrics | OTLP 取り込み。どの OpenTelemetry SDK からでも | 取り込み |
/v1/ingest | ネイティブ取り込み。1 つのリクエストでモデル全体を送れます | 取り込み |
/v1/api/… | クエリ API。トレース、スパン、ログ、スコア、分析 | 読み取り |
/ | 運用者向けインターフェース。バイナリに埋め込まれています | 不要 |
/healthz, /readyz, /metrics | liveness、readiness、そして mcpd 自身の Prometheus カウンター | 不要 |
取り込みリクエストの上限は 32 MiB です。プロンプトは大きいので、この 上限は意図的に余裕を持たせてありますが、mcpd の手前に立つプロキシは たいていもっと小さい上限を持っています。3 MiB のバッチを拒否する リバースプロキシは、トレースが届かない理由としていちばんよくあるもの です。
Compose
このファイルには説明する価値のある判断が 2 つあります。どちらも書き忘れ
のように見えるからです。ポートは 127.0.0.1 に公開されて
いるので、キーをまだ設定している最中に、ホストの外から届くことはあり
ません。そしてこのファイルにヘルスチェックがないのは、イメージが
持っているからです。イメージの中の busybox の wget で、
MCPD_LISTEN のポートの /healthz に 30 秒ごとに
リクエストし、結果は docker compose ps に表示されます。
Docker は unhealthy になったコンテナを再起動しません。再起動ポリシーが
働くのは、プロセスが終了したときです。
deploy/docker
# mcpd: one service, one volume.
#
# The volume is most of the reason this file exists. Without it the SQLite file
# lives in the container's writable layer, and `docker compose down` takes every
# trace with it.
#
# Copy .env.example to .env and fill it in before running this. The keys are not
# in this file on purpose: a compose file is committed, a .env file is not.
services:
mcpd:
image: ghcr.io/mcpd/mcpd:0.1.0
restart: unless-stopped
# Published on the loopback interface of the host, so nothing outside this
# machine can reach the port even while you are still setting up keys.
ports:
- "127.0.0.1:4317:4317"
environment:
MCPD_LISTEN: 0.0.0.0:4317
# Set the path explicitly. If this and the mount below ever disagree, the
# daemon writes to the container layer and the volume stays empty.
MCPD_DATA: /data/mcpd.db
MCPD_PROJECT: default
MCPD_RETENTION_DAYS: "30"
MCPD_LOG_FORMAT: json
MCPD_INGEST_KEYS: ${MCPD_INGEST_KEYS:?set MCPD_INGEST_KEYS in .env}
MCPD_READ_KEYS: ${MCPD_READ_KEYS:?set MCPD_READ_KEYS in .env}
MCPD_REDACT_KEY: ${MCPD_REDACT_KEY:?set MCPD_REDACT_KEY in .env}
# The run store: a second file on the same volume.
MCPD_RUNS_DATA: /data/mcpd.runs.db
# The two scopes of the run surface. Unset accepts the write keys, which
# here are the ingest keys; set them in .env once a worker runs outside
# this host. No value here on purpose: Compose passes the variable only
# when .env sets it, and `${VAR:-}` would pass an empty string, which the
# daemon reads as one empty key rather than none.
MCPD_EXECUTE_KEYS:
MCPD_WORKER_KEYS:
MCPD_REQUEST_TIMEOUT_SECONDS: "30"
# The two bounds on one request: bytes of body, and signals in it once
# decoded. Lower the first on a small host, since it is what decides how
# much memory one request can take before anything is parsed.
MCPD_MAX_BODY_BYTES: "33554432"
MCPD_MAX_BATCH_SIGNALS: "10000"
# Set MCPD_CORS_ORIGINS when the interface is served from another origin,
# MCPD_REDACT_KEY_CONTAINS to mask fields the built-in rules do not know,
# and MCPD_REDACT_MAX_STRING to lower the 128 KiB cap on a stored string.
# MCPD_SIGNUP=true opens account sign-up; the accounts go to
# MCPD_ACCOUNTS_DATA, beside the data file by default. Behind a proxy, set
# MCPD_CLIENT_IP_HEADER and MCPD_TRUSTED_PROXY_CIDRS so the sign-up and
# log-in limits count clients rather than the proxy.
volumes:
- mcpd-data:/data
# No healthcheck here, because the image has one: it requests /healthz
# every 30 seconds with the busybox wget in the image, on the port in
# MCPD_LISTEN, and `docker compose ps` shows the result. It is the liveness
# endpoint, never /readyz, so a slow query does not mark the daemon sick.
# Docker does not restart a container for being unhealthy; the restart
# policy above acts when the process exits.
# Longer than the 30-second request timeout above, so a request still
# running at `docker compose down` can finish before Docker kills it.
stop_grace_period: 45s
volumes:
mcpd-data: # Copy to .env and replace every value. Generate each one with:
# openssl rand -hex 32
# Keys permitted to write. Comma-separated, one per fleet or per team, so a
# leaked key can be revoked without re-deploying every agent.
MCPD_INGEST_KEYS=replace-me-ingest
# Keys permitted to read stored prompts and completions. Never the same value
# as an ingest key.
MCPD_READ_KEYS=replace-me-read
# Salt for the hashes redaction leaves behind. Without it the digests of short
# identifiers can be recovered by guessing.
MCPD_REDACT_KEY=replace-me-redact
# Keys for the run surface. Leave both commented out and the ingest keys are
# accepted for starting runs and claiming steps, which is fine while every
# worker runs on this host. Set them once a worker runs elsewhere: the worker
# key lives in the process that runs step bodies and opens nothing that reads a
# prompt. Uncomment a line only with a value: `MCPD_WORKER_KEYS=` with nothing
# after it passes an empty string, not an unset variable.
# MCPD_EXECUTE_KEYS=replace-me-execute
# MCPD_WORKER_KEYS=replace-me-worker docker compose -f deploy/docker/docker-compose.yml up -d
キーの各行にある :? は飾りではありません。変数が未設定の
とき、Compose はそのサービスの起動を拒否します。設定し忘れたキーが、
静かに開いたポートではなくエラーメッセージになります。実行用の 2 つの
キーはファイルの中で値をまったく持たないので、Compose が渡すのは
.env が設定したときだけです。空の値を渡すと、デーモンには
キーなしではなく、空のキー 1 つとして届きます。
Kubernetes
オブジェクトは 4 つです。Deployment、Service、PersistentVolumeClaim、
そして自分で作る Secret。Deployment は Recreate 戦略でレプリカを 1 つ動かし、
これらのマニフェストの中で変えてはいけない 1 行がそこです。mcpd は
すべてを 1 つの SQLite ファイルに保存し、それを書くプロセスは 1 つで、
ローリングアップデートは、最初の Pod がまだファイルを握っている間に
同じボリュームの上で 2 つ目の Pod を起動してしまいます。それはスケール
の話ではなく、破損の話です。
Secret は kubectl apply が読むファイルには入っていません。
secret.yaml.example がその形を示し、拡張子のおかげで apply
の対象から外れています。そのまま適用された Secret は、プレースホルダー
の文字列をそのままキーにしてしまうからです。代わりに、その場で生成した
キーでコマンドラインから作ってください。Pod は uid 10001 で、読み取り
専用のルートファイルシステム、ケーパビリティなし、ランタイム既定の
seccomp プロファイルで動きます。Pod Security の
restricted レベルが求めるのはこれです。
deploy/k8s
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcpd
labels:
app.kubernetes.io/name: mcpd
spec:
# One SQLite file has one writer. Two pods mounting the same volume is not a
# scaling story, it is a corruption story.
replicas: 1
strategy:
# RollingUpdate would start the new pod while the old one still holds the
# database. Recreate stops the old pod first and costs a few seconds of
# downtime, which the SDK buffers cover.
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: mcpd
template:
metadata:
labels:
app.kubernetes.io/name: mcpd
spec:
securityContext:
runAsNonRoot: true
# The uid and gid the image creates its user with, so this pod and a
# plain `docker run` own files the same way.
runAsUser: 10001
runAsGroup: 10001
# The volume is created owned by root. Without fsGroup the daemon
# cannot create its database and exits on the first start.
fsGroup: 10001
# The Pod Security `restricted` level refuses a pod without it.
seccompProfile:
type: RuntimeDefault
# Longer than the 30-second request timeout below, so a request still
# running at SIGTERM can finish before the kubelet kills the process.
terminationGracePeriodSeconds: 45
containers:
- name: mcpd
image: ghcr.io/mcpd/mcpd:0.1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4317
env:
- name: MCPD_LISTEN
value: 0.0.0.0:4317
- name: MCPD_DATA
value: /data/mcpd.db
- name: MCPD_PROJECT
value: default
- name: MCPD_RETENTION_DAYS
value: "30"
- name: MCPD_LOG_FORMAT
value: json
- name: MCPD_INGEST_KEYS
valueFrom:
secretKeyRef:
name: mcpd-keys
key: ingest-keys
- name: MCPD_READ_KEYS
valueFrom:
secretKeyRef:
name: mcpd-keys
key: read-keys
- name: MCPD_ADMIN_KEYS
valueFrom:
secretKeyRef:
name: mcpd-keys
key: admin-keys
# The two scopes of the run surface. A worker key lives in the
# process that runs step bodies and opens nothing that reads a
# stored prompt. Optional: with no entry in the Secret the
# variable is not set at all, and the ingest keys are accepted.
- name: MCPD_EXECUTE_KEYS
valueFrom:
secretKeyRef:
name: mcpd-keys
key: execute-keys
optional: true
- name: MCPD_WORKER_KEYS
valueFrom:
secretKeyRef:
name: mcpd-keys
key: worker-keys
optional: true
# The run store is a second file on the same volume.
- name: MCPD_RUNS_DATA
value: /data/mcpd.runs.db
- name: MCPD_REQUEST_TIMEOUT_SECONDS
value: "30"
# The two bounds on one request: bytes of body, and signals in it
# once decoded. Lower the first on a small pod, since it is what
# decides how much memory one request can take.
- name: MCPD_MAX_BODY_BYTES
value: "33554432"
- name: MCPD_MAX_BATCH_SIGNALS
value: "10000"
# Set when the interface is served from another origin, as a
# comma-separated list of origins; and to mask fields the built-in
# rules do not know, or to lower the 128 KiB cap on a stored string:
# MCPD_CORS_ORIGINS, MCPD_REDACT_KEY_CONTAINS, MCPD_REDACT_MAX_STRING
- name: MCPD_REDACT_KEY
valueFrom:
secretKeyRef:
name: mcpd-keys
key: redact-key
# Liveness never touches storage, so a slow query cannot restart a
# process that is working.
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# Readiness pings the store, so traffic only reaches an instance that
# can actually write to its database.
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
# Migrations run at start. On a large database that takes longer than
# the liveness probe would allow, so the startup probe holds it off.
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 2
failureThreshold: 45
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
# No CPU limit: ingest arrives in bursts, and throttling the
# writer is how a queue turns into dropped spans.
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: mcpd-data apiVersion: v1
kind: Service
metadata:
name: mcpd
labels:
app.kubernetes.io/name: mcpd
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: mcpd
ports:
- name: http
port: 4317
targetPort: http
protocol: TCP apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mcpd-data
labels:
app.kubernetes.io/name: mcpd
spec:
accessModes:
# ReadWriteOnce, and not because of a quota. SQLite locking is only correct
# on a local block device: on NFS or any other shared filesystem the locks
# are advisory at best and the database will eventually be corrupted.
- ReadWriteOnce
resources:
requests:
storage: 20Gi
# storageClassName: gp3 # A template, not part of the files beside it.
#
# The extension is .example on purpose. `kubectl apply -f deploy/k8s/` applies
# every manifest in this directory, and a Secret applied as written would put
# these placeholder strings into production as the keys. Create the Secret
# from the command line instead, with keys generated on the spot:
#
# kubectl -n mcpd create secret generic mcpd-keys \
# --from-literal=ingest-keys="$(openssl rand -hex 32)" \
# --from-literal=read-keys="$(openssl rand -hex 32)" \
# --from-literal=admin-keys="$(openssl rand -hex 32)" \
# --from-literal=redact-key="$(openssl rand -hex 32)"
#
# Add execute-keys and worker-keys the same way once a worker runs outside the
# cluster; until then the deployment accepts the ingest keys for both.
#
# Or copy this file, replace every value with the output of
# `openssl rand -hex 32`, apply it by name, and do not commit the result.
apiVersion: v1
kind: Secret
metadata:
name: mcpd-keys
labels:
app.kubernetes.io/name: mcpd
type: Opaque
stringData:
# Comma-separated. One key per fleet or per team means a leaked key can be
# revoked without redeploying every agent.
ingest-keys: REPLACE_WITH_A_RANDOM_KEY
# Never the same value as an ingest key.
read-keys: REPLACE_WITH_A_DIFFERENT_RANDOM_KEY
# Keys permitted to delete data and read the audit log. Without this entry
# the daemon accepts the read keys for admin work, so every read key can
# delete a trace; `mcpd doctor` reports that as a warning.
admin-keys: REPLACE_WITH_A_THIRD_RANDOM_KEY
# Keys permitted to start, cancel and signal runs.
execute-keys: REPLACE_WITH_A_FIFTH_RANDOM_KEY
# Keys permitted to claim steps and report their outcome. This one lives in
# the process that runs step bodies, so it is never the read key.
worker-keys: REPLACE_WITH_A_SIXTH_RANDOM_KEY
# Salt for the hashes redaction leaves behind.
redact-key: REPLACE_WITH_A_FOURTH_RANDOM_KEY export INGEST_KEY=$(openssl rand -hex 32) READ_KEY=$(openssl rand -hex 32) ADMIN_KEY=$(openssl rand -hex 32)
kubectl create namespace mcpd
kubectl -n mcpd create secret generic mcpd-keys \
--from-literal=ingest-keys=$INGEST_KEY \
--from-literal=read-keys=$READ_KEY \
--from-literal=admin-keys=$ADMIN_KEY \
--from-literal=redact-key=$(openssl rand -hex 32)
kubectl -n mcpd apply -f deploy/k8s/
3 つのキーは、チームがシークレットを置いている場所に保管してください。
取り込みキーはすべてのエージェントへ、読み取りキーはインターフェースを
読む人へ、管理キーはデータを削除してよい人へ渡します。ワーカーが
クラスターの外で動くようになったら、execute-keys と
worker-keys を同じ方法で追加してください。それまでは、
どちらにも取り込みキーが受け付けられます。
export EXECUTE_KEY=$(openssl rand -hex 32) WORKER_KEY=$(openssl rand -hex 32)
kubectl -n mcpd patch secret mcpd-keys --type merge \
-p '{"stringData":{"execute-keys":"'$EXECUTE_KEY'","worker-keys":"'$WORKER_KEY'"}}'
kubectl -n mcpd rollout restart deploy/mcpd kubectl -n mcpd port-forward svc/mcpd 4317:4317 クラスター内のエージェントは Service を指します。SDK はこの 2 つを環境 から読むので、計装済みのコードは変えなくて済みます。
MCPD_ENDPOINT=http://mcpd.mcpd.svc:4317 MCPD_API_KEY=$INGEST_KEY プローブ
この 2 つのエンドポイントは意図的に違うことをしていて、入れ替えると 特定の、見苦しい失敗を起こします。
-
/healthzはストレージに一切触れません。プロセスの状態 だけで答えます。データベースに問い合わせる liveness プローブは、 大きなクエリで遅くなるたびに健康なデーモンを再起動してしまいます。 先行書き込みログを書き出している最中の再起動こそ、いちばん避けたい ことです。 -
/readyzはストアに ping を打ち、届かないときは"status": "degraded"と、何がおかしいかを書いた 1 行を 添えて 503 を返します。処理できないインスタンスから、それを殺さずに トラフィックを遠ざけます。
startup プローブがあるのは、起動時にマイグレーションが走るからです。 マイグレーションは冪等でたいていは一瞬ですが、大きなデータベースでは liveness プローブが許すより長くかかることがあり、毎回マイグレーションの 途中で殺される Pod は、いつまでも起動しません。
割り当てるもの
ここの数字は、このプロセスが実際に何をするかから来ています。ベンチ マークから来たものではなく、ベンチマークを主張してもいません。
| リソース | 値 | 理由 |
|---|---|---|
| CPU request | 100m | 待機中はほとんど何も使いません。request があるのは、スケジューラーが騒がしいものの下に重ねて置かないようにするためです。 |
| CPU limit | なし | 取り込みはバーストで届きます。バーストの最中に書き手を絞ると、クライアントのキューが埋まり、スパンが落ちます。 |
| メモリー request | 128Mi | 静的バイナリ 1 つ、SQLite の接続 5 つ、そのページキャッシュ。 |
| メモリー limit | 512Mi | 同時取り込みのための余裕。1 つのリクエストが 32 MiB のプロンプト文を抱えうるので、メモリーは大きなバッチが同時にいくつ届くかで決まります。 |
| ボリューム | 20Gi、ReadWriteOnce | 自分のペイロードから見積もってください。支配的なのはテキストで、検索インデックスは外部内容参照型ではないので、インデックスされたプロンプトと出力はもう一度保存されます。 |
ボリュームはローカルのブロックデバイスに置いてください。SQLite の ロックが正しく働くのはそこだけです。NFS などの共有ファイルシステム ではロックはよくてアドバイザリー止まりで、その失敗はエラーとしては 現れません。数週間後に壊れたデータベースとして現れます。
Helm チャート
チャートは同じ 4 つのオブジェクトを、その手前に値を置いた形で描画し、 さらに値で有効にするまで無効な 6 つを持ちます。Ingress、 NetworkPolicy、PodDisruptionBudget、ServiceAccount、ServiceMonitor、 そして設定ファイル用の ConfigMap または Secret です。リリースのたびに、 チャートはイメージと同じレジストリーへ push されます。先にキーを生成し、 チームがシークレットを置いている場所に保管してください。インストールが キーを表示し返すことはありません。
helm install mcpd oci://ghcr.io/mcpd/charts/mcpd -n mcpd --create-namespace --set keys.ingest=$INGEST_KEY --set keys.read=$READ_KEY keys.ingest か keys.read が未設定だと
インストールは失敗し、理由を伝えます。取り込みエンドポイントが開いて
いることに後から気付かせるチャートは、仕事をしていません。Secret を
自分で管理しているなら、代わりに keys.existingSecret を
設定してください。チャートは Secret を作りません。
Pod テンプレートは Secret のチェックサムを持つので、キーを入れ替えて
helm upgrade を実行すると Pod が再起動します。これが
なければ、新しいキーはクラスターに置かれたまま、デーモンは古いキーを
強制し続けます。
モニター、SLO、ジャッジ、プロジェクトごとのキー、実行ストアのタイミング
は、フラグではなくデーモンの設定ファイルから読まれます。その JSON
オブジェクトを configFile.content に書くと、チャートは
それを読み取り専用でマウントし、MCPD_CONFIG_FILE をそこに
向けます。プロジェクトごとのキー、ジャッジの api_key、
転送のヘッダーは資格情報なので、これらを含む内容を ConfigMap に置くこと
をチャートは拒否します。configFile.kind を
Secret にするか、自分で管理する Secret にファイルを置いて
configFile.existingSecret で名指ししてください。それ以外は
extraEnv、extraVolumes、
extraVolumeMounts で扱えます。たとえばプライベート CA の
バンドルです。
チャートが扱えない組み合わせは、すべてインストール時に拒否されます。 メッセージは変えるべき値を名指しし、インストール後のクラッシュループに 任せることはありません。拒否の一覧はチャートの README にあります。
deploy/helm/mcpd
# mcpd chart values.
#
# The chart refuses to install without keys. That is deliberate: a daemon on a
# cluster network with open ingest accepts writes from anything that can reach
# the Service, and a chart that lets you find that out later is not doing its
# job. Set keys.ingest and keys.read, or point keys.existingSecret at a Secret
# you manage yourself.
image:
repository: ghcr.io/mcpd/mcpd
# Empty means the chart's appVersion. Pin it in production.
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Daemon configuration. Each of these maps to one environment variable.
config:
# The container port and the NetworkPolicy port are read from the number
# after the last colon, so moving the listener moves both.
listen: 0.0.0.0:4317
dataPath: /data/mcpd.db
project: default
# Days to keep signals. Empty or 0 keeps everything for ever, and the chart
# then sets no MCPD_RETENTION_DAYS at all.
retentionDays: 30
# Fraction of traces to keep, 0.0 to 1.0. Empty keeps all of them.
sample: ""
logFormat: json
# Turn off built-in redaction. Only sensible when something upstream already
# redacts.
noRedact: false
# Mask any value whose key contains one of these, on top of the built-in
# rules. Matched on lowercase substrings of the key.
redactKeyContains: []
# Longest single string stored, in bytes. Empty keeps the built-in 128 KiB.
redactMaxString: ""
# Where the run store lives, when runs.enabled is true. It must be inside the
# directory of dataPath, because that directory is the only writable mount.
# Empty derives it from dataPath: /data/mcpd.runs.db for the default. "off"
# is the same as runs.enabled false.
runsPath: ""
# Browser origins allowed to call the query API from another site, such as
# an interface served from a portal. Empty answers no cross-origin request.
corsOrigins: []
# Longest a request may take before it is answered with a 503. Empty keeps
# the built-in 30 seconds. Raise terminationGracePeriodSeconds with it.
requestTimeoutSeconds: ""
# An external database for signals, instead of the embedded file.
#
# An external store is what lets mcpd run more than one pod: the single writer
# and the single replica are limits of the SQLite file, not of mcpd. It is half
# of what it takes, though. The run store is always an embedded file (see
# `runs` below), so more than one replica also needs runs.enabled false.
#
# `type` decides the mode, never the URLs. That is what lets a URL live only in
# a Secret you manage: set the type here, point keys.existingSecret at the
# Secret, and leave the URL values empty. A URL value with type none, or with
# the other type, refuses to render rather than being ignored.
externalStore:
# none, postgres or clickhouse.
type: none
# A Postgres connection string. Reach for this first: it lifts both limits on
# a database most teams already run, back up and monitor.
postgresUrl: ""
# A ClickHouse HTTP endpoint. Reach for this when retention is measured in
# billions of spans.
clickhouseUrl: ""
clickhouseDatabase: mcpd
clickhouseUser: ""
# Both URLs and the ClickHouse password carry credentials, so they are held in
# the chart's Secret rather than in the pod spec. With keys.existingSecret,
# the chart creates no Secret: put them in yours under the keys postgres-url,
# clickhouse-url and clickhouse-password, and leave these values empty.
clickhousePassword: ""
# The durable run surface: definitions, the journal of step attempts, deadlines
# and waits.
#
# Its store is an embedded SQLite file whatever the signal store is, so while
# it is on, the pod keeps a file on a volume and runs as one replica. With an
# external store that volume holds the run store alone. Turn it off to run
# several pods against an external store; the daemon then answers the run
# endpoints with a 503 that says so, and nothing else changes.
runs:
enabled: true
# Pods to run. More than one needs both an external store and runs.enabled
# false, because each embedded SQLite file has one writer. The chart refuses to
# render a larger count otherwise, rather than pinning it to 1 behind your back.
replicaCount: 1
keys:
# Comma-separated keys permitted to write.
ingest: ""
# Comma-separated keys permitted to read stored prompts and completions.
read: ""
# Comma-separated keys permitted to change stored data: scores, feedback,
# datasets, experiments and acknowledgements. Unset means the ingest keys are
# accepted, which is what a deployment written before this scope existed did.
# The ingest key is in every container that emits a span, so separating them
# is worth doing once you have more than one team.
write: ""
# Comma-separated keys permitted to destroy data and read the audit log.
# Unset means the read keys are accepted, so a read key can delete a trace.
# `mcpd doctor` reports that as a warning and names this setting.
admin: ""
# Comma-separated keys permitted to start, cancel and signal runs. Unset
# means the write keys are accepted, and an admin key always is.
execute: ""
# Comma-separated keys permitted to claim steps and report their outcome.
# This is the key that lives in the process running step bodies. Unset means
# the write keys are accepted, and an admin key always is.
worker: ""
# Salt for the hashes redaction leaves behind. Optional, but set it anywhere
# that hashes: without it, digests of short identifiers can be guessed.
redact: ""
# Use a Secret you already manage instead of the values above. It must carry
# ingest-keys and read-keys, and optionally write-keys, admin-keys,
# execute-keys, worker-keys and redact-key.
existingSecret: ""
# The two bounds on one request. Empty means the built-in defaults, 33554432
# bytes (32 MiB) of body and 10000 signals per request.
#
# The byte cap is what bounds how much memory one request can take before
# anything is decoded, so lower it on a small pod. The signal cap is counted
# across the whole request on every ingest door, whether the signals arrive as
# one batch, an array of batches or an NDJSON stream.
maxBodyBytes: ""
maxBatchSignals: ""
# A ceiling on signals stored per project per minute. Empty means no ceiling,
# which is what a deployment that never sets it had before quotas existed.
#
# It stops one project spending the whole daemon. Signals over the ceiling are
# refused and reported with the count and the project, never dropped in silence,
# and the rest of the batch is still stored.
quotaPerMinute: ""
# Record WHICH PERSON acted on an audit row, not only which key.
#
# mcpd has no identity provider and is not getting one, so the identity comes
# from the ingress already in front of it. Both halves are required: the header
# your ingress sets, and the ranges allowed to set it. A header is a string
# anyone who can reach the port may send, so mcpd reads it only from an address
# you have named here. A request from anywhere else is served normally and its
# audit row carries no subject.
#
# Set trustedProxyCidrs to the range your ingress controller runs in, not to
# 0.0.0.0/0, which would trust every client to name itself.
identity:
# For example x-forwarded-user, x-forwarded-email or x-auth-request-user.
header: ""
# A list of CIDR ranges, for example ["10.42.0.0/16"].
trustedProxyCidrs: []
# The header your proxy puts the client's address in, for example
# cf-connecting-ip. Read only from trustedProxyCidrs. The sign-up and log-in
# limits count this address; without it they count the proxy's.
clientIpHeader: ""
# Open sign-up: anyone may create an account, which owns one new project and
# its own API keys. Off by default. On, a request with no key is refused
# everywhere except the health probes, sign-up and log-in. Set
# identity.clientIpHeader and identity.trustedProxyCidrs as well when an
# ingress is in front, so the per-address limits count clients.
signup:
enabled: false
# Where the accounts file goes. Empty puts it beside the data file, as
# mcpd.accounts.db, which is on the volume.
accountsData: ""
# Copy every stored span on to another OTLP endpoint. The local write always
# happens first, so a slow or absent destination never costs you the data.
forward:
# Base URL, not the /v1/traces path; mcpd appends it.
endpoint: ""
# Headers as "Name: value" strings. A credential here lands in the Secret.
headers: []
# The volume for whatever the pod keeps on disk: the signal store and the run
# store on the embedded store, or the run store alone with an external one.
# Not used with an external store and runs.enabled false, since nothing local
# is left to keep.
#
# With enabled false the chart mounts an emptyDir instead, so the daemon can
# still write, and everything it wrote is gone when the pod is.
persistence:
enabled: true
# Sized for the embedded signal store. A volume that holds the run store
# alone needs far less.
size: 20Gi
# Leave empty for the cluster default. SQLite locking is only correct on a
# local block device, so do not point this at NFS.
storageClass: ""
existingClaim: ""
service:
type: ClusterIP
port: 4317
annotations: {}
# Everything from here to `resources` is off by default, so an install that sets
# nothing renders the same four objects it always did. Each block is here
# because a chart review asks a question this chart could not otherwise answer.
# "What identity does this pod run as, and what can that identity do?"
serviceAccount:
create: false
# With create true, empty means the release's fullname. With create false,
# naming an account you manage yourself puts the pod on it, and leaving it
# empty keeps the namespace's `default` account, which is today's behaviour.
name: ""
annotations: {}
# mcpd never calls the Kubernetes API, so it has no use for a token.
# Turn this on only if something you add to the pod needs one.
automountServiceAccountToken: false
# "How does traffic reach mcpd from outside the cluster, and who terminates
# TLS?"
#
# Not mcpd: it speaks plain HTTP and has no TLS configuration, so the ingress
# controller has to terminate. Two things to know before you publish it.
#
# One path root. mcpd serves OTLP ingest, the query API and the operator
# interface from the same root, so a rule for "/" publishes all three, and your
# ingest keys are what stands between the open internet and a write. There is no
# path prefix that separates them.
#
# Body size. mcpd accepts a 32 MiB batch, and several ingress controllers cap a
# request body at 1 MiB by default, so a batch the daemon would have taken is
# refused before it arrives. On ingress-nginx the annotation to raise is
# `nginx.ingress.kubernetes.io/proxy-body-size`.
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: mcpd.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: mcpd-tls
# hosts:
# - mcpd.example.com
# "Only these namespaces may reach ingest."
#
# The limit belongs in the answer, so here it is. mcpd serves ingest, the query
# API, the operator interface and /metrics on ONE port, and a NetworkPolicy
# matches pods and ports, never paths. This gates the whole daemon or nothing:
# every source you allow in for ingest can also reach /v1/api and read stored
# prompts and completions. The read key is what holds those apart, not this
# policy. A split at the network layer would need a second listener, and mcpd
# does not have one.
#
# What it does do is still worth having. With no policy in the namespace, every
# pod in the cluster can open a connection to mcpd. With one, only these can.
#
# A trap worth knowing first: some CNI plugins enforce policy on traffic
# arriving from the node, and the kubelet's liveness and readiness probes
# arrive that way. On those clusters, add the node CIDR to `ipBlocks`, or the
# pod never becomes ready and the rollout stalls.
networkPolicy:
enabled: false
ingress:
# Whole namespaces, by label. Kubernetes sets
# `kubernetes.io/metadata.name` on every namespace itself, so this works
# without labelling anything first.
namespaceSelectors: []
# - matchLabels:
# kubernetes.io/metadata.name: agents
# Pods in mcpd's OWN namespace. A podSelector with no namespaceSelector
# beside it never reaches across a namespace boundary.
podSelectors: []
# - matchLabels:
# app.kubernetes.io/part-of: checkout-agents
ipBlocks: []
# - cidr: 10.42.0.0/16
# except:
# - 10.42.7.0/24
# Whole `from` entries, written out and appended as they are. Use this for
# the one thing the three lists above cannot say: a namespaceSelector and a
# podSelector inside a SINGLE entry, which means "those pods, in those
# namespaces" rather than "either of the two".
extraFrom: []
# - namespaceSelector:
# matchLabels:
# kubernetes.io/metadata.name: agents
# podSelector:
# matchLabels:
# app.kubernetes.io/name: checkout-agent
egress:
# Leave this off unless you have a reason. Restricting egress breaks four
# things and breaks them quietly, because mcpd logs a warning and carries
# on: alert webhooks, forwarding to another OTLP endpoint, the judge's model
# endpoint, and an external Postgres or ClickHouse.
enabled: false
allowDns: true
dnsNamespace: kube-system
# Complete egress rules, appended as written, because what mcpd is allowed
# to dial depends entirely on what you configured it to dial.
rules: []
# "What happens to ingest while a node is drained?"
#
# Only answerable with more than one replica, which takes an external store and
# runs.enabled false. With one replica, a budget over that pod either blocks
# every drain or permits every one. The chart refuses to render it there rather
# than let you discover which during a cluster upgrade.
podDisruptionBudget:
enabled: false
# Set one of these, never both. With replicaCount 2, minAvailable 1 lets one
# pod go at a time and keeps ingest up through a drain.
minAvailable: 1
maxUnavailable: ""
# "Is the collector itself monitored?"
#
# Needs the Prometheus Operator's CRDs in the cluster. Without them the install
# fails on a kind the API server does not know, which is why this is off rather
# than on with a capability check: a chart that silently drops the object you
# asked for is worse than one that fails.
#
# There is no credential below because mcpd serves /metrics with no key. It
# exposes counters and uptime, not stored prompts or completions.
serviceMonitor:
enabled: false
# Empty means the release's namespace.
namespace: ""
interval: 30s
scrapeTimeout: 10s
# The labels your Prometheus selects ServiceMonitors by. Without the ones it
# expects, this object is created and never scraped, and nothing reports that.
labels: {}
relabelings: []
metricRelabelings: []
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
# The uid and gid are the ones the image creates its user with, so the pod and
# a plain `docker run` own files the same way. fsGroup is what makes a new
# volume, created owned by root, writable to that user. The seccomp profile is
# required by the Pod Security `restricted` level, which refuses the pod
# without it.
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefault
# Longer than the longest request. A request may run for the request timeout,
# 30 seconds by default, and the pod has to outlive it after SIGTERM or the
# kubelet kills it mid-write. Raise it with config.requestTimeoutSeconds.
terminationGracePeriodSeconds: 45
# The settings too structured for an environment variable: monitors, SLOs, the
# judge, per-project keys, retention and sampling under `projects`, and the run
# store's timings under `runs`. Written here as the JSON object the daemon's
# --config-file reads (see https://mcpd.sh/docs/configuration), mounted
# read-only at /etc/mcpd/mcpd.json, and named in MCPD_CONFIG_FILE. A change
# restarts the pod, as a key rotation does.
#
# Per-project keys, the judge's api_key and forward headers are credentials. A
# ConfigMap is readable by anything allowed to list ConfigMaps in the
# namespace, so the chart refuses to put content carrying any of them in one.
# Set kind to Secret, or keep the file in a Secret you manage and name it in
# existingSecret.
configFile:
content: {}
# monitors:
# - name: error-rate
# ...
# projects:
# checkout:
# retention_days: 90
# ConfigMap or Secret: the object `content` is rendered into.
kind: ConfigMap
# A Secret you manage that holds the file, instead of `content`.
existingSecret: ""
# The entry in existingSecret that holds the file.
existingSecretKey: mcpd.json
# Extra environment variables, as Kubernetes writes them, appended after the
# ones the chart sets. Use it for a setting this chart has no value for, or to
# read one from a Secret of your own with valueFrom.
extraEnv: []
# - name: MCPD_FORWARD_HEADERS
# valueFrom:
# secretKeyRef:
# name: otlp-credentials
# key: headers
# Extra volumes and where to mount them, for example a CA bundle for a
# ClickHouse or a judge endpoint behind a private certificate authority.
extraVolumes: []
extraVolumeMounts: []
podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {} 他の何かが届くようになる前に
取り込みは既定で開いています。これはノート PC での最初の 5 分に ついては意図した選択です。最初のトレースの前に資格情報を求めるのは、 得るもののない摩擦だからです。そして、ポートが他の何かから届くように なった瞬間には、これは誤った既定になります。だからデーモンは、キーの 無いままループバックでないアドレスで起動するたびに、そう伝えます。
$ docker run -p 4317:4317 -v mcpd-data:/data ghcr.io/mcpd/mcpd:0.1.0mcpd 0.1.0 listening on http://0.0.0.0:4317WARN listening on 0.0.0.0:4317 with open ingest. Anyone who can reach this port can write to it. Set --ingest-key (or MCPD_INGEST_KEYS) before exposing it.WARN listening on 0.0.0.0:4317 with an open read API. Stored prompts and completions are readable by anyone who can reach this port. Set --read-key to require a key. mcpd は起動を拒否するのではなく警告します。拒否すれば、これを動かす 価値そのものにつながる道が断たれるからです。この警告が約束です。 デプロイのログでこれを見たなら、そのデプロイは開いています。
6 つのキー、6 つのスコープ
--ingest-key はシグナルの書き込みを許します。
--read-key はその読み取りを許します。
--write-key は保存済みデータの変更を許します。スコア、
フィードバック、データセット、実験、確認応答です。
--execute-key は実行の開始、キャンセル、シグナル送信を許します。--worker-key はステップの受け取りと結果の報告を許し、ステップ本体を動かすプロセスに置く鍵がこれです。--admin-key はトレースの削除と監査ログの読み取りを許します。
最初の分割が被害範囲を決めます。対象となる集団の大きさが違うからです。 フリートのすべてのエージェントプロセスが取り込みキーを必要とし、 それ以外を必要とする人はずっと少数です。読み取りもできる 1 つのキーを フリートに配れば、保存されたすべてのプロンプトへの資格情報を、スパンを 出すすべてのコンテナに置くことになります。その中には第三者のコードを 動かしているコンテナも含まれます。
実行をデータセットに取り込む操作が書き込みスコープに移ったのも、その ためです。この操作は途中でその実行の入力と出力を読むので、フリートが 持ち歩くキーで許可すると、それらのコンテナすべてに、保存済みの プロンプトを外へ写す手段を渡すことになります。
どのフラグもカンマ区切りのリストを取り、それぞれに環境変数があります。
MCPD_INGEST_KEYS、MCPD_READ_KEYS、
MCPD_WRITE_KEYS、MCPD_ADMIN_KEYS です。複数
使ってください。チームごと、フリートごとにキーを分けておけば、漏れた
キーはリストから外すだけで失効させられます。スパンを出すものすべてを
再デプロイする必要はありません。キーは
Authorization: Bearer … か X-Mcpd-Key
ヘッダーで提示し、比較は定数時間です。
知っておくべき非対称性は、新しいほうの 2 つのフラグを未設定のままにした
ときの挙動です。書き込みリストが未設定なら取り込みキーを受け付け、
管理リストが未設定なら読み取りキーを受け付けます。だからアップグレード
が、動いていたキーを失効させることはありません。
--admin-key を設定するまで、読み取りキーは読み取りと削除の
ためのキーであって、厳密な読み取り専用ではなく、デーモンは起動時に
そう伝えます。それを踏まえて配るか、フラグを設定してください。
誰が何を読んだか
ペイロードを返すハンドラーは、そのアクセスを記録します。トレースの 詳細、スパンとログの検索、フィードバック、データセットの項目、取り込み、 削除です。行には時刻、操作したキーの短いダイジェスト、操作、 プロジェクト、リソース、そして認可が許可したかどうかが入ります。キー 自体が書き残されることはないので、このログが資格情報の 2 つ目の置き場に なることはありません。
読むには GET /v1/api/audit を使います。管理スコープが必要
です。これに頼る前に知っておく性質が 2 つあります。1 つ、このログは
--retention-days では削除されません。説明対象のデータと
同じ日程で自分自身を消してしまうログでは、すでに消えたデータを誰が
読んだのかを答えられないからです。2 つ、行の書き込みが、それが説明する
読み取りを失敗させることはありません。書けなかった行は
/metrics の mcpd_audit_write_failures_total に
数えられ、これがアラートを付けるべきカウンターです。
キーが覆わないもの
- インターフェース。
/のマークアップは、 ポートに届く誰にでも配信されます。その後ろのデータは違います。 インターフェースが出すリクエストはすべてクエリ API を通り、そこで 読み取りキーが強制されます。デーモンにログイン画面はないので、 サービスではなく人がアクセスするなら、すでに運用している ID プロキシ の後ろに置いてください。 - プローブと
/metrics。意図的に認証なし です。kubelet も Prometheus のスクレイパーもキーを持てないからです。 公開するのはカウンターと稼働時間だけで、ペイロードは決して出しません。 - 通信路。mcpd は平文の HTTP を話し、TLS の終端は しません。ingress かメッシュで終端してください。プロンプトと出力は その経路をそのまま通ります。
- 秘匿化されたペイロードの中のハッシュ。
--no-redactを渡さないかぎり秘匿化は有効ですが、 識別子をハッシュする場合、そのダイジェストはソルトと同じ程度にしか 秘密ではありません。--redact-keyを設定してください。 さもないと短い識別子は総当たりで復元できます。 秘匿化と保持期間を 参照してください。
公開する前の確認
mcpd config は、その呼び出しで使われる設定を表示して終了
します。間違った場所に設定した環境変数が何もしていなかった、と気付く
いちばん安上がりな方法です。
$ docker compose -f deploy/docker/docker-compose.yml run --rm mcpd configlisten 0.0.0.0:4317storage sqlite at /data/mcpd.dbconfig file nonelog format jsonproject defaultprojects noneretention 30 daysingest auth key requiredread auth key requiredwrite auth key required (ingest keys)admin auth key required (read keys)execute auth key required (ingest keys)worker auth key required (ingest keys)signup offruns sqlite at /data/mcpd.runs.dbrun settings lease_ttl_ms 30000, reclaim_window_ms 30000, stall_window_ms 300000, idempotency_window_ms 86400000, park_ttl_ms 86400000, max_depth 8, max_step_repeats 16identity offclient ip peer addresssampling keep everythingquota unlimitedredaction onredact key setcors same-origin onlytimeout 30s per requestlimits 32 MiB per request, 10000 signals per batchderived duration and token metricsaspects offmonitors noneobjectives nonejudge offforwarding off
ここで重要なのは 6 行です。認証の行に open とあれば、その
スコープはポートに届く相手に開いています。括弧付きの行は、そのスコープ
がどの広いリストを継承しているかを示します。上の呼び出しは
--admin-key を設定していないので、その読み取りキーは読む
だけでなく削除もします。--execute-key と
--worker-key も設定していないので、その取り込みキーは実行の開始と
ステップの取得もでき、mcpd は起動時にそのことを警告します。ここに表示されるフラグはすべて
設定に載っています。
バックアップ
バックアップの対象はファイル 1 つです。検索インデックスを含め、mcpd が
保存するものはすべて MCPD_DATA のファイルにあります。
デーモンが動いている間にそのファイルをコピーすること、それだけはやめて
ください。mcpd は SQLite を WAL モードで動かすので、最近の書き込みは
横の -wal ファイルにあり、データベース単体のコピーは
それらが欠けたコピーになります。代わりに一貫したコピーを取ってください。
sqlite3 ツールがあり、同じボリュームをマウントしている
イメージなら、どれからでも取れます。
sqlite3 /data/mcpd.db ".backup '/backup/mcpd-$(date -u +%Y%m%dT%H%M).db'"
どちらも、書き込みが続いている間に一貫したスナップショットを読みます。
CSI ドライバーが取るボリュームスナップショットも、そのスナップショットが
アトミックなら使えます。動いているディレクトリの tar は
そうではありません。リストアは、デーモンを止め、ファイルを
MCPD_DATA に戻し、その横の -wal と
-shm のファイルを削除し、デーモンをまた起動することです。
運用で順を追って
説明しています。
削除された行はファイルの中のページを解放するだけで、ファイルは小さく
なりません。だから多くのデータを期限切れにしたデータベースも、大きさは
そのままです。ディスクを取り戻したいときは VACUUM を実行
してください。
アップグレード
タグを変えて再起動します。マイグレーションは起動時に走り、毎回走らせて も安全なので、プロセスの外で行う手順はありません。それでも先に バックアップを取ってください。マイグレーションは、データの下でスキーマ が変わる唯一の瞬間です。
再起動はローリングではなく短い停止です。レプリカが 1 つである理由と同じ です。エージェントはこれを乗り切ります。SDK はメモリーにキューを持って 再送し、そのキューには上限があるので、数秒で終わる再起動は何も損なわず、 1 時間かかる再起動は古いスパンから落とし、そのことをクライアントの ログに書きます。
mcpd は SIGTERM を処理して正常に停止します。Kubernetes が送るのはこの
シグナルです。terminationGracePeriodSeconds は最も長い
リクエストより長くなければなりません。短いと、kubelet は書き込みの
途中でプロセスを殺します。リクエストは 30 秒のリクエストタイムアウト
まで続くことがあるので、マニフェストとチャートは Pod に 45 秒を与え、
compose ファイルもコンテナに同じ時間を与えます。上げるときは両方を
一緒に上げてください。
1 つの SQLite ファイルの限界と、その先の手立て
組み込みファイルは正しい既定であり、動かせない制限を 2 つ持ちます。 どちらも mcpd ではなく SQLite のものです。
- 書き手は 1 つ。読み取りは自分の接続で走り、書き手を 止めることは決してありません。WAL モードはそのためにあります。しかし ファイルを所有するプロセスは 1 つです。そのインスタンスは、外へでは なく上へスケールさせます。
- ボリューム 1 つ、アベイラビリティーゾーン 1 つ。 ReadWriteOnce のクレームは Pod をそこに固定します。データを保った まま別のノードへフェイルオーバーする道はありません。
たいていは、新しいバックエンドより 2 つの設定のほうが多くを取り返します。 サンプリングはトレースの 一部だけを残し、残すと決めたトレースはまるごと残します。判定はトレース id のハッシュで行うので、フリートのどのインスタンスも同じ判断をします。 保持期間は、ファイルがどこまで遡るかを抑えます。チームごとに、それぞれ 自分のボリュームを持つインスタンスを 1 つずつ動かすのも有効で、騒がしい チームが全員のディスクを埋めるのを防げます。
それでも足りなくなったら、mcpd をデータベースに向けてください。
チャートで externalStore.type を postgres か
clickhouse にし、接続文字列を
externalStore.postgresUrl か
externalStore.clickhouseUrl に設定します。接続文字列は
Pod の spec ではなく、チャートの Secret に保持されます。values からも
完全に外したいなら、自分で管理する Secret の postgres-url
か clickhouse-url に置き、その Secret を
keys.existingSecret で名指しし、それでも type は設定して
ください。バックエンドを選ぶのは URL ではなく type です。
これでシグナルは移りますが、ディスクにはファイルが 1 つ残ります。実行
ストアです。シグナルのストアが何であっても、これは組み込みの SQLite
です。runs.enabled が true(既定)なら、チャートはそれを
ボリュームに置き、レプリカを 1 つ動かします。複数の Pod を動かすには、
実行の機能を止めてください。するとチャートは
replicaCount 個の Pod を動かし、Recreate から
ローリングアップデートに切り替え、PersistentVolumeClaim を外します。
奪い合うローカルのものが何も残らないからです。デーモンは実行の
エンドポイントに、そのことを伝える 503 で応答します。実行が有効な間、
チャートは 1 を超える replicaCount を拒否します。それぞれが
自分だけの実行ストアを持つ Pod を起動するよりよいからです。
helm install mcpd ./deploy/helm/mcpd \
--set keys.ingest=$INGEST_KEY \
--set keys.read=$READ_KEY \
--set externalStore.type=postgres \
--set externalStore.postgresUrl=postgres://mcpd:$PGPASSWORD@db:5432/mcpd \
--set runs.enabled=false \
--set replicaCount=3 まず Postgres を選んでください。たいていのチームがすでに運用し、 バックアップし、監視しているデータベースの上で、2 つの制限を外せます。 マイグレーションはアドバイザリーロックを取り、スキーマが最新なら 飛ばされるので、複数のレプリカが同時に起動しても安全です。保持期間が 数十億スパンで測られるようになったら ClickHouse です。type が名指し しないバックエンドの URL は、無視されるのではなく拒否されます。
バックエンドを変えても変わらないことが 1 つあります。
保持期間が覆うのは、書き留めたプロジェクトだけです。
1 時間ごとの処理は、--project が名指しするプロジェクトと、
projects ブロックのすべてのプロジェクトで、
--retention-days より古いシグナルを削除します。
retention_days を持つプロジェクトはその値で削除されます。
どちらにも現れないプロジェクト名で送られたトレースは期間を持たず、
一度も削除されません。エージェントが自分でプロジェクト名を決めている
なら、量に気を配ってください。