Docker와 Kubernetes
프로세스 하나, 볼륨 하나, 백업할 파일 하나입니다. 이 페이지에서 가장 긴 절은, 다른 누군가가 닿을 수 있게 되기 전에 무엇을 설정해야 하는지에 관한 것입니다.
컨테이너 하나
아직 릴리스가 없습니다.
이 사이트의 명령이 가리키는 이미지, 차트, 패키지는 예정된 배포물이며 아직 아무것도 공개되지 않았습니다. 현재 동작하는 방법은 소스에서 빌드하는 것이고, Rust와 Node가 필요합니다.
빌드하고 실행
git clone https://github.com/mcpd/mcpd && cd mcpd && make build && ./target/release/mcpd demo mcpd는 스토리지를 함께 컴파일해 넣은 프로세스 하나입니다. 옆에서 돌릴 데이터베이스도, 사이드카도, 정적 파일 서버도 없습니다. 수집과 쿼리 API와 인터페이스가 모두 포트 하나에서 서비스됩니다.
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용으로 빌드되며, 이미지에서 /data를
소유한 uid 10001로 실행됩니다.
포트, 그리고 그 위에서 답하는 것
모든 것이 4317에 있습니다. 배포가 컨테이너 하나로 끝나는 이유가 그것이고, 인그레스 규칙이나 네트워크 정책을 쓸 때 알아야 할 것도 그것입니다. 경로마다 위험이 같지 않기 때문입니다.
| 경로 | 무엇인가 | 필요한 키 |
|---|---|---|
/v1/traces, /v1/logs, /v1/metrics | OTLP 수집. 어떤 OpenTelemetry SDK든 씁니다 | 수집 |
/v1/ingest | 네이티브 수집. 요청 하나에 전체 모델이 담깁니다 | 수집 |
/v1/api/… | 쿼리 API. 트레이스, 스팬, 로그, 점수, 분석입니다 | 읽기 |
/ | 바이너리에 들어 있는 운영자 인터페이스 | 없음 |
/healthz, /readyz, /metrics | 라이브니스, 레디니스, 그리고 mcpd 자신의 Prometheus 카운터 | 없음 |
수집 요청은 32 MiB로 묶여 있습니다. 프롬프트는 크기 때문에 이 한계는 일부러 넉넉하게 두었지만, mcpd 앞에 선 프록시는 보통 더 작은 한계를 가지고 있습니다. 3 MiB짜리 배치를 거부하는 리버스 프록시가 트레이스가 도착하지 않는 가장 흔한 이유입니다.
Compose
이 파일에서 설명할 값이 있는 결정이 둘 있습니다. 둘 다 빠뜨린 것처럼 보이기
때문입니다. 포트는 127.0.0.1에 게시되므로, 아직 키를
설정하는 동안 호스트 밖에서는 아무것도 닿을 수 없습니다. 그리고 이 파일에
헬스체크가 없는 것은 이미지에 이미 있기 때문입니다. 이미지 안의 busybox
wget으로 MCPD_LISTEN의 포트에 30초마다
/healthz를 요청하고, 그 결과는 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가 서비스 시작을 거부하므로, 잊어버린 키가 조용히 열린 포트가
아니라 오류 메시지가 됩니다. 실행 키 두 개는 파일 안에 값이 아예 없으므로,
Compose는 .env가 설정할 때만 그것들을 넘깁니다. 빈 값은 키가
없는 것이 아니라 빈 키 하나로 데몬에 전달되기 때문입니다.
Kubernetes
객체는 넷입니다. Deployment, Service, PersistentVolumeClaim, 그리고 직접
만드는 Secret입니다. Deployment는 Recreate 전략으로 레플리카 하나를 돌리며, 이
매니페스트에서 바꾸지 말아야 할 한 줄이 바로 그것입니다. mcpd는 모든 것을
SQLite 파일 하나에 저장하고 프로세스 하나가 그것을 쓰는데, 롤링 업데이트는
첫 번째 파드가 아직 볼륨을 쥐고 있는 동안 같은 볼륨 위에 두 번째 파드를
띄웁니다. 그것은 확장에 관한 이야기가 아니라 손상에 관한 이야기입니다.
Secret은 kubectl apply가 읽는 파일에 들어 있지 않습니다.
secret.yaml.example이 그 형태를 보여 주고, 확장자가 그것을
apply에서 빼 둡니다. 적힌 그대로 적용된 Secret은 자리표시 문자열을 그대로
키로 만들기 때문입니다. 대신 그 자리에서 만든 키로 명령줄에서 생성하십시오.
파드는 uid 10001로, 읽기 전용 루트 파일시스템과 capability 없이, 런타임의
기본 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/
세 키는 팀이 비밀을 두는 곳에 보관하십시오. 수집 키는 모든 에이전트에게,
읽기 키는 인터페이스를 읽는 사람에게, 관리자 키는 데이터를 지울 수 있는
사람에게 갑니다. 워커가 클러스터 밖에서 돌게 되면 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는 이 둘을 환경에서 읽으므로, 계측된 코드는 고칠 것이 없습니다.
MCPD_ENDPOINT=http://mcpd.mcpd.svc:4317 MCPD_API_KEY=$INGEST_KEY 프로브
두 엔드포인트는 의도적으로 서로 다른 일을 하며, 둘을 바꿔 달면 구체적이고 보기 흉한 실패가 납니다.
-
/healthz는 스토리지를 절대 건드리지 않습니다. 프로세스 상태만 보고 답합니다. 데이터베이스에 질의하는 라이브니스 프로브는 큰 쿼리 하나가 데몬을 느리게 만들 때마다 건강한 데몬을 재시작시킬 것이고, 쓰기 선행 로그를 비우는 중에 재시작하는 것이야말로 바라지 않는 일입니다. -
/readyz는 스토어에 핑을 보내고, 닿지 못하면"status": "degraded"와 무엇이 잘못되었는지 적은 줄을 담아 503으로 답합니다. 그래서 서비스할 수 없는 인스턴스에서 트래픽은 비켜 가되, 그 인스턴스를 죽이지는 않습니다.
스타트업 프로브가 있는 이유는 마이그레이션이 시작할 때 돌기 때문입니다. 마이그레이션은 멱등이고 보통은 즉시 끝나지만, 큰 데이터베이스에서는 라이브니스 프로브가 참아 줄 시간보다 오래 걸릴 수 있고, 시도할 때마다 마이그레이션 도중에 죽는 파드는 영영 시작하지 못합니다.
무엇을 얼마나 줄 것인가
이 숫자들은 벤치마크가 아니라 프로세스가 실제로 하는 일에서 나왔고, 어떤 벤치마크도 주장하지 않습니다.
| 리소스 | 값 | 이유 |
|---|---|---|
| CPU request | 100m | 놀고 있을 때 드는 비용은 거의 없습니다. 이 요청은 스케줄러가 시끄러운 것 아래에 이것을 쌓지 않게 하려고 있습니다. |
| CPU limit | 없음 | 수집은 몰려서 들어옵니다. 몰리는 동안 기록자를 조이면 클라이언트 큐가 차고 스팬이 버려집니다. |
| Memory request | 128Mi | 정적 바이너리 하나, SQLite 커넥션 다섯 개와 그 페이지 캐시입니다. |
| Memory limit | 512Mi | 동시 수집을 위한 여유입니다. 요청 하나가 32 MiB의 프롬프트 텍스트를 쥘 수 있으므로, 메모리는 큰 배치가 한꺼번에 몇 개나 도착하는지를 따라갑니다. |
| 볼륨 | 20Gi, ReadWriteOnce | 실제 페이로드로 크기를 잡으십시오. 텍스트가 지배적이고, 검색 인덱스는 외부 내용을 참조하는 방식이 아니므로 인덱싱된 프롬프트와 컴플리션은 두 번째로 한 번 더 저장됩니다. |
볼륨은 로컬 블록 장치에 두십시오. SQLite의 잠금은 거기에서만 올바릅니다. NFS 같은 공유 파일 시스템에서는 잠금이 잘해야 권고 수준이고, 그 실패는 오류로 나타나지 않습니다. 몇 주 뒤에 손상된 데이터베이스로 나타납니다.
Helm 차트
이 차트는 같은 객체 넷을 값으로 감싸 렌더링하고, 값이 켜 주기 전까지는 꺼져 있는 여섯 가지를 더합니다. Ingress, NetworkPolicy, PodDisruptionBudget, ServiceAccount, ServiceMonitor, 그리고 설정 파일을 담는 ConfigMap 또는 Secret입니다. 릴리스마다 차트를 이미지와 같은 레지스트리에 올립니다. 키는 먼저 만들어서 팀이 비밀을 두는 곳에 보관하십시오. 설치가 키를 다시 출력해 주지는 않습니다.
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의 체크섬을 지니고 있으므로, 키를 교체하고
helm upgrade를 돌리면 파드가 다시 시작합니다. 그것이 없으면 새
키는 클러스터에 놓여 있고 데몬은 옛 키를 계속 강제합니다.
모니터, SLO, 판정기, 프로젝트별 키, 실행 저장소의 타이밍은 플래그가 아니라
데몬의 설정 파일에서 읽습니다. 그 JSON 객체를 configFile.content
아래에 쓰면 차트가 그것을 읽기 전용으로 마운트하고
MCPD_CONFIG_FILE이 그것을 가리키게 합니다. 프로젝트별 키, 판정기의
api_key, forward 헤더는 자격 증명입니다. 그래서 차트는 그중
하나라도 담은 내용을 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: {} 다른 무엇이 닿을 수 있게 되기 전에
수집은 기본으로 열려 있습니다. 노트북에서 보내는 첫 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는 시작을 거부하는 대신 경고합니다. 거부하면 이것을 돌릴 값이 있게 만드는 그 길이 끊기기 때문입니다. 경고가 곧 계약입니다. 배포 로그에서 이것을 보았다면 그 배포는 열려 있습니다.
키 여섯, 스코프 여섯
--ingest-key는 시그널 쓰기를 허용합니다.
--read-key는 읽기를 허용합니다. --write-key는
저장된 데이터를 바꾸는 일, 즉 점수, 피드백, 데이터셋, 실험, 확인 처리를
허용합니다. --execute-key는 실행을 시작하고 취소하고 신호를 보내는 것을 허용합니다. --worker-key는 스텝을 가져가고 결과를 보고하는 것을 허용하며, 스텝 본문을 실행하는 프로세스에 두는 키가 이것입니다. --admin-key는 트레이스 삭제와 감사 로그 읽기를
허용합니다.
폭발 반경을 정하는 것은 첫 번째 구분입니다. 두 집단의 크기가 다르기 때문입니다. 모든 에이전트 프로세스에는 수집 키가 필요하지만, 나머지 가운데 어느 것이든 필요한 사람은 훨씬 적습니다. 읽기까지 되는 키 하나를 전체 프로세스에 쥐여 주면, 스팬을 내보내는 모든 컨테이너에, 그중에는 서드파티 코드를 돌리는 컨테이너도 있는데, 저장된 모든 프롬프트에 대한 자격 증명을 넣어 두는 셈이 됩니다.
실행을 데이터셋으로 담는 일이 쓰기 스코프로 옮겨 간 것도 그 때문입니다. 그 일은 지나가면서 실행의 입력과 출력을 읽으므로, 에이전트 프로세스들이 들고 다니는 키로 그것을 인가하면 그 컨테이너 하나하나에 저장된 프롬프트를 복사해 낼 방법을 쥐여 주게 됩니다.
각 플래그는 쉼표로 구분한 목록을 받고, 각각 환경 변수가 있습니다.
MCPD_INGEST_KEYS, MCPD_READ_KEYS,
MCPD_WRITE_KEYS, MCPD_ADMIN_KEYS입니다. 하나만 쓰지
마십시오. 팀별 또는 무리별로 키를 두면, 새어 나간 키는 목록에서 지우는 것으로
무효가 되고, 스팬을 내보내는 모든 것을 다시 배포할 필요가 없습니다. 키는
Authorization: Bearer …로 또는 X-Mcpd-Key 헤더로
제시되며, 상수 시간에 비교됩니다.
알아 둘 비대칭은 새로 생긴 두 플래그를 설정하지 않았을 때 벌어지는
일입니다. 설정하지 않은 쓰기 목록은 수집 키를 받아들이고, 설정하지 않은
관리자 목록은 읽기 키를 받아들이므로, 업그레이드가 잘 쓰이던 키를 무효로
만드는 일은 없습니다. --admin-key를 설정하기 전까지 읽기 키는
엄격한 읽기 전용 키가 아니라 읽고 지우는 키이며, 데몬은 시작할 때 그렇게
말합니다. 그 점을 감안해 나눠 주거나, 플래그를 설정하십시오.
누가 무엇을 읽었는가
페이로드를 돌려주는 핸들러는 그 접근을 기록합니다. 트레이스 상세, 스팬 검색과 로그 검색, 피드백, 데이터셋 항목, 캡처, 삭제입니다. 한 행에는 시각, 동작한 키의 짧은 다이제스트, 동작, 프로젝트, 리소스, 그리고 인가가 허용했는지 여부가 담깁니다. 키 자체는 절대 기록되지 않으므로, 이 로그가 자격 증명이 머무는 두 번째 장소가 되지는 않습니다.
로그는 GET /v1/api/audit로 읽으며, 이 엔드포인트는 admin
스코프를 요구합니다. 이것에 기대기 전에 알아 둘 성질이 둘 있습니다. 이 로그는
--retention-days로 정리되지 않습니다. 자기가 설명하는 데이터와
같은 일정으로 스스로를 지우는 로그는, 이미 사라진 데이터를 누가 읽었는지 말해
줄 수 없기 때문입니다. 그리고 행을 쓰는 일이 그 행이 설명하는 읽기를
실패시키는 일은 없습니다. 쓸 수 없는 행은 /metrics의
mcpd_audit_write_failures_total로 집계되며, 알림을 걸어야 할
카운터가 그것입니다.
키가 덮지 않는 것
- 인터페이스.
/의 마크업은 포트에 닿을 수 있는 누구에게나 서비스됩니다. 그 뒤의 데이터는 그렇지 않습니다. 인터페이스가 보내는 모든 요청은 쿼리 API를 지나가고, 쿼리 API는 읽기 키를 강제합니다. 데몬 안에 로그인 화면은 없으므로, 서비스가 아니라 사람이 접근해야 한다면 이미 돌리고 있는 신원 프록시 뒤에 두십시오. - 프로브와
/metrics. 일부러 인증 없이 둡니다. kubelet과 Prometheus 스크레이퍼는 키를 들고 다닐 수 없기 때문입니다. 이들은 카운터와 가동 시간을 내보낼 뿐 페이로드는 절대 내보내지 않습니다. - 전송. mcpd는 평문 HTTP로 말하며 TLS를 종단하지 않습니다. 인그레스나 메시에서 종단하십시오. 프롬프트와 컴플리션은 그 구간을 그대로 건너갑니다.
- 마스킹된 페이로드 안의 해시.
--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
여기에서 중요한 줄은 여섯입니다. auth 줄 가운데 하나라도 open이라고
적혀 있다면, 그 스코프는 포트에 닿을 수 있는 누구에게나 열려 있습니다. 괄호
안의 내용은 그 스코프가 물려받고 있는 더 넓은 목록의 이름입니다. 위의 호출은
--admin-key를 설정하지 않았으므로, 그 읽기 키는 읽기뿐 아니라
삭제도 합니다. --execute-key와 --worker-key도
설정하지 않았으므로, 그 수집 키는 실행을 시작하고 스텝을 가져갈 수도 있으며,
mcpd는 시작할 때 이를 경고합니다. 여기 출력되는 모든 플래그는
설정에 적혀 있습니다.
백업
백업은 파일 하나입니다. 검색 인덱스를 포함해 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을 돌리십시오.
업그레이드
태그를 바꾸고 다시 시작하십시오. 마이그레이션은 시작할 때 돌고 매번 돌려도 안전하므로, 프로세스 바깥에서 벌어지는 단계는 없습니다. 그래도 백업은 먼저 뜨십시오. 마이그레이션은 데이터 아래에서 스키마가 바뀌는 유일한 순간입니다.
재시작은 롤링이 아니라 짧은 중단이며, 레플리카가 하나인 것과 같은 이유입니다. 에이전트는 그것을 견딥니다. SDK는 메모리에 쌓아 두고 재시도하며 그 큐에는 상한이 있으므로, 몇 초로 끝나는 재시작은 아무 대가도 치르지 않고, 한 시간이 걸리는 재시작은 가장 오래된 스팬을 버리고 클라이언트 로그에 그렇게 적습니다.
mcpd는 SIGTERM을 처리하고 정상적으로 종료하며, Kubernetes가 보내는 신호가
그것입니다. terminationGracePeriodSeconds는 가장 긴 요청보다
길어야 합니다. 그렇지 않으면 kubelet이 쓰기 도중에 프로세스를 죽입니다.
요청은 30초짜리 요청 타임아웃만큼 걸릴 수 있으므로, 매니페스트와 차트는
파드에 45초를 주고, compose 파일도 컨테이너에 같은 시간을 줍니다. 둘은 함께
올리십시오.
SQLite 파일 하나가 한계에 닿는 곳, 그리고 그때 할 일
내장 파일은 옳은 기본값이고, 넘을 수 없는 한계가 둘 있습니다. 둘 다 mcpd가 아니라 SQLite의 것입니다.
- 기록자는 하나입니다. 읽기는 자기 커넥션에서 돌고 기록자를 막지 않습니다. WAL 모드가 있는 이유가 그것입니다. 다만 파일은 프로세스 하나가 소유합니다. 그 인스턴스는 옆으로가 아니라 위로 키웁니다.
- 볼륨 하나, 가용 영역 하나. ReadWriteOnce 클레임은 파드를 한곳에 묶습니다. 데이터를 온전히 지닌 채 다른 노드로 넘어가는 장애 조치는 없습니다.
보통은 새 백엔드보다 두 설정이 더 많은 것을 되사 줍니다. 샘플링은 트레이스의 일부만 남기되 남긴 트레이스는 통째로 남기며, 트레이스 id를 해시해 판단하므로 무리 안의 모든 인스턴스가 같은 결론에 이릅니다. 보존은 파일이 얼마나 옛날까지 거슬러 갈지를 묶습니다. 팀마다 인스턴스 하나씩을 각자의 볼륨과 함께 돌리는 것도 되고, 시끄러운 팀 하나가 모두의 디스크를 채우는 일도 막아 줍니다.
그것들이 바닥나면 mcpd를 데이터베이스로 향하게 하십시오. 차트에서
externalStore.type을 postgres나
clickhouse로 설정하고, 연결 문자열은
externalStore.postgresUrl이나
externalStore.clickhouseUrl에 넣습니다. 연결 문자열은 파드
스펙이 아니라 차트의 Secret에 담깁니다. values에서 아예 빼 두고 싶다면 직접
관리하는 Secret의 postgres-url이나 clickhouse-url
아래에 넣고, 그 Secret 이름을 keys.existingSecret에 적고, 타입은
그래도 설정하십시오. 백엔드를 고르는 것은 URL이 아니라 타입입니다.
그러면 시그널은 옮겨 가고, 디스크에는 파일 하나가 남습니다. 실행 저장소입니다.
시그널 저장소가 무엇이든 실행 저장소는 내장 SQLite입니다.
runs.enabled가 기본값인 true이면 차트는 그것을 볼륨에 두고
레플리카 하나를 돌립니다. 파드를 여러 개 돌리려면 실행 기능을 끄십시오.
그러면 차트는 replicaCount만큼 파드를 돌리고,
Recreate에서 롤링 업데이트로 바꾸고, PersistentVolumeClaim을
뺍니다. 다툴 로컬 파일이 더 이상 없기 때문입니다. 데몬은 실행 엔드포인트에
그렇게 말하는 503으로 답합니다. 실행 기능이 켜져 있는 동안 차트는 1보다 큰
replicaCount를 거부합니다. 파드마다 자기만의 실행 저장소를 갖게
띄우는 대신입니다.
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에 먼저 손을 뻗으십시오. 대부분의 팀이 이미 돌리고 백업하고 감시하는 데이터베이스 위에서 두 한계를 함께 풀어 줍니다. 마이그레이션은 어드바이저리 락을 잡고 스키마가 최신이면 건너뛰므로, 여러 레플리카가 함께 시작해도 안전합니다. 보존 기간을 수십억 스팬 단위로 재게 되었을 때 ClickHouse에 손을 뻗으십시오. 타입이 가리키지 않는 백엔드의 URL은 무시되지 않고 거부됩니다.
백엔드가 바뀌어도 달라지지 않는 것이 하나 있습니다.
보존은 적어 둔 프로젝트를 덮습니다. 한 시간 주기
처리는 --project가 지목한 프로젝트와 projects
블록의 모든 프로젝트에서 --retention-days보다 오래된 시그널을
지우며, 각 프로젝트가 retention_days를 정했으면 그 값을
씁니다. 두 곳 어디에도 없는 프로젝트 이름으로 보낸 트레이스는 기간이 없어
절대 정리되지 않으므로, 에이전트가 프로젝트 이름을 직접 정한다면 양을 지켜
보십시오.
다음
- 모든 플래그와 환경 변수: 설정.
- 프로브, 카운터, 그리고 매일의 운영: 운영하기.
- 디스크에 절대 닿지 않는 것: 마스킹과 보존 기간.
- 아직 아무것도 배포하지 않았다면: 빠른 시작.