ClickStack & HyperDX HOLs
GitHub

labs/privatelink-gateway

A validation plan for one topology: OpenTelemetry from many VPCs in several AWS accounts, sent to ClickHouse Cloud through one gateway collector and one AWS PrivateLink endpoint, with the service closed to the public internet. The point is to measure it before a team builds it in its own accounts, so the configuration they are handed has been run once.

Last run: 2026-10-14, terraform apply → bin/setup.sh → checks → terraform destroy in ap-northeast-2, one AWS account (Results). Changed since, without a re-run: K2's wait for gw-default's queue to drain before counting, and the consumers' earliest start in terraform/user_data.sh.tftpl on a freshly created cluster (the second K1–K4 run used earliest through bin/check.sh), #106. V8's overload control (#108) and K7 have not run.

The topology

service VPC · account A ─┐   apps: OTel SDK (OTLP) · agent collector scraping Prometheus exporters
service VPC · account B ─┼─→ Transit Gateway
other service VPCs ──────┘        │
                                  ▼
               central VPC ┌─────────────────────────────────────────────────────┐
                           │ internal NLB :4317/:4318                            │
                           │   → gateway collector (queue · retry · batch)       │
                           │   → interface VPC endpoint (the only way out)       │
                           └─────────────────────────────────────────────────────┘
                                  │  AWS PrivateLink
                                  ▼
               ClickHouse Cloud service — endpoint ID on its allow list, IP access list: deny all

The conditions it is built for: a private-only policy, Transit Gateway already joining the accounts, about ten VPCs in two accounts, and all three signals (metrics, logs, traces). Some teams already instrument with the OTel SDK or Prometheus exporters, and others are new to OpenTelemetry.

What the documentation already settles

Read on 2026-10-14. These are vendor documentation, not measurements; the plan below checks the ones the topology depends on.

Fact Source
PrivateLink is available on the Scale and Enterprise plans AWS PrivateLink
Steps: get endpointServiceId and privateDnsHostname (console or the privateEndpointConfig API), create an interface endpoint, add its endpoint ID to the service's allow list same page
DNS is the customer's: point privateDnsHostname at the endpoint's network interfaces. The page's CloudFormation and Terraform examples turn the endpoint's own private DNS off same page
Security group: ports 443, 8443, 9440, 3306 same page
Cross-region endpoints are supported for the listed regions, not for ap-east-2 or mx-central-1. AWS charges cross-region transfer same page
PrivateLink carries only connections initiated from your VPC to ClickHouse Cloud same page
IP access lists apply only to public-internet connections, not PrivateLink. To accept PrivateLink only, set the list to deny all. IPv4 only. Default: allow from anywhere IP access lists
A collector of your own sends to Managed ClickStack. The ClickStack distribution is configured by CLICKHOUSE_ENDPOINT (the full HTTPS URL with port 8443), user and password. Use a dedicated ingestion user. Batch at least 1,000 rows (the example uses 10,000) ClickStack: OTel collector
ClickHouse exporter: traces and logs beta, metrics alpha. Batch inside sending_queue.batch rather than the batch processor. The queue is in memory unless sending_queue.storage names a storage extension. retry_on_failure.max_elapsed_time defaults to 300 s clickhouseexporter README on main. Check it against the collector version you actually run

The docs leave these open, and the plan measures them:

Checks

Every check has a positive and a negative control. Data checks are SQL against otel_traces, otel_logs and otel_metrics_*, by a verify.run_id resource attribute the way _base/bin/verify.sh does it. A UI screenshot is not a result.

# What Positive Negative
V1 Endpoint and allow list from the central VPC, SELECT 1 over privateDnsHostname:8443 succeeds remove the endpoint ID from the service's allow list: the same query fails (record the error)
V2 DNS stays in the central VPC privateDnsHostname resolves to the endpoint's private IPs in the central VPC. The NLB's DNS name resolves to private IPs from a service VPC from a service VPC, privateDnsHostname does not resolve to the endpoint's IPs, so apps never need that record
V3 Public path closed IP access list set to deny all: V1 still succeeds the public hostname from a host on the internet fails
V4 Through Transit Gateway telemetrygen in a service VPC of the other account sends to the NLB, and the rows arrive remove the security-group rule for 4317: no rows with that run_id arrive (telemetrygen still exits 0, so the count is the evidence)
V5 All three signals N traces, N logs, N metric points with one run_id: each table counts N for that run_id a run_id that was never sent counts 0 in each table
V6 Prometheus path an agent collector scrapes an exporter in a service VPC, and its series appear in otel_metrics_gauge / otel_metrics_sum stop the exporter: no new points with a value after the stop time (the first failed scrape writes one staleness marker per series, Flags bit 1)
V7 ClickHouse unreachable for longer than 300 s gateway with a file-storage queue and a raised max_elapsed_time: send N during the outage, restart the gateway once mid-outage, restore. The count reaches N the same with defaults (memory queue, 300 s): the count stays below N. Record how far below
V8 Load and delay at a fixed rate for 10 minutes: the time from the generator's last send to the count reaching N, and the gateway's CPU and memory from its own telemetry (:8888) a run at a rate the gateway cannot sustain: the queue grows (record where, and what is dropped)
V9 People can reach the UI with the IP access list at deny all, run a query in the SQL console and open the Managed ClickStack search. Record what happens the same with an allow entry for the tester's IP, to tell an access-list effect from anything else

V7 is the outage the topology must survive. The central VPC's one endpoint is the only way out, so anything that breaks it stops every team at once.

Option: a Kafka buffer

The gateway's file-storage queue covers a short outage and a restart (V7). It lives on one node, so it does not cover the loss of that node, an outage longer than its disk holds, replaying data, or a second consumer. If any of those becomes a requirement, put Kafka (Amazon MSK) in the central VPC.

service VPCs ─→ Transit Gateway ─→ central VPC
                                   internal NLB :4317/:4318
                                     → ingest collector    OTLP in → kafka exporter (otlp_proto)
                                     → MSK                 otlp_spans · otlp_logs · otlp_metrics
                                     → consumer collectors kafka receiver → clickhouse exporter
                                     → interface VPC endpoint → PrivateLink → ClickHouse Cloud

Apps keep the same OTLP endpoint, and the only connection to ClickHouse Cloud is still the one the VPC opens. What the components' READMEs say (contrib main, read 2026-10-14; check against the version you run):

Not the first choice: ClickPipes pulling from MSK. ClickPipes can read MSK privately through a reverse private endpoint over MSK multi-VPC connectivity, which is not cross-region (ClickPipes PrivateLink, read 2026-10-14). That removes the consumer collectors, at two costs:

Kafka checks (run after V1–V9, on the same environment plus MSK):

# What Positive Negative
K1 Same data through Kafka V5 through the Kafka path: N per signal for one run_id a run_id never sent counts 0
K2 Outage longer than the gateway's disk would hold ClickHouse unreachable for an hour: consumer lag grows, then drains on restore, and the count reaches N V7's file-storage gateway, same outage: record what it kept and what it lost
K3 Consumer dies mid-export message_marking.after: true, kill the consumer while it exports: the count reaches at least N. Record the duplicates (count − uniqExact of the record key) message_marking.after: false, same kill: the count stays below N
K4 Replay reset the consumer group to earliest into a separate database: its counts equal the original's for the run_id a consumer group with initial_offset: latest started after the send reads nothing of it
K5 Losing one consumer two consumers in the group, stop one: rebalance, the count reaches N — (K3 covers loss)
K6 Spike a burst above the insert rate: lag grows, then drains. Record peak lag and the time to drain the same burst without Kafka (V8's gateway): record where it queues or drops
K7 ClickPipes variant (hypothesis) otlp_json topics through a ClickPipe over the reverse private endpoint into a raw table, then materialized views into otel_*: counts equal K1's — run only if K1–K6 make Kafka a requirement

Kafka is worth it when K2, K3 or K4 meets a requirement the gateway's file-storage queue cannot (V7). Otherwise it is one more cluster to run.

Test environment

The smallest build that answers the checks. Everything is in one region, the same as the Cloud service. A cross-region endpoint is a separate question.

Part What Why
ClickHouse Cloud one Scale service, a database and an ingestion user of its own PrivateLink needs Scale or above
Central VPC interface endpoint in two AZs, a Route 53 private hosted zone for privateDnsHostname associated with this VPC only, an internal NLB, a gateway collector on one EC2 instance V1–V3, V7, V8
Service VPC, same account telemetrygen, an agent collector, one Prometheus exporter V5, V6
Service VPC, second account telemetrygen only V4: the Transit Gateway shared across accounts. One account proves the routing but not the sharing
Transit Gateway attachments for the three VPCs V4
MSK, Kafka checks only the smallest provisioned cluster in the central VPC, an ingest collector, two consumer collectors K1–K6. K7 adds a ClickPipe and its reverse private endpoint

Collector images and telemetrygen are pinned when the lab is built and recorded in every result line, with the ClickHouse version the service reports.

Cost before running. Line items to estimate from the AWS Price List and the ClickHouse Cloud pricing page, for the hours the run needs: - the Scale service - the interface endpoint (per AZ-hour and per GB) - the NLB - the Transit Gateway (per attachment-hour and per GB processed) - the EC2 instances - cross-AZ transfer - for the Kafka checks: MSK broker-hours and storage. For K7: ClickPipes compute

Estimated before the 2026-10-14 run (AWS Price List API for ap-northeast-2, read that day):

Item Rate Per hour
ClickHouse, 2 compute units (2 × 8 GiB) the pricing page lists no Scale rate for Seoul; its Enterprise rate, $0.4801 per unit-hour, as a ceiling ≤ $0.960
Transit Gateway, 3 VPC attachments $0.07 each $0.210
EC2 t4g.small × 3 $0.0208 each $0.062
NLB $0.0225 + LCU ≈ $0.030
Interface endpoint, 2 AZs $0.013 each $0.026
Public IPv4 × 3 $0.005 each $0.015
Base ≈ $1.31
Kafka: MSK kafka.t3.small × 2, 2 more instances and addresses, storage $0.0569 per broker ≈ $0.17 more

Data processing ($0.01/GB at the endpoint, $0.02/GB at the Transit Gateway) came to cents at these volumes. The actual run is under Results. Nothing is launched without the owner's go-ahead.

How to run

Ran end to end on 2026-10-14 (Results). Before that, terraform validate and otelcol-contrib 0.155.0 validate passed and each failed when a fault was injected, and the SQL and the V5 and V7 controls ran once against a local ClickHouse 26.9.7.9 container.

Path What
terraform/ the ClickHouse service and ip_access, three VPCs and the Transit Gateway, the endpoint and its private hosted zone, the NLB, EC2 with SSM, and MSK behind enable_kafka
collector/ gateway-durable (file-storage queue, long retry), gateway-default (defaults), agent, ingest-kafka, consumer-kafka — otel/opentelemetry-collector-contrib:0.155.0
bin/ setup.sh (ingest user and databases), check.sh <V1…V8, K1…K6>, outage.sh block/unblock
sql/ 00-ingest-user.sql (the ingest user's grants, sent by setup.sh), counts.sql and dupes.sql (row counts and duplicate spans per run, used by check.sh)
results/ each check.sh run writes its result lines to <UTC date>-<id>.txt here
export AWS_PROFILE=<profile>
export CLICKHOUSE_CLOUD_API_KEY=<key>
export CLICKHOUSE_CLOUD_API_SECRET=<secret>
export CLICKHOUSE_ORG_ID=<org id>
cd labs/privatelink-gateway/terraform
cp terraform.tfvars.example terraform.tfvars    # set clickhouse_org_id
terraform init && terraform plan -out=tf.plan && terraform apply tf.plan
cd ../bin && ./setup.sh && ./check.sh V1        # … V8, then enable_kafka = true and K1 … K6
cd ../terraform && terraform destroy

All SQL runs on the gateway through SSM, because the service accepts nothing from the public internet. There is no SSH and no NAT. Instances take a public IP only to pull packages and images.

Found while building, and kept in the plan: - create_schema: true stops a collector from starting while ClickHouse is unreachable. In V7 a gateway restarted mid-outage cannot accept OTLP until the outage ends; its persisted queue still replays afterwards. A production gateway should create the schema once and run with create_schema: false. - A generator's own count is not evidence. telemetrygen v0.155.0 emits two spans per trace even with --child-spans 0, counts --rate per worker, and at an unthrottled rate reported far more traces than arrived at a gateway that refused nothing. The checks count what the receiver accepted. - One AWS account. V4 proves Transit Gateway routing between VPCs, not sharing it across accounts. - Passwords reach the instances through user data and SSM commands. That is acceptable for a lab torn down the same day, not for anything kept.

Found while running (2026-10-14), and fixed where it says so: - ClickHouse Cloud rejects a SQL user's password without a special character (CREATE USER, Code 36, "contain at least 1 special character"), while the service API took the alphanumeric default password. bin/setup.sh stopped there. Fixed: random_password.ingest now has two specials from -_.~. - ip_access = [] is stored as [] and means deny all: the public hostname reset the connection (curl: (35)) and, with the tester's /32 added, the same request from the same machine returned 200 (V3, V9). - A revoked and re-authorized security-group rule comes back with a new ID, so the next terraform plan wants to create it again, and the apply would fail on a duplicate. Before each later plan the rule was removed from state and imported by its new ID. terraform destroy is not affected (#107). - create_schema: true measured: the gateway restarted mid-outage failed to start 10 times (create database: failed to query server hello) and started 15 s after the outage ended (V7). - A full memory queue refuses at the receiver: gw-default accepted exactly its 1000 requests and refused the rest, so accepted never exceeds the queue. Requests waiting in the queue did not age: after a 600 s outage it delivered 4910 of 5000 accepted traces. With 200 traces, all of them in flight at once, it delivered none (V7). That the 300 s clock starts only when a consumer takes a request is read from these numbers, not from the exporterhelper source. K2's control was rewritten to use the refused count. - telemetrygen retries refused batches past --duration; a synchronous send to the full gw-default held past the SSM limit. K2 now runs the gateway sends detached and removes them after the send window. - Kafka consumers with initial_offset: latest skip data on a partition with no committed offset. The topics are auto-created by the first produce, after the consumers join: K1 counted 0 of 200 per signal, K3's positive lost half, and K4's original group counted 0 logs and 0 metrics while the replay group (earliest) read 200 of each. Fixed: the default group starts from earliest, which applies only where it has no committed offset. - The Prometheus receiver writes a staleness marker per series at the first failed scrape (363 points, Flags bit 1, no value). V6's negative now counts only points with a value. - One t4g.small telemetrygen tops out near 8,700 spans/s (asked for 20,000). V8's overload control needs more generators than this build has.

Results

Run on 2026-10-14 in ap-northeast-2, one AWS account. Versions, as each result line records them: ClickHouse Cloud 26.6.1.2326 (SELECT version()), a service of 2 replicas × 8 GiB with idle scaling; collector otel/opentelemetry-collector-contrib:0.155.0; telemetrygen v0.155.0; prom/node-exporter:v1.12.1; MSK Kafka 3.9.x on 2 × kafka.t3.small; Terraform 1.16.4 with hashicorp/aws 6.68.0 and ClickHouse/clickhouse 3.38.0. Managed ClickStack was used through the console; its version is not shown there and was not recorded.

Verified on ClickHouse Cloud 26.6.1.2326, Managed ClickStack (version not recorded) and otel/opentelemetry-collector-contrib:0.155.0, 2026-10-14: V1–V7, V8 positive, V9, K1–K6.

# Result What was measured
V1 PASS SELECT 1 over the private hostname from the central VPC. Endpoint ID removed from the allow list (API 200): curl: (35) Recv failure: Connection reset by peer. Re-added: works again
V2 PASS central VPC: the private hostname resolves to the endpoint's two IPs. svc_a: the NLB name resolves to 10.60.x addresses; the private hostname resolves to nothing
V3 PASS API reports ipAccessList: []; PrivateLink still answers. Public hostname from the tester's machine: curl: (35) connection reset, and HTTP 200 once that machine's /32 was allowed (V9)
V4 PASS 200 of 200 traces from svc_b through the Transit Gateway and the NLB. 4317 rule revoked: 0 rows (telemetrygen exit 0). Rule back: sends again
V5 PASS one run_id: 200 traces (400 spans), 200 logs, 200 gauge points. A never-sent run_id: 0 in each
V6 PASS, second run 240 gauge and 107 sum metric names from node-exporter. After the stop: 0 points with a value, 363 staleness markers. The first run counted the markers and failed
V7 PASS outage 360 s. gw-durable, restarted once mid-outage: 200 of 200 (400 spans, 0 duplicates). gw-default: 0 of 200
V8 positive PASS, negative not reached 2000 spans/s for 600 s: 599,335 traces accepted and counted, the full count 16 s after the last send; gateway (t4g.small) 2–7 % CPU, 68–74 MiB container memory, peak queue 3,700 items. Asked for 20,000 spans/s: 523,139 traces in 120 s (≈ 8,700 spans/s), gateway ≈ 18 % CPU, peak queue 11,500 items, nothing refused or lost
V9 PASS IP access list []: the console's SQL console ran SELECT version() → 26.6.1.2326, and Managed ClickStack searched a Logs source on otel_pl.otel_logs: 800 results, the same 800 rows SQL counts. With the tester's /32 allowed, the public hostname answered from that machine
K1 PASS, second run 200 of 200 per signal through Kafka; a never-sent run_id 0. First run 0 of 200 per signal (latest, above)
K2 PASS outage 600 s, not the planned hour. Kafka path: 12,625 of 12,625. gw-default: 5000 accepted, 70 spans refused, 4910 delivered once its queue drained (the check counted 3315, 30 s after the restore). gw-durable: 19,516 of 19,516
K3 PASS, second run after: true, both consumers killed during an outage: 4000 of 4000 spans, 0 duplicates. after: false: 1000 of 4000, 3000 lost
K4 PASS, second run replay group from earliest into otel_replay: 200 traces, logs and gauge points, equal to the original. A latest group started after the send: 0, then read its marker
K5 PASS consumer-1 stopped while holding unexported messages: consumer-2 delivered 2000 of 2000 after the rebalance
K6 PASS 60 s burst through Kafka: 258,483 traces, peak backlog 129,471, the full count 92 s after the last send. The same burst at the gateway: 260,519 traces, peak queue 11,500 items, nothing refused or lost
K7 not run hypothesis; run only if K1–K6 make Kafka a requirement

What this says about the topology. One PrivateLink endpoint with the IP access list at deny all carries all three signals from both service VPCs, and people keep the console and Managed ClickStack. The gateway's file-storage queue survives an outage longer than 300 s and a restart, so it, not the defaults, belongs in the handover. At the load one generator could make, the gateway was not the bottleneck, and Kafka added delay (92 s to drain a burst the gateway absorbed) without a requirement that the file-storage queue failed.

Cost: the base build ran 06:34–09:37Z (3.05 h) and the Kafka part 07:17–09:37Z (2.33 h). At the rates under Test environment that is 3.05 × $1.31 + 2.33 × $0.17 ≈ $4.39, plus cents of data processing. The ClickHouse rate is the Enterprise ceiling and the service scaled idle when unused, so the bill should be lower. Destroy removed 95 resources; a search by name and tag afterwards found none left.


하나의 구성을 검증하는 계획입니다. 여러 AWS 계정에 걸친 여러 VPC의 OpenTelemetry 데이터를 gateway Collector 하나와 AWS PrivateLink endpoint 하나를 거쳐 ClickHouse Cloud로 보냅니다. 서비스는 공용 인터넷에 닫아 둡니다. 다른 팀이 자기 계정에 이 구성을 만들기 전에 우리가 먼저 측정해서, 그 팀이 받는 설정이 한 번은 실제로 돌아간 것이 되게 하려는 문서입니다.

마지막 실행: 2026-10-14. ap-northeast-2, AWS 계정 하나에서 terraform apply → bin/setup.sh → 검증 → terraform destroy까지 돌렸습니다(결과). 그 뒤에 바꿨지만 다시 돌리지 않은 것: K2가 gw-default의 queue가 빌 때까지 기다린 뒤 세도록 한 수정, 새로 만든 cluster에서 terraform/user_data.sh.tftpl의 consumer가 earliest로 시작하는 부분(두 번째 K1–K4 실행은 bin/check.sh를 통해 earliest를 썼습니다), #106. V8의 과부하 대조(#108)와 K7은 실행하지 않았습니다.

구성

서비스 VPC · 계정 A ─┐   앱: OTel SDK(OTLP) · Prometheus exporter를 scrape하는 agent Collector
서비스 VPC · 계정 B ─┼─→ Transit Gateway
나머지 서비스 VPC ───┘        │
                              ▼
               중앙 VPC ┌─────────────────────────────────────────────────────┐
                        │ 내부 NLB :4317/:4318                                │
                        │   → gateway Collector (queue · retry · batch)       │
                        │   → interface VPC endpoint (유일한 출구)             │
                        └─────────────────────────────────────────────────────┘
                              │  AWS PrivateLink
                              ▼
               ClickHouse Cloud 서비스 — 허용 목록에 endpoint ID, IP access list는 deny all

전제 조건은 이렇습니다. - private 연결만 허용하는 정책 - 계정들이 이미 Transit Gateway로 연결되어 있음 - 계정 2개에 VPC 10개 안팎 - 세 가지 신호(metrics, logs, traces)를 모두 수집 - 이미 OTel SDK나 Prometheus exporter를 쓰는 팀도 있고, OpenTelemetry가 처음인 팀도 있음

문서로 이미 정해진 것

2026-10-14에 읽었습니다. 측정값이 아니라 벤더 문서의 내용입니다. 이 구성이 기대는 항목은 아래 검증 계획에서 다시 확인합니다.

사실 출처
PrivateLink는 Scale과 Enterprise 플랜에서 쓸 수 있다 AWS PrivateLink
절차: endpointServiceId와 privateDnsHostname을 받는다(콘솔 또는 privateEndpointConfig API). interface endpoint를 만들고, 그 endpoint ID를 서비스 허용 목록에 넣는다 같은 페이지
DNS는 고객이 맡는다: privateDnsHostname이 endpoint의 네트워크 인터페이스를 가리키게 한다. 페이지의 CloudFormation·Terraform 예시는 endpoint 자체의 private DNS를 끈다 같은 페이지
Security group: 443, 8443, 9440, 3306 포트 같은 페이지
cross-region endpoint는 목록의 리전에서 지원되고 ap-east-2, mx-central-1에서는 안 된다. 리전 간 전송은 AWS가 과금한다 같은 페이지
PrivateLink는 고객 VPC에서 ClickHouse Cloud로 시작하는 연결만 지원한다 같은 페이지
IP access list는 공용 인터넷 연결에만 적용되고 PrivateLink에는 적용되지 않는다. PrivateLink만 받으려면 목록을 deny all로 둔다. IPv4만 지원한다. 기본값은 모든 곳에서 허용 IP access lists
Managed ClickStack에는 직접 운영하는 Collector가 데이터를 보낸다. ClickStack 배포판은 CLICKHOUSE_ENDPOINT(포트 8443까지 넣은 HTTPS URL 전체)와 사용자, 비밀번호로 설정한다. 수집 전용 사용자를 쓴다. 한 번에 1,000행 이상 넣는다(예시는 10,000) ClickStack: OTel collector
ClickHouse exporter: traces·logs는 beta, metrics는 alpha. batch는 batch processor가 아니라 sending_queue.batch에서 한다. sending_queue.storage에 storage extension을 지정하지 않으면 queue는 메모리에만 있다. retry_on_failure.max_elapsed_time 기본값은 300초 main 브랜치의 clickhouseexporter README. 실제로 돌리는 Collector 버전과 대조할 것

문서가 답하지 않아서 이 계획으로 측정할 것은 세 가지입니다.

검증 항목

모든 항목에 positive control과 negative control을 하나씩 둡니다. 데이터 확인은 _base/bin/verify.sh와 같은 방식으로 합니다. verify.run_id resource attribute로 데이터를 구분하고, otel_traces, otel_logs, otel_metrics_*에 SQL로 확인합니다. UI 스크린샷은 결과로 인정하지 않습니다.

# 무엇 Positive Negative
V1 endpoint와 허용 목록 중앙 VPC에서 privateDnsHostname:8443으로 SELECT 1이 성공한다 서비스 허용 목록에서 endpoint ID를 빼면 같은 쿼리가 실패한다(오류를 기록)
V2 DNS는 중앙 VPC 안에서만 중앙 VPC에서 privateDnsHostname이 endpoint의 private IP로 풀린다. 서비스 VPC에서 NLB DNS 이름이 private IP로 풀린다 서비스 VPC에서는 privateDnsHostname이 endpoint IP로 풀리지 않는다. 앱에는 이 레코드가 필요 없다는 뜻이다
V3 공용 경로 차단 IP access list를 deny all로 둔 상태에서도 V1이 성공한다 인터넷의 호스트에서 public 호스트명으로 접속하면 실패한다
V4 Transit Gateway 경유 다른 계정의 서비스 VPC에서 telemetrygen으로 NLB에 보내면 데이터가 들어온다 4317 security group 규칙을 빼면 그 run_id로는 아무것도 들어오지 않는다(telemetrygen은 그래도 exit 0이므로 count가 근거다)
V5 세 신호 모두 같은 run_id로 trace, log, metric point를 각각 N개 보내면 각 테이블에서 그 run_id가 N개다 보낸 적 없는 run_id는 각 테이블에서 0개다
V6 Prometheus 경로 서비스 VPC의 agent Collector가 exporter를 scrape하면 그 series가 otel_metrics_gauge / otel_metrics_sum에 들어온다 exporter를 멈추면 멈춘 시각 이후 값이 있는 새 point가 없다(첫 실패한 scrape는 series마다 staleness marker를 하나씩 남긴다, Flags bit 1)
V7 ClickHouse에 300초보다 오래 연결 안 됨 file storage queue를 쓰고 max_elapsed_time을 늘린 gateway로 장애 중에 N개를 보낸다. 장애 중간에 gateway를 한 번 재시작하고 연결을 복구하면 count가 N에 이른다 기본값(메모리 queue, 300초)으로 같은 실험을 하면 count가 N에 못 미친다. 얼마나 모자라는지 기록
V8 부하와 지연 고정 속도로 10분: 생성기의 마지막 전송부터 count가 N에 이를 때까지의 시간, 그리고 gateway 자체 텔레메트리(:8888)의 CPU와 메모리 gateway가 감당하지 못하는 속도로 보내면 queue가 쌓인다(어디에 쌓이고 무엇이 버려지는지 기록)
V9 사람이 UI에 접근 IP access list가 deny all인 상태에서 SQL console로 쿼리하고 Managed ClickStack 검색을 연다. 결과를 기록 테스트하는 사람의 IP를 허용 목록에 넣고 같은 동작을 한다. access list 때문인지 다른 원인인지 가르기 위함

V7은 이 구성이 반드시 견뎌야 하는 장애입니다. 중앙 VPC의 endpoint 하나가 유일한 출구이므로, 여기가 끊기면 모든 팀이 한꺼번에 멈춥니다.

선택지: Kafka 버퍼

gateway의 file storage queue는 짧은 장애와 재시작을 감당합니다(V7). 하지만 queue가 노드 하나에 있어서, 다음 경우는 감당하지 못합니다. - 그 노드 자체를 잃는 경우 - 디스크에 담을 수 있는 양보다 오래 끊기는 장애 - 데이터를 다시 적재(replay)해야 하는 경우 - 같은 데이터를 다른 소비자에게도 보내야 하는 경우

이 중 하나라도 요구 사항이 되면 중앙 VPC에 Kafka(Amazon MSK)를 둡니다.

서비스 VPC ─→ Transit Gateway ─→ 중앙 VPC
                                 내부 NLB :4317/:4318
                                   → ingest Collector     OTLP 수신 → kafka exporter (otlp_proto)
                                   → MSK                  otlp_spans · otlp_logs · otlp_metrics
                                   → consumer Collector   kafka receiver → clickhouse exporter
                                   → interface VPC endpoint → PrivateLink → ClickHouse Cloud

앱은 같은 OTLP endpoint로 계속 보내면 되고, ClickHouse Cloud로 가는 연결도 여전히 VPC에서 시작하는 하나뿐입니다. 각 컴포넌트 README의 내용은 이렇습니다. contrib main 기준이고 2026-10-14에 읽었으므로, 실제로 돌리는 버전과 대조해야 합니다.

1순위가 아닌 방식: ClickPipes가 MSK에서 가져오기. ClickPipes는 MSK multi-VPC connectivity 위의 reverse private endpoint로 MSK를 private하게 읽을 수 있습니다. 이 방식은 cross-region을 지원하지 않습니다 (ClickPipes PrivateLink, 2026-10-14 읽음). consumer Collector가 필요 없어지는 대신 대가가 두 가지 있습니다.

Kafka 검증 항목 (V1–V9를 마친 뒤, 같은 환경에 MSK를 더해서 실행):

# 무엇 Positive Negative
K1 Kafka 경로에서도 같은 데이터 V5를 Kafka 경로로 실행: 한 run_id에 신호별 N개 보낸 적 없는 run_id는 0개
K2 gateway 디스크가 담을 수 있는 시간보다 긴 장애 ClickHouse를 한 시간 끊는다. consumer lag이 쌓였다가 복구 뒤에 해소되고, count가 N에 이른다 V7의 file storage gateway로 같은 장애를 겪게 한다. 남긴 것과 잃은 것을 기록
K3 export 도중 consumer가 죽음 message_marking.after: true로 두고 export 중에 consumer를 죽이면, count가 N 이상이다. 중복 수를 기록(count − record key의 uniqExact) message_marking.after: false로 같은 실험을 하면 count가 N에 못 미친다
K4 replay consumer group을 earliest로 되돌려 별도 데이터베이스에 적재하면, 그 run_id의 count가 원본과 같다 전송 뒤에 initial_offset: latest로 시작한 consumer group은 그 데이터를 하나도 읽지 않는다
K5 consumer 하나를 잃음 group에 consumer 둘을 두고 하나를 멈추면, rebalance 뒤 count가 N에 이른다 — (유실은 K3에서 확인)
K6 피크 insert 속도보다 빠르게 몰아서 보내면 lag이 쌓였다가 해소된다. 최대 lag과 해소 시간을 기록 Kafka 없이(V8의 gateway) 같은 피크를 보낸다. 어디에 쌓이거나 버려지는지 기록
K7 ClickPipes 방식 (가설) otlp_json topic을 reverse private endpoint 위의 ClickPipe로 raw 테이블에 넣고, materialized view로 otel_*에 풀면 count가 K1과 같다 — K1–K6 결과로 Kafka가 요구 사항이 될 때만 실행

K2, K3, K4 중 하나가 gateway의 file storage queue(V7)로는 안 되는 요구 사항을 채운다면 Kafka를 둘 가치가 있습니다. 그렇지 않으면 운영할 클러스터가 하나 늘어날 뿐입니다.

테스트 환경

검증 항목에 답할 수 있는 가장 작은 구성입니다. 모든 것을 Cloud 서비스와 같은 리전 하나에 둡니다. cross-region endpoint는 별도의 주제입니다.

구성 요소 내용 이유
ClickHouse Cloud Scale 서비스 1개, 전용 데이터베이스와 수집 사용자 PrivateLink는 Scale 이상에서 쓸 수 있다
중앙 VPC 두 AZ의 interface endpoint, 이 VPC에만 연결한 privateDnsHostname용 Route 53 private hosted zone, 내부 NLB, EC2 한 대의 gateway Collector V1–V3, V7, V8
서비스 VPC (같은 계정) telemetrygen, agent Collector, Prometheus exporter 1개 V5, V6
서비스 VPC (두 번째 계정) telemetrygen만 V4: 계정 간에 공유한 Transit Gateway. 계정 하나로는 라우팅은 확인되지만 공유는 확인되지 않는다
Transit Gateway 세 VPC의 attachment V4
MSK (Kafka 검증에만) 중앙 VPC의 가장 작은 provisioned cluster, ingest Collector, consumer Collector 둘 K1–K6. K7에는 ClickPipe와 그 reverse private endpoint를 더함

Collector 이미지와 telemetrygen은 실습을 만들 때 버전을 고정합니다. 결과 줄마다 그 버전과 서비스가 보고하는 ClickHouse 버전을 함께 기록합니다.

실행 전 비용 추정. AWS Price List와 ClickHouse Cloud 가격 페이지를 보고, 실행에 필요한 시간만큼 아래 항목을 추정합니다. - Scale 서비스 - interface endpoint(AZ-시간당, GB당) - NLB - Transit Gateway(attachment-시간당, 처리 GB당) - EC2 인스턴스 - AZ 간 전송 - Kafka 검증용: MSK broker-시간과 스토리지. K7은 ClickPipes compute 추가

2026-10-14 실행 전 추정입니다(ap-northeast-2의 AWS Price List API, 당일 조회).

항목 단가 시간당
ClickHouse 2 compute unit(2 × 8 GiB) 가격 페이지에 서울 Scale 단가가 없어 Enterprise 단가 $0.4801/unit-시간을 상한으로 씀 ≤ $0.960
Transit Gateway VPC attachment 3개 개당 $0.07 $0.210
EC2 t4g.small 3대 대당 $0.0208 $0.062
NLB $0.0225 + LCU ≈ $0.030
Interface endpoint, AZ 2개 개당 $0.013 $0.026
Public IPv4 3개 개당 $0.005 $0.015
기본 구성 ≈ $1.31
Kafka: MSK kafka.t3.small 2개, 인스턴스와 주소 2개씩, 스토리지 broker당 $0.0569 ≈ $0.17 추가

데이터 처리비(endpoint $0.01/GB, Transit Gateway $0.02/GB)는 이 정도 양에서는 몇 센트였습니다. 실제 실행 시간과 비용은 결과에 있습니다. 소유자의 승인 없이는 아무것도 띄우지 않습니다.

실행 방법

2026-10-14에 처음부터 끝까지 실행했습니다(결과). 그 전에 terraform validate와 otelcol-contrib 0.155.0 validate가 통과했고 일부러 고장을 넣었을 때 각각 실패하는 것도 확인했으며, SQL과 V5, V7 제어 실험은 로컬 ClickHouse 26.9.7.9 컨테이너에서 한 번 돌렸습니다.

경로 내용
terraform/ ClickHouse 서비스와 ip_access, VPC 셋과 Transit Gateway, endpoint와 private hosted zone, NLB, SSM을 쓰는 EC2, enable_kafka로 켜는 MSK
collector/ gateway-durable(file storage queue, 긴 retry), gateway-default(기본값), agent, ingest-kafka, consumer-kafka — otel/opentelemetry-collector-contrib:0.155.0
bin/ setup.sh(수집 사용자와 데이터베이스), check.sh <V1…V8, K1…K6>, outage.sh block/unblock
sql/ 00-ingest-user.sql(수집 사용자 권한, setup.sh가 전송), counts.sql과 dupes.sql(run별 행 수와 중복 span, check.sh가 사용)
results/ check.sh를 실행할 때마다 결과 줄을 <UTC 날짜>-<id>.txt로 여기에 씁니다
export AWS_PROFILE=<profile>
export CLICKHOUSE_CLOUD_API_KEY=<key>
export CLICKHOUSE_CLOUD_API_SECRET=<secret>
export CLICKHOUSE_ORG_ID=<org id>
cd labs/privatelink-gateway/terraform
cp terraform.tfvars.example terraform.tfvars    # clickhouse_org_id 입력
terraform init && terraform plan -out=tf.plan && terraform apply tf.plan
cd ../bin && ./setup.sh && ./check.sh V1        # … V8, 그다음 enable_kafka = true로 K1 … K6
cd ../terraform && terraform destroy

서비스가 공용 인터넷에서 오는 연결을 받지 않으므로, SQL은 모두 SSM을 거쳐 gateway에서 실행합니다. SSH와 NAT는 쓰지 않습니다. 인스턴스의 public IP는 패키지와 이미지를 받는 데만 씁니다.

만들면서 알게 된 점입니다. 계획에도 반영해 둡니다. - create_schema: true이면 ClickHouse에 연결되지 않는 동안 Collector가 시작되지 않습니다. V7에서 장애 중에 재시작한 gateway는 장애가 끝날 때까지 OTLP를 받지 못합니다. 그래도 디스크에 저장한 queue는 복구 뒤에 다시 보냅니다. 운영 gateway는 스키마를 한 번 만들어 두고 create_schema: false로 돌려야 합니다. - 생성기가 보고하는 건수는 근거가 되지 않습니다. telemetrygen v0.155.0은 다음처럼 동작합니다. - --child-spans 0이어도 trace 하나에 span을 두 개 만듭니다. - --rate를 worker마다 따로 셉니다. - 속도 제한 없이 돌리면, 거부한 것이 없는 gateway에 실제로 도착한 것보다 훨씬 많은 trace를 보냈다고 보고합니다.

그래서 검증은 receiver가 실제로 받은 건수를 셉니다. - AWS 계정이 하나입니다. V4는 VPC 사이의 Transit Gateway 라우팅을 확인할 뿐, 계정 간 공유는 확인하지 않습니다. - 비밀번호가 인스턴스에 전달됩니다. user data와 SSM 명령에 들어가기 때문입니다. 당일에 지우는 실습이라 허용하지만, 남겨 둘 환경에서는 쓰면 안 됩니다.

실행하면서 알게 된 점입니다(2026-10-14). 고친 것은 그렇게 적었습니다. - ClickHouse Cloud는 특수문자가 없는 SQL 사용자 비밀번호를 거부합니다(CREATE USER, Code 36, "contain at least 1 special character"). 서비스 API는 영숫자로만 된 default 비밀번호를 받았습니다. bin/setup.sh가 여기서 멈췄습니다. 수정: random_password.ingest에 -_.~ 중 특수문자 두 개를 넣습니다. - ip_access = []는 []로 저장되고 deny all을 뜻합니다. public 호스트명은 연결을 끊었고(curl: (35)), 테스트하는 사람의 /32를 넣자 같은 장비에서 같은 요청이 200을 받았습니다(V3, V9). - 회수했다가 다시 승인한 security group 규칙은 새 ID로 돌아옵니다. 그래서 다음 terraform plan이 그 규칙을 다시 만들려 하고, apply는 중복으로 실패합니다. 이후 plan마다 그 규칙을 state에서 지우고 새 ID로 import했습니다. terraform destroy에는 영향이 없습니다(#107). - create_schema: true를 실측했습니다. 장애 중에 재시작한 gateway는 10번 시작에 실패했고 (create database: failed to query server hello), 장애가 끝나고 15초 뒤에 떴습니다(V7). - 메모리 queue가 차면 receiver가 거부합니다. gw-default는 요청 1000개를 정확히 받고 나머지를 거부했으므로, accepted는 queue 크기를 넘지 않습니다. queue에서 기다리는 요청은 나이를 먹지 않았습니다. 600초 장애 뒤 받은 5000 trace 중 4910을 전달했습니다. 한꺼번에 전송 중이던 200 trace만 있던 V7에서는 하나도 전달하지 못했습니다. 300초 시계가 consumer가 요청을 꺼낼 때 시작한다는 것은 이 숫자에서 읽은 것이지 exporterhelper 소스로 확인한 것이 아닙니다. K2의 대조 조건은 거부 건수를 쓰도록 고쳤습니다. - telemetrygen은 거부된 batch를 --duration이 지나도 다시 보냅니다. 가득 찬 gw-default로 동기 전송하자 SSM 제한 시간을 넘겼습니다. K2는 이제 gateway 전송을 분리해서 띄우고 전송 시간이 지나면 지웁니다. - initial_offset: latest인 Kafka consumer는 commit된 offset이 없는 partition의 데이터를 건너뜁니다. topic은 consumer가 합류한 뒤 첫 produce 때 자동 생성됩니다. K1은 신호마다 200 중 0을 셌고, K3 positive는 절반을 잃었고, K4의 원래 group은 logs와 metrics를 0으로 셌습니다. 같은 때 replay group(earliest)은 각각 200을 읽었습니다. 수정: 기본 group은 earliest로 시작합니다. 이 값은 commit된 offset이 없을 때만 적용됩니다. - Prometheus receiver는 첫 실패한 scrape에서 series마다 staleness marker를 남깁니다(363 point, Flags bit 1, 값 없음). V6 negative는 이제 값이 있는 point만 셉니다. - t4g.small 한 대의 telemetrygen은 약 8,700 spans/s가 한계입니다(20,000을 요청). V8의 과부하 대조에는 이 구성보다 많은 생성기가 필요합니다.

결과

2026-10-14, ap-northeast-2, AWS 계정 하나에서 실행했습니다. 결과 줄마다 기록한 버전은 다음과 같습니다. ClickHouse Cloud 26.6.1.2326(SELECT version()), replica 2개 × 8 GiB에 idle scaling을 켠 서비스, Collector otel/opentelemetry-collector-contrib:0.155.0, telemetrygen v0.155.0, prom/node-exporter:v1.12.1, kafka.t3.small 2개의 MSK Kafka 3.9.x, Terraform 1.16.4와 hashicorp/aws 6.68.0, ClickHouse/clickhouse 3.38.0. Managed ClickStack은 콘솔에서 썼고, 화면에 버전이 나오지 않아 기록하지 못했습니다.

Verified on ClickHouse Cloud 26.6.1.2326, Managed ClickStack(버전 미기록), otel/opentelemetry-collector-contrib:0.155.0, 2026-10-14: V1–V7, V8 positive, V9, K1–K6.

# 결과 측정한 것
V1 PASS 중앙 VPC에서 private 호스트명으로 SELECT 1. 허용 목록에서 endpoint ID를 빼자(API 200) curl: (35) Recv failure: Connection reset by peer. 다시 넣자 복구
V2 PASS 중앙 VPC: private 호스트명이 endpoint IP 두 개로 풀림. svc_a: NLB 이름은 10.60.x 주소로 풀리고, private 호스트명은 아무것으로도 풀리지 않음
V3 PASS API의 ipAccessList: []. PrivateLink는 계속 응답. 테스트 장비에서 public 호스트명: curl: (35) 연결 끊김, 그 장비의 /32를 허용하자 HTTP 200(V9)
V4 PASS svc_b에서 Transit Gateway와 NLB를 거쳐 200 trace 중 200 도착. 4317 규칙 회수: 0행(telemetrygen exit 0). 규칙 복구: 다시 전송됨
V5 PASS 한 run_id에 trace 200(span 400), log 200, gauge point 200. 보낸 적 없는 run_id는 모두 0
V6 두 번째 실행에서 PASS node-exporter의 gauge 240종, sum 107종. 멈춘 뒤: 값이 있는 point 0, staleness marker 363. 첫 실행은 marker까지 세서 실패
V7 PASS 장애 360초. 장애 중 한 번 재시작한 gw-durable: 200 중 200(span 400, 중복 0). gw-default: 200 중 0
V8 positive PASS, negative 미도달 2000 spans/s로 600초: 599,335 trace를 받아 모두 셈, 마지막 전송 16초 뒤 전부 도착. gateway(t4g.small) CPU 2–7 %, 컨테이너 메모리 68–74 MiB, queue 최대 3,700 item. 20,000 spans/s 요청: 120초에 523,139 trace(≈ 8,700 spans/s), gateway CPU ≈ 18 %, queue 최대 11,500 item, 거부·유실 없음
V9 PASS IP access list []: 콘솔 SQL console에서 SELECT version() → 26.6.1.2326, Managed ClickStack에서 otel_pl.otel_logs Logs source 검색 결과 800건으로 SQL count 800과 같음. 테스트 장비의 /32를 허용하자 그 장비에서 public 호스트명이 응답
K1 두 번째 실행에서 PASS Kafka 경로로 신호마다 200 중 200, 보낸 적 없는 run_id는 0. 첫 실행은 신호마다 200 중 0(위의 latest)
K2 PASS 장애 600초(계획한 한 시간이 아님). Kafka 경로: 12,625 중 12,625. gw-default: 5000 받음, span 70 거부, queue가 빈 뒤 4910 전달(체크는 복구 30초 뒤에 세어 3315). gw-durable: 19,516 중 19,516
K3 두 번째 실행에서 PASS after: true, 장애 중 consumer 둘을 죽임: span 4000 중 4000, 중복 0. after: false: 4000 중 1000, 3000 유실
K4 두 번째 실행에서 PASS earliest로 otel_replay에 replay: trace, log, gauge point 각 200으로 원본과 같음. 전송 뒤 시작한 latest group: 0, 그 뒤 marker는 읽음
K5 PASS 아직 export하지 않은 메시지를 가진 consumer-1을 멈춤: rebalance 뒤 consumer-2가 2000 중 2000 전달
K6 PASS Kafka 경로로 60초 burst: 258,483 trace, backlog 최대 129,471, 마지막 전송 92초 뒤 전부 도착. 같은 burst를 gateway로: 260,519 trace, queue 최대 11,500 item, 거부·유실 없음
K7 실행 안 함 가설. K1–K6 결과로 Kafka가 요구 사항이 될 때만 실행

이 구성에 대해 말해 주는 것. IP access list를 deny all로 둔 PrivateLink endpoint 하나로 두 서비스 VPC의 세 신호를 모두 받을 수 있고, 사람은 콘솔과 Managed ClickStack을 계속 쓸 수 있습니다. gateway의 file storage queue는 300초보다 긴 장애와 재시작을 견디므로, 넘겨줄 설정은 기본값이 아니라 이것이어야 합니다. 생성기 한 대가 낼 수 있는 부하에서 gateway는 병목이 아니었습니다. Kafka는 지연만 더했고(gateway가 흡수한 burst를 비우는 데 92초), file storage queue가 채우지 못한 요구 사항은 나오지 않았습니다.

비용: 기본 구성은 06:34–09:37Z(3.05시간), Kafka 부분은 07:17–09:37Z(2.33시간) 떠 있었습니다. 테스트 환경의 단가로 3.05 × $1.31 + 2.33 × $0.17 ≈ $4.39이고, 데이터 처리비 몇 센트가 더해집니다. ClickHouse 단가는 Enterprise 상한이고 쓰지 않을 때 서비스가 idle로 줄었으므로 실제 청구는 이보다 낮아야 합니다. destroy는 리소스 95개를 지웠고, 그 뒤 이름과 태그로 찾았을 때 남은 것은 없었습니다.

Open this lab on GitHub →GitHub에서 이 실습 열기 →