easy-db-lab
easy-db-lab creates lab environments for database evaluations in AWS. It provisions infrastructure, deploys databases, and sets up a full observability stack so you can focus on testing, benchmarking, and learning.
Supported databases include Apache Cassandra, ClickHouse, TiDB, and OpenSearch, with Trino, Presto, and Apache Spark available for analytics workloads.
If you are looking for a tool to aid in stress testing Cassandra clusters, see the companion project cassandra-easy-stress.
If you're looking for tools to help manage Cassandra in production environments please see Reaper, cstar, and K8ssandra.
Quick Start
- Install easy-db-lab
- Set up your profile - Run
easy-db-lab profile setup - Follow the tutorial
Features
Database Support
- Apache Cassandra: Released versions and branch builds that ship in the AMI, plus custom builds from a local source tree. Includes Cassandra Sidecar and integration with cassandra-easy-stress for benchmarking.
- ClickHouse: Sharded clusters with configurable replication, distributed tables, and S3-tiered storage.
- TiDB: MySQL-compatible HTAP clusters deployed via the TiDB Operator, with TiKV row storage for OLTP and TiFlash columnar storage for analytics.
- Trino: Distributed SQL query engine with federation support for querying across data sources.
- Presto: Distributed SQL query engine for in-memory analytics.
- OpenSearch: AWS OpenSearch domains for search and analytics.
- Apache Spark: EMR-based Spark clusters for analytics workloads.
AWS Integration
- EC2 Provisioning: Automated provisioning with configurable instance types
- EBS Storage: Optional EBS volumes for persistent storage
- S3 Backup: Automatic backup of configurations and state to S3
- IAM Integration: Managed IAM policies for secure operations
Kubernetes (K3s)
- Lightweight K3s: Automatic K3s cluster deployment across all nodes
- kubectl/k9s: Pre-configured access with SOCKS5 proxy support
- Private Registry: HTTPS Docker registry for custom images
- Jib Integration: Push custom containers directly from Gradle
Monitoring and Observability
- Mimir: Metrics storage, written to S3 so metrics outlive the cluster
- Loki: Log storage, written to S3 so logs outlive the cluster
- Tempo: Distributed trace storage with trace-to-metrics correlation
- Pyroscope: Continuous profiling of database and system processes
- Grafana: Pre-configured dashboards for Cassandra, ClickHouse, and system metrics
- OpenTelemetry: Distributed tracing and metrics collection
- eBPF Observability: Beyla for L7 network metrics and ebpf_exporter for low-level kernel metrics
- AxonOps: Optional integration with AxonOps for Cassandra monitoring and management
Developer Experience
- Shell Aliases: Convenient shortcuts for cluster management (
c0,c-all,c-status, etc.) - Server: Integration with Claude Code for AI-assisted operations
- Restore Support: Recover cluster state from VPC ID or S3 backup
- SOCKS5 Proxy: Secure access to private cluster resources
Stress Testing
- cassandra-easy-stress: Native integration with Apache stress testing tool
- Sysbench: OLTP benchmarks against any MySQL- or PostgreSQL-compatible kit, with live results in Grafana
- Kubernetes Jobs: Run stress tests as K8s jobs for scalability
- Artifact Collection: Automatic collection of metrics and diagnostics
Installation
Prerequisites
System Requirements
| Requirement | Details |
|---|---|
| Operating System | macOS or Linux |
| Java | JDK 21 or later |
| Docker | Required for building custom AMIs |
AWS Requirements
- AWS Account: A dedicated AWS account is recommended for lab environments
- AWS Credentials: Either static access key & secret, or a named profile — including an AWS SSO (IAM Identity Center) profile
- IAM Permissions: Permissions to create EC2, IAM, S3, and optionally EMR resources
Run easy-db-lab show-iam-policies to see the exact IAM policies required with your account ID populated. See Setup for details.
Optional
- AxonOps Account: For free Cassandra monitoring. Create an account at axonops.com
Install from Release
Download a tarball from the releases page and add the bin directory to your $PATH:
export PATH="$PATH:/path/to/easy-db-lab/bin"
Build from Source
git clone https://github.com/rustyrazorblade/easy-db-lab.git
cd easy-db-lab
./gradlew assemble
The built distribution will be in build/distributions/.
Next Steps
Run the interactive setup to configure your profile:
easy-db-lab profile setup
See the Setup Guide for detailed instructions.
Setup
This guide walks you through the initial setup of easy-db-lab, including AWS credentials configuration, IAM policies, and AMI creation.
Overview
The profile setup command handles all initial configuration interactively. It will:
- Collect your email and AWS credentials
- Validate your AWS access
- Create necessary AWS resources (key pair, IAM roles, Packer VPC)
- Build or validate the required AMI
Prerequisites
Before running setup:
- AWS Account: An AWS account with appropriate permissions (see IAM Policies below)
- Java 21+: Required to run easy-db-lab
- Docker: Required only if building custom AMIs
Step 1: Run Setup Profile
Run the interactive setup:
easy-db-lab profile setup
The setup wizard will prompt you for:
| Prompt | Description | Default |
|---|---|---|
| Used to tag AWS resources for ownership | (required) | |
| AWS Region | Region for your clusters | us-west-2 |
| AWS Profile name | Named AWS profile to authenticate with — press Enter to enter credentials manually instead | (manual) |
| AWS Access Key | Your AWS access key ID (only asked if no profile name was given) | (required) |
| AWS Secret Key | Your AWS secret access key (only asked if no profile name was given) | (required) |
| AxonOps Org | Optional: AxonOps organization name | (skip) |
| AxonOps Key | Optional: AxonOps API key | (skip) |
| SSH transport | How SSH reaches cluster nodes: direct (public IP, port 22) or ssm (tunneled through AWS SSM Session Manager, for networks that block outbound port 22 — see SSH over SSM Session Manager) | direct |
The AWS profile name is asked first. If you provide one, the access key and secret prompts are skipped — easy-db-lab resolves credentials through that profile (including AWS SSO profiles). Static access keys are only collected when you leave the profile name blank.
profile setup validates your credentials against AWS immediately (and then provisions resources), so your credentials must be usable before you run it. For static keys this is automatic; for an SSO profile, run aws sso login first (see below).
What Gets Created
During setup, the following AWS resources are created:
- EC2 Key Pair: For SSH access to instances
- IAM Role: For instance permissions (
EasyDBLabEC2Role), including a minimal inline Session Manager policy so every node can be reached over SSM Session Manager - Packer VPC: Infrastructure for building AMIs
- AMI (if needed): Takes 10-15 minutes to build
Configuration Location
Your profile is saved to:
~/.easy-db-lab/profiles/default/settings.yaml
Use a different profile by setting EASY_DB_LAB_PROFILE environment variable before running setup.
Using AWS SSO (IAM Identity Center)
If your AWS access is provisioned through AWS SSO (IAM Identity Center) rather than long-lived access keys, easy-db-lab authenticates through a named AWS profile backed by an SSO session. You do not copy temporary credentials out of the AWS access portal — the tool resolves them automatically from your SSO login.
The AWS access portal's "Command line or programmatic access" panel gives you a temporary access key, secret, and session token. Do not use these with easy-db-lab. The static-credential path has no field for a session token, so those credentials will not work. Use the SSO profile flow below instead.
1. Define an SSO profile in ~/.aws/config:
[sso-session my-sso]
sso_start_url = https://my-company.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access
[profile edl]
sso_session = my-sso
sso_account_id = 123456789012
sso_role_name = YourRoleName
region = us-west-2
2. Log in to start an SSO session (this opens a browser):
aws sso login --profile edl
3. Run profile setup and enter edl when prompted for the AWS profile name (leave the access key and secret blank):
easy-db-lab profile setup
Run aws sso login before profile setup. It validates and provisions against AWS immediately, so it needs an active SSO session. This first-time aws sso login → profile setup sequence is only needed once.
Day-to-day usage
After the one-time setup, you do not sign in for every command. An aws sso login session lasts for hours (your organization sets the exact window), and easy-db-lab resolves and refreshes credentials from it automatically:
# Once per work session (e.g. each morning), or whenever the session expires:
aws sso login --profile edl
# Then run commands freely — no per-command authentication:
easy-db-lab up
easy-db-lab cassandra start
easy-db-lab down --auto-approve
When the session expires, the next command fails with an authentication error directing you to log in again. Re-run aws sso login --profile edl and continue.
For operations that run longer than your SSO session window (e.g. an unattended overnight benchmark), the session can expire mid-run and a later AWS call may fail. For interactive, command-by-command use this does not come up.
Step 2: Getting IAM Policies
If you need to request permissions from your AWS administrator, use the show-iam-policies command to display the required policies with your account ID populated:
easy-db-lab show-iam-policies
This displays five policies:
| Policy | Purpose |
|---|---|
| EC2 | Create/manage EC2 instances, VPCs, security groups; open SSM Session Manager sessions to cluster instances |
| IAM | Create instance roles and profiles |
| EMR | Create Spark clusters (optional) |
| OpenSearch | Create OpenSearch domains (optional) |
| Compactor | Run the account compactor on ECS Fargate: ECS, its log group, and passing its two roles |
Filter by Policy Name
To show a specific policy:
easy-db-lab show-iam-policies ec2 # Show EC2 policy only
easy-db-lab show-iam-policies iam # Show IAM policy only
easy-db-lab show-iam-policies emr # Show EMR policy only
easy-db-lab show-iam-policies compactor # Show the compactor policy only
Recommended IAM Setup
For teams with multiple users, we recommend creating managed policies attached to an IAM group:
- Create an IAM group (e.g., "EasyDBLabUsers")
- Create the managed policies from the JSON output
- Attach all policies to the group
- Add users to the group
Inline policies have a 5,120 byte limit which may not fit all the policies. Use managed policies instead.
Step 3: Build Custom AMI (Optional)
If setup couldn't find a valid AMI for your architecture, or if you want to customize the base image, build one manually:
easy-db-lab build-image
Build Options
| Option | Description | Default |
|---|---|---|
--arch | CPU architecture (AMD64 or ARM64) | AMD64 |
--region | AWS region for the AMI | (from profile) |
Examples
# Build AMD64 AMI (default)
easy-db-lab build-image
# Build ARM64 AMI for Graviton instances
easy-db-lab build-image --arch ARM64
# Build in specific region
easy-db-lab build-image --region eu-west-1
If your profile's SSH transport is ssm, AMI builds reach the build instance over SSM Session
Manager too. The first such build also builds a local Packer image with the Session Manager plugin
added (see AMI builds).
Environment Variables
| Variable | Description | Default |
|---|---|---|
EASY_DB_LAB_USER_DIR | Override configuration directory | ~/.easy-db-lab |
EASY_DB_LAB_PROFILE | Use a named profile | default |
EASY_DB_LAB_INSTANCE_TYPE | Default database instance type for init | i4i.xlarge |
EASY_DB_LAB_STRESS_INSTANCE_TYPE | Default application (stress) instance type | c6id.2xlarge |
EASY_DB_LAB_AMI | Override AMI ID | (auto-detected) |
Verify Installation
After setup completes, verify by running:
easy-db-lab
You should see the help output with available commands.
Next Steps
Once setup is complete, follow the Tutorial to create your first cluster.
Tutorial: Getting Started
This tutorial walks you through creating a database cluster from scratch, covering initialization, infrastructure provisioning, and database configuration. The examples below use Cassandra, but the same infrastructure supports ClickHouse, OpenSearch, and Spark.
Before starting, ensure you've completed the Setup process by running easy-db-lab profile setup.
Part 1: Initialize Your Cluster
The init command creates local configuration files for your cluster. It does not provision AWS resources yet.
easy-db-lab init my-cluster
This creates a 3-node Cassandra cluster by default.
Init Options
The database and application node groups use a namespaced --db.* / --app.*
scheme. Every older flag still works as an alias with its established
default; when both a namespaced option and its legacy alias are given for the
same setting, the namespaced option wins, regardless of order.
| Option | Description | Default |
|---|---|---|
--db.count (alias --db, --cassandra, -c) | Number of database instances | 3 |
--app.count (alias --app, --stress, -s) | Number of application instances | 0 |
--db.instance-type (alias --instance, -i) | Database instance type | i4i.xlarge |
--app.instance-type (alias --stress-instance, -si) | Application instance type | c6id.2xlarge |
--azs, -z | Availability zones (e.g., a,b,c) | all available |
--ebs.type | EBS volume type (NONE, gp2, gp3, io1, io2) | NONE |
--ebs.size | EBS volume size in GB | 256 |
--ebs.iops | EBS IOPS (gp3 only) | 0 |
--ebs.throughput | EBS throughput (gp3 only) | 0 |
--until | When instances can be deleted | tomorrow |
--tag | Custom tags (key=value, repeatable) | - |
--vpc | Use existing VPC ID | - |
--up | Auto-provision after init | false |
--clean | Remove existing config first | false |
Examples
Basic 3-node cluster:
easy-db-lab init my-cluster
5-node cluster with 2 stress nodes:
easy-db-lab init my-cluster --db 5 --stress 2
Production-like cluster with EBS storage:
easy-db-lab init prod-test --db 5 --ebs.type gp3 --ebs.size 500 --ebs.iops 3000
ARM64 cluster for Graviton instances: The architecture is derived automatically from the instance type — no flag needed.
easy-db-lab init my-cluster --db.instance-type r7g.2xlarge
Mixed-architecture cluster (arm64 database, x86_64 application):
easy-db-lab init my-cluster --db.instance-type r7g.2xlarge --app.instance-type c6id.2xlarge
Initialize and provision in one step:
easy-db-lab init my-cluster --up
Storage Requirements
Database instances need a data disk separate from the root volume. This can come from either:
- Instance store (local NVMe) — Instance types with a
dsuffix (e.g.,i3.xlarge,m5d.xlarge,c5d.2xlarge) include local NVMe storage and require no extra configuration. - EBS volumes — Attach an EBS volume using
--ebs.typefor any other instance type.
If the selected instance type has no instance store and --ebs.type is not specified, up will fail. For example:
easy-db-lab init my-cluster --db.instance-type c5.2xlarge --ebs.type gp3 --ebs.size 200
Part 2: Launch Infrastructure
Once initialized, provision the AWS infrastructure:
easy-db-lab up
This command creates:
- S3 Storage: Cluster data stored under a dedicated prefix in the account S3 bucket
- VPC: With subnets and security groups
- EC2 Instances: Cassandra nodes, stress nodes, and a control node (an
m5d.xlarge; K3s data is stored on its NVMe drive to avoid filling the root volume) - K3s Cluster: Lightweight Kubernetes across all nodes
What Happens During up
- Configures account S3 bucket with cluster prefix
- Creates VPC with public subnets in your availability zones
- Provisions EC2 instances in parallel
- Waits for SSH availability
- Configures K3s cluster on all nodes
- Writes SSH config and environment files
If any step fails — EC2 setup, K3s, node labeling, StorageClasses, the observability stack,
Tailscale — up aborts with a non-zero exit code instead of continuing with a
partially-provisioned cluster. EC2 instances already launched are left running; clusters are
ephemeral and there's no automatic rollback. Reclaim them with easy-db-lab down, fix the
underlying issue, and re-run up.
Up Options
| Option | Description |
|---|---|
--no-setup, -n | Skip K3s setup and AxonOps configuration |
Environment Setup
After up completes, source the environment file:
source env.sh
This configures your shell with:
- SSH shortcuts:
ssh db0,ssh db1,ssh stress0, etc. - Cluster aliases:
c0,c-all,c-status - SOCKS proxy configuration
See Shell Aliases for all available shortcuts.
The following section covers Cassandra. If you're deploying a different database, the infrastructure steps above are the same — see ClickHouse, OpenSearch, or Spark for database-specific setup after your cluster is running.
Part 3: Configure Cassandra 5.0
With infrastructure running, configure and start Cassandra.
Step 1: Select Cassandra Version
easy-db-lab cassandra use 5.0
This command:
- Sets the active Cassandra version on all nodes
- Downloads configuration files to your local directory
- Applies any existing patch configuration
cassandra use accepts any installed version. Run easy-db-lab cassandra list to see them.
This includes a build published by cassandra build. See Custom Builds,
or run easy-db-lab help cassandra-builds.
Step 2: Customize Configuration (Optional)
Edit cassandra.patch.yaml to customize settings:
# Example: Change token count
vim cassandra.patch.yaml
Common customizations:
| Setting | Description | Default |
|---|---|---|
num_tokens | Virtual nodes per instance | 4 |
concurrent_reads | Max concurrent read operations | 64 |
concurrent_writes | Max concurrent write operations | 64 |
endpoint_snitch | Network topology snitch | Ec2Snitch |
Step 3: Apply Configuration
easy-db-lab cassandra update-config
This uploads and applies the patch to all Cassandra nodes.
To apply and restart Cassandra in one command:
easy-db-lab cassandra update-config --restart
Step 4: Start Cassandra
easy-db-lab cassandra start
Step 5: Verify Cluster
Check cluster status:
ssh db0 nodetool status
Or use the shell alias (after sourcing env.sh):
c-status
You should see all nodes in UN (Up/Normal) state.
Part 4: Working with Your Cluster
SSH Access
After sourcing env.sh:
ssh db0 # First Cassandra node
ssh db1 # Second Cassandra node
ssh stress0 # First stress node (if provisioned)
ssh control0 # Control node
Cassandra Management
# Stop Cassandra on all nodes
easy-db-lab cassandra stop
# Start Cassandra on all nodes
easy-db-lab cassandra start
# Restart Cassandra on all nodes
easy-db-lab cassandra restart
Filter to Specific Hosts
Most commands support the --hosts filter:
# Apply config only to db0 and db1
easy-db-lab cassandra update-config --hosts db0,db1
# Restart only db2
easy-db-lab cassandra restart --hosts db2
Download Configuration Files
To download the current configuration from nodes:
easy-db-lab cassandra download-config
This saves configuration files to a local directory named after the version (e.g., 5.0/).
Part 5: Shut Down
When finished, destroy the cluster infrastructure:
easy-db-lab down
This permanently destroys all EC2 instances, the VPC, and associated resources. S3 data under the cluster prefix is scheduled for expiration (default: 1 day).
Quick Reference
| Task | Command |
|---|---|
| Initialize cluster | easy-db-lab init <name> [options] |
| Provision infrastructure | easy-db-lab up |
| Initialize and provision | easy-db-lab init <name> --up |
| Select Cassandra version | easy-db-lab cassandra use <version> |
| Apply configuration | easy-db-lab cassandra update-config |
| Start Cassandra | easy-db-lab cassandra start |
| Stop Cassandra | easy-db-lab cassandra stop |
| Restart Cassandra | easy-db-lab cassandra restart |
| Check cluster status | ssh db0 nodetool status |
| Download config | easy-db-lab cassandra download-config |
| Destroy cluster | easy-db-lab down |
| Display hosts | easy-db-lab hosts |
| Clean local files | easy-db-lab clean |
Next Steps
- Kubernetes Access - Access K3s cluster with kubectl and k9s
- Shell Aliases - All available CLI shortcuts
- ClickHouse - Deploy ClickHouse for analytics
- Spark - Set up Apache Spark via EMR
Legacy Integrations
The following databases have native integrations that predate the Kits system. They are fully functional but will eventually be migrated to kits.
Configuring Cassandra
This page covers Cassandra version management and configuration. For a step-by-step walkthrough, see the Tutorial.
Supported Versions
easy-db-lab supports the following Cassandra versions:
| Version | Java | Notes |
|---|---|---|
| 3.0 | 8 | Legacy support |
| 3.11 | 8 | Stable release |
| 4.0 | 11 | First 4.x release |
| 4.1 | 11 | Current LTS |
| 5.0 | 11 | Latest stable (recommended) |
| 5.0-HEAD | 17 | Nightly build from 5.0 branch |
| 6.0-HEAD | 21 | Nightly build from 6.0 branch |
| trunk | 21 | Development branch |
A released version downloads once and is then served from your account's S3 download cache. A nightly (5.0-HEAD, 6.0-HEAD, trunk) and any cassandra install --url tarball always download from their origin, so each bake or install gets the current build.
Quick Start
# Select Cassandra 5.0
easy-db-lab cassandra use 5.0
# Generate configuration patch
easy-db-lab cassandra write-config
# Apply configuration and start
easy-db-lab cassandra update-config
easy-db-lab cassandra start
# Verify cluster
ssh db0 nodetool status
Version Management
Select a Version
easy-db-lab cassandra use <version>
Examples:
easy-db-lab cassandra use 5.0 # Latest stable
easy-db-lab cassandra use 4.1 # LTS version
easy-db-lab cassandra use trunk # Development branch
This command:
- Sets the active Cassandra version on all nodes
- Downloads current configuration files locally
- Applies any existing
cassandra.patch.yaml
It fails if the version is not installed on a targeted node rather than leaving the node pointed at something that isn't there. Install it first — see Custom Builds.
Specify Java Version
easy-db-lab cassandra use 5.0 --java 11
This selects the JDK the already-installed build runs under. It does not reinstall or rebuild anything. The AMI has JDK 8, 11, 17, 21 and 25 installed. If the JDK switch fails on a node, the command fails.
List Available Versions
easy-db-lab ls
Versions installed on the node are listed first. A version declared with
lazy: true that has not been installed is listed too, marked
(declared, not installed).
Configuration
The Patch File
Cassandra configuration uses a patch file approach. The cassandra.patch.yaml file contains only the settings you want to customize, which are merged with the default cassandra.yaml.
Generate a new patch file:
easy-db-lab cassandra write-config
Options:
-t,--tokens: Number of tokens (default: 4)
Example patch file:
cluster_name: "my-cluster"
num_tokens: 4
concurrent_reads: 64
concurrent_writes: 64
trickle_fsync: true
The following settings are automatically managed by easy-db-lab. Including them in your patch file may cause problems:
listen_address,rpc_address— injected with each node's private IPseed_provider/seeds— configured automatically based on cluster topologyhints_directory,data_file_directories,commitlog_directory— set based on the cluster's disk configuration
Apply Configuration
easy-db-lab cassandra update-config
Options:
--restart,-r: Restart Cassandra after applying--hosts: Filter to specific hosts
Apply and restart in one command:
easy-db-lab cassandra update-config --restart
Download Configuration
Download current configuration files from nodes:
easy-db-lab cassandra download-config
Files are saved to a local directory named after the version (e.g., 5.0/).
Starting and Stopping
# Start on all nodes
easy-db-lab cassandra start
# Stop on all nodes
easy-db-lab cassandra stop
# Restart on all nodes
easy-db-lab cassandra restart
# Target specific hosts
easy-db-lab cassandra start --hosts db0,db1
Cassandra Sidecar
The Apache Cassandra Sidecar is automatically installed and started alongside Cassandra. The sidecar provides:
- REST API for Cassandra operations
- S3 import/restore capabilities
- Streaming data operations
- Metrics collection (Prometheus-compatible)
Sidecar Access
The sidecar runs on port 9043 on each Cassandra node:
# Check sidecar health
curl http://<cassandra-node-ip>:9043/api/v1/__health
Sidecar Management
The sidecar runs in K3s as the cassandra-sidecar DaemonSet, with one pod on each db node. Each pod uses the host network, so the sidecar answers on the node's own IP.
cassandra start deploys the sidecar to every db node, and cassandra stop removes it. A stop limited with --hosts leaves the sidecar in place while any other db node still runs the database; the stop that leaves no db node running removes it. cassandra restart restarts the database, then restarts the sidecar pods one at a time.
Use kubectl from the cluster workspace after source env.sh (see Kubernetes):
# See the sidecar pods and the node each one runs on
kubectl get pods -l app.kubernetes.io/name=cassandra-sidecar -o wide
# Read the sidecar logs
kubectl logs -l app.kubernetes.io/name=cassandra-sidecar --tail=100
# Restart the sidecar on every db node
kubectl rollout restart daemonset/cassandra-sidecar
Sidecar Configuration
The configuration is in the cassandra-sidecar-config ConfigMap. When a pod starts, it writes the configuration to /conf/sidecar.yaml in the pod, with the node's IP filled in. Key settings:
- Cassandra connection details
- Data directory paths
- Traffic shaping and throttling
- S3 integration settings
Custom Builds
To run a custom Cassandra build (your own fork, a feature branch, or a prebuilt tarball), you can build it on your own machine and publish it, install it onto a cluster that is already running, or bake it into the AMI so every future cluster has it.
Build your working branch and install it by name
If you are developing Cassandra itself, build the checkout you are already working in and publish it once, rather than making every node build the same branch:
cd ~/dev/cassandra
easy-db-lab cassandra build --java 17
# -> 5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17
easy-db-lab cassandra install 5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17
easy-db-lab cassandra use 5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17
The build is published to your profile's S3 bucket, so it outlives the cluster
and installs onto whatever you bring up next. Uncommitted changes are fine — the
manifest records the tree as dirty so you can tell later. cassandra list shows
everything you have published that is not yet on the node.
See cassandra build for the naming
scheme and what the manifest records.
Install onto a running cluster
easy-db-lab cassandra install <version>
easy-db-lab cassandra use <version>
This downloads (or clones and builds) the version on every Cassandra node and
records it in each node's version list, so cassandra use works afterward. It
never rebuilds the AMI, so nothing is preserved across a down/up cycle —
install it again on the new cluster.
Where the install parameters come from:
-
A declared entry (the default): whatever you declared in your profile's extras directory, described below. Nothing else to pass.
-
CLI options, for a genuinely one-off test with no file to edit:
easy-db-lab cassandra install my-build \ --url https://example.com/apache-cassandra-my-build-bin.tar.gz \ --java 11Or from a branch, built with ant on each node:
easy-db-lab cassandra install my-build \ --url https://github.com/myuser/cassandra.git \ --branch my-feature-branch \ --java 11 \ --ant-flags "-Duse.jdk11=true"An option you pass overrides the declared entry's value for that field; anything you leave out falls back to the declared entry.
--pythondefaults to3.11.9.
Useful details:
--hosts db0,db1installs on a subset of nodes only.- Re-installing a version a node already has is a no-op, as long as you ask for the same parameters it was built with. See Re-installing with different options.
- If a node fails, the command exits non-zero and names the node and the reason; nodes that succeeded keep their install.
- A branch build runs a full
antbuild on every targeted node, which is CPU-heavy and takes several minutes. It is safe while Cassandra is running — the install only writes to/usr/local/cassandra/<version>— but it is not free. Tarball installs are much cheaper.
Re-installing with different options
A version that is already on a node is never rebuilt. If you re-run install
for it with options that contradict what the node recorded when it was built —
a different --java, --python, or --ant-flags — the command changes nothing,
names the node and the fields that disagree, and exits non-zero:
db0: my-build is already installed, built with the parameters this node declares —
java: declared 11, requested 17. Nothing was changed. To change the java version
in use, run: cassandra use my-build --java <version>
This is deliberate: the bits on disk were compiled against Java 11, so silently
recording them as a Java 17 build would leave every later cassandra use
picking the wrong JDK.
What to do instead depends on what you actually want:
-
Run the existing build under a different JDK — no reinstall needed:
easy-db-lab cassandra use my-build --java 17 -
Genuinely rebuild with the new options — install it under a different version name (
my-build-jdk17), or remove/usr/local/cassandra/<version>on the affected nodes and install again.
A --url pointing at a private fork may carry a token
(https://<token>@github.com/...). The token is used for the clone and is never
written to the node's version list, never stored in the cluster workspace, and is
stripped from log lines, error messages, and emitted events. It does still live
in your shell history like any other command argument.
Bake into the AMI
You don't edit the repository's cassandra_versions.yaml. Instead, drop one or
more YAML files into your profile's extras directory:
~/.easy-db-lab/profiles/<profile>/cassandra_versions/
(The default profile is default.) At build time these are merged with the
built-in versions. Each version must be unique across the built-in list and
your extras, or the build fails.
1. Add a version entry
Create e.g. ~/.easy-db-lab/profiles/default/cassandra_versions/my-build.yaml.
Build from a git branch (cloned and compiled with ant during the AMI build):
- version: "my-build"
java: "11"
python: "3.10.6"
url: "https://github.com/myuser/cassandra.git"
branch: "my-feature-branch"
ant_flags: "-Duse.jdk11=true" # optional, passed to ant
Or install a prebuilt tarball:
- version: "my-build"
java: "11"
python: "3.10.6"
url: "https://example.com/apache-cassandra-my-build-bin.tar.gz"
An entry with no url/branch downloads the matching official Apache release.
2. Rebuild the Cassandra AMI
easy-db-lab build-cassandra
3. Select the custom build
easy-db-lab cassandra use my-build
Declaring a version without baking it
Add lazy: true to an entry to declare a version without spending AMI build
time on it:
- version: "my-build"
java: "11"
python: "3.11.9"
url: "https://github.com/myuser/cassandra.git"
branch: "my-feature-branch"
lazy: true
The AMI build skips the download/clone/build for that entry, but the entry
still ships in the image. easy-db-lab cassandra list shows it as
(declared, not installed), and easy-db-lab cassandra install my-build
installs it on a running cluster without you re-specifying any of its fields.
Next Steps
- Tutorial - Complete walkthrough
- Shell Aliases - Convenient shortcuts for Cassandra management
OpenSearch
AWS OpenSearch can be provisioned as a managed domain for full-text search and log analytics.
Commands
| Command | Description |
|---|---|
opensearch start | Create an OpenSearch domain |
opensearch status | Check domain status |
opensearch stop | Delete the OpenSearch domain |
Starting OpenSearch
easy-db-lab opensearch start
This creates an AWS-managed OpenSearch domain linked to your cluster's VPC. The domain takes several minutes to provision.
Checking Status
easy-db-lab opensearch status
Stopping OpenSearch
easy-db-lab opensearch stop
This deletes the OpenSearch domain. Data stored in the domain will be lost.
Spark
easy-db-lab supports provisioning Apache Spark clusters via AWS EMR for analytics workloads.
Enabling Spark
There are two ways to enable Spark:
Option 1: During Init (before up)
Enable Spark during cluster initialization with the --spark.enable flag. The EMR cluster will be created automatically when you run up:
easy-db-lab init --spark.enable
easy-db-lab up
Init Spark Configuration Options
| Option | Description | Default |
|---|---|---|
--spark.enable | Enable Spark EMR cluster | false |
--spark.master.instance.type | Master node instance type | m5.xlarge |
--spark.worker.instance.type | Worker node instance type | m5.xlarge |
--spark.worker.instance.count | Number of worker nodes | 3 |
Example with Custom Configuration
easy-db-lab init \
--spark.enable \
--spark.master.instance.type m5.2xlarge \
--spark.worker.instance.type m5.4xlarge \
--spark.worker.instance.count 5
Option 2: After up (standalone spark init)
Add Spark to an existing environment that is already running. This is useful when you forgot to pass --spark.enable during init, or when you decide to add Spark later:
easy-db-lab spark init
Prerequisites: easy-db-lab init and easy-db-lab up must have been run first.
Spark Init Configuration Options
| Option | Description | Default |
|---|---|---|
--master.instance.type | Master node instance type | m5.xlarge |
--worker.instance.type | Worker node instance type | m5.xlarge |
--worker.instance.count | Number of worker nodes | 3 |
Example with Custom Configuration
easy-db-lab spark init \
--master.instance.type m5.2xlarge \
--worker.instance.type m5.4xlarge \
--worker.instance.count 5
Submitting Spark Jobs
Submit JAR-based Spark applications to your EMR cluster:
easy-db-lab spark submit \
--jar /path/to/your-app.jar \
--main-class com.example.YourMainClass \
--conf spark.easydblab.keyspace=my_keyspace \
--conf spark.easydblab.table=my_table \
--wait
Submit Options
| Option | Description | Required |
|---|---|---|
--jar | Path to JAR file (local path or s3:// URI) | Yes |
--main-class | Main class to execute | Yes |
--conf | Spark configuration (key=value), can be repeated | No |
--env | Environment variable (KEY=value), can be repeated | No |
--args | Arguments for the Spark application | No |
--wait | Wait for job completion | No |
--name | Job name (defaults to main class) | No |
When --jar is a local path, it is automatically uploaded to the cluster's S3 bucket before submission. When it is an s3:// URI, it is used directly.
When a job submitted with --wait fails, the command waits up to 7 minutes for EMR to upload the step's final stderr. EMR uploads a running step's logs every few minutes and once more after the step ends, so an earlier copy can end before the exception. The command then shows the last lines of the final copy. If the final copy does not arrive in time, the command prints the S3 path where it will appear; fetch it later with easy-db-lab spark status --step-id <step-id> --logs.
Using a JAR Already on S3
If your JAR is already on S3 (e.g., from a CI pipeline or a previous upload), pass the S3 URI directly:
easy-db-lab spark submit \
--jar s3://my-bucket/jars/your-app.jar \
--main-class com.example.YourMainClass \
--conf spark.easydblab.keyspace=my_keyspace \
--wait
This skips the upload step entirely, which is useful for large JARs or when resubmitting the same job.
Cancelling a Job
Cancel a running or pending Spark job without terminating the cluster:
easy-db-lab spark stop
Without --step-id, this cancels the most recent job. To cancel a specific job:
easy-db-lab spark stop --step-id <step-id>
The cancellation uses EMR's TERMINATE_PROCESS strategy (SIGKILL). The API is asynchronous — use spark status to confirm the job has been cancelled.
Checking Job Status
View Recent Jobs
List recent Spark jobs on the cluster:
easy-db-lab spark jobs
Options:
--limit- Maximum number of jobs to display (default: 10)
Check Specific Job Status
easy-db-lab spark status --step-id <step-id>
Without --step-id, shows the status of the most recent job.
Options:
--step-id- EMR step ID to check--verbose- Show detailed step information (equivalent toaws emr describe-step)--logs- Download the step'sstderr.gzfrom the EMR log path in S3, save it underlogs/<cluster-id>/<step-id>/, and print it. If EMR has not uploaded the log yet, the command says so and prints the S3 path where it will appear.
Retrieving Logs
Query a Spark job's logs from Loki, scoped to the current cluster:
easy-db-lab spark logs --step-id <step-id>
Without --step-id, shows the logs of the most recent job. The step's name is its job's name, and the
job's lines are the ones whose service_name is spark-<job-name>; if a job name was run more than
once within --since, every run's lines are returned.
--step-id- EMR step whose job's logs to show--limit,-n- Maximum number of lines (default: 100)--since- Time range, e.g.1h,30m,1d(default:1d)
Every line from an EMR node also carries the label source="emr", so
easy-db-lab logs query --source emr shows all of them.
The collector on each EMR node also sends the log files that EMR and YARN write on the node:
| Files | Node |
|---|---|
YARN container stdout, stderr and syslog, /var/log/hadoop-yarn/containers/application_*/container_*/ | every node |
Step logs (controller, stderr, stdout, syslog), /mnt/var/log/hadoop/steps/<step-id>/ | primary |
Bootstrap action logs, /emr/instance-controller/log/bootstrap-actions/<n>/ | every node |
These lines have source="emr", the node's node_role (spark-master or spark-worker), its
host_name and the cluster label, and the file path in log_file_path. A driver that fails before
its Java agent sends anything still leaves its exception in the step's stderr:
easy-db-lab logs query --source emr --grep NoSuchMethodError
Architecture
When Spark is enabled, easy-db-lab provisions:
- EMR Cluster: Managed Spark cluster with master and worker nodes
- S3 Integration: Logs stored at
s3://<bucket>/spark/emr-logs/ - IAM Roles: Service and job flow roles for EMR operations
- Observability: Each EMR node runs an OTel Collector (host metrics, the node's EMR and YARN log files, OTLP forwarding), OTel Java Agent (auto-instrumentation for logs/metrics/traces), and Pyroscope Java Agent (continuous CPU/allocation/lock profiling). All telemetry flows to the control node's observability stack.
Timeouts and Polling
- Job Polling Interval: 5 seconds
- Maximum Wait Time: 4 hours
- Cluster Creation Timeout: 30 minutes
Spark with Cassandra
A common use case is running Spark jobs that read from or write to Cassandra. Use the Spark Cassandra Connector:
import com.datastax.spark.connector._
val df = spark.read
.format("org.apache.spark.sql.cassandra")
.options(Map("table" -> "my_table", "keyspace" -> "my_keyspace"))
.load()
Ensure your JAR includes the Spark Cassandra Connector dependency and configure the Cassandra host in your Spark application.
Bulk Write Implementations
easy-db-lab provides three different implementations for bulk writing data to Cassandra, each with different characteristics. All three use the same configuration properties (spark.easydblab.*) so you can easily compare performance by just swapping the JAR and main class.
Implementation Comparison
| Implementation | Transport | Use Case | Prerequisites |
|---|---|---|---|
| Direct (Sidecar) | DIRECT | Low latency, direct network path, single DC | Sidecar running, network connectivity |
| S3 Staging | S3_COMPAT | Large datasets, multi-dc | S3 bucket, IAM permissions |
| Connector | CQL | Standard writes, compatibility | Cassandra native protocol |
Direct Bulk Writer (Sidecar Transport)
Streams SSTables directly from Spark to Cassandra nodes via the Sidecar REST API on port 9043.
How it works:
- Spark generates SSTables from source data
- SSTables are streamed directly to Sidecar endpoints
- Sidecar validates and imports SSTables into Cassandra
When to use:
- Direct network connectivity between Spark and Cassandra
- Lower latency requirements
- Smaller to medium datasets
Limitations:
- Requires network connectivity from EMR to Cassandra on port 9043
- Streaming backpressure if Sidecar can't keep up
S3 Bulk Writer (S3 Staging Transport)
Stages SSTables in S3, then notifies Cassandra Sidecar to download and import them.
How it works:
- Spark generates SSTables and bundles them into ZIPs with manifests
- Bundles are uploaded to S3 bucket (provided by easy-db-lab)
- Spark pushes import notification to Sidecar REST API
- Sidecar downloads bundles from S3, validates checksums, filters by token ranges
- Sidecar imports SSTables into Cassandra
When to use:
- Large-scale bulk loads (terabytes)
- S3 provides durability and staging for retries
- Multi-DC Cassandra clusters
Prerequisites:
- S3 bucket (automatically provisioned by easy-db-lab as
clusterState.dataBucket) - EMR instance profile with S3 write permissions (automatically configured)
- Cassandra Sidecar running on port 9043
- For Cassandra 5.x:
storage_compatibility_mode: NONEincassandra.yaml(the bulk writer requires the new SSTable format)
Credentials:
- EMR uses instance profile (IMDS) to write to S3 - no manual credential configuration needed
- AWS region is auto-detected from EC2 metadata
- Sidecar uses its IAM role to read from S3
Benefits:
- S3 provides durability for large datasets
- Token range filtering ensures data goes to correct nodes
- Checksum validation guarantees data integrity
Standard Connector Writer
Uses the DataStax Spark Cassandra Connector to write data via CQL.
How it works:
- Spark generates rows as DataFrames
- Connector batches writes and sends via Cassandra native protocol (port 9042)
- Cassandra processes writes through normal write path (memtables → SSTables)
When to use:
- Smaller datasets
- Need CDC, triggers, or other write-time features
- Existing Spark Cassandra Connector pipelines
Limitations:
- Significantly slower than bulk writers for large datasets (goes through full write path)
- Compaction overhead after writes complete
- More network round-trips
Spark Modules
The Spark job modules live in the spark-examples
repository and share unified configuration via spark.easydblab.* properties. You can compare
performance across implementations by swapping the JAR and main class while keeping the same
--conf flags.
Module Overview
| Module | Gradle Path | Main Class | Transport | Description |
|---|---|---|---|---|
common | :common | — | — | Shared config, data generation, CQL setup |
bulk-writer-sidecar | :bulk-writer-sidecar | DirectBulkWriter | DIRECT | Streams SSTables directly to Sidecar |
bulk-writer-s3-iam | :bulk-writer-s3-iam | IamBulkWriter | S3_COMPAT | Stages SSTables in S3 via IAM credentials, imports via Sidecar |
connector-writer | :connector-writer | StandardConnectorWriter | CQL | Standard writes via Cassandra native protocol |
connector-read-write | :connector-read-write | KeyValuePrefixCount | CQL | Read→transform→write example |
The Gradle paths above are within the
spark-examplesbuild, not this repo.
Getting the job JARs
The job modules live in their own repository,
spark-examples, which publishes the
shadow (fat) JARs as GitHub Release assets. Download the one you need and pass it to
spark submit with --jar:
# Download a published job jar (check the releases page for the latest version)
curl -L -O https://github.com/rustyrazorblade/spark-examples/releases/download/v0.1.0/bulk-writer-sidecar.jar
Available jars: bulk-writer-sidecar.jar, bulk-writer-s3-iam-all.jar,
connector-writer.jar, connector-read-write.jar.
Releases: https://github.com/rustyrazorblade/spark-examples/releases
Usage
All modules use the same --conf properties for easy comparison.
Direct Bulk Writer (Sidecar)
Streams SSTables directly to Cassandra Sidecar endpoints:
easy-db-lab spark submit \
--jar bulk-writer-sidecar.jar \
--main-class com.rustyrazorblade.easydblab.spark.DirectBulkWriter \
--conf spark.easydblab.contactPoints=host1:9043,host2:9043,host3:9043 \
--conf spark.easydblab.keyspace=bulk_test \
--conf spark.easydblab.localDc=us-west-2 \
--conf spark.easydblab.rowCount=1000000 \
--wait
Note: Contact points should include the Sidecar port (9043).
IAM S3 Bulk Writer
Stages SSTables in S3 using IAM instance profile credentials, then imports via Sidecar. Cluster topology is auto-discovered from the Cassandra driver — no manual DC configuration needed:
easy-db-lab spark submit \
--jar bulk-writer-s3-iam-all.jar \
--main-class com.rustyrazorblade.easydblab.spark.IamBulkWriter \
--conf spark.easydblab.contactPoints=host1,host2,host3 \
--conf spark.easydblab.localDc=us-west-2 \
--conf spark.easydblab.s3.bucket=my-bucket \
--conf spark.easydblab.rowCount=10000000 \
--conf spark.easydblab.parallelism=20 \
--wait
Credentials: Both the EMR executor and the Cassandra Sidecar authenticate independently via their attached IAM roles. No credentials are extracted or transmitted.
Standard Connector Writer
Standard CQL writes via Cassandra native protocol:
easy-db-lab spark submit \
--jar connector-writer.jar \
--main-class com.rustyrazorblade.easydblab.spark.StandardConnectorWriter \
--conf spark.easydblab.contactPoints=host1,host2,host3 \
--conf spark.easydblab.keyspace=bulk_test \
--conf spark.easydblab.localDc=us-west-2 \
--conf spark.easydblab.rowCount=1000000 \
--wait
Note: Contact points for the connector use the Cassandra native protocol port (9042), not Sidecar.
Convenience Script
The bin/spark-bulk-write script handles JAR lookup, host resolution, and health checks:
# From a cluster directory
spark-bulk-write direct --rows 10000
spark-bulk-write s3 --rows 1000000 --parallelism 20
spark-bulk-write connector --keyspace myks --table mytable
Configuration Properties
All modules share these properties via spark.easydblab.*:
| Property | Description | Default |
|---|---|---|
spark.easydblab.contactPoints | Comma-separated database hosts | Required |
spark.easydblab.keyspace | Target keyspace | Required |
spark.easydblab.table | Target table | data_<timestamp> |
spark.easydblab.localDc | Local datacenter name | Required |
spark.easydblab.rowCount | Number of rows to write | 1000000 |
spark.easydblab.parallelism | Spark partitions for generation | 10 |
spark.easydblab.partitionCount | Cassandra partitions to distribute across | 10000 |
spark.easydblab.replicationFactor | Keyspace replication factor | 3 |
spark.easydblab.skipDdl | Skip keyspace/table creation (validates they exist) | false |
spark.easydblab.compaction | Compaction strategy | (default) |
spark.easydblab.s3.bucket | S3 bucket (S3 mode only) | Required for S3 |
spark.easydblab.s3.endpoint | S3 endpoint override | AWS S3 |
Table Schema
The test data generators produce this schema:
CREATE TABLE <keyspace>.<table> (
partition_id bigint,
sequence_id bigint,
course blob,
marks bigint,
PRIMARY KEY ((partition_id), sequence_id)
);
Troubleshooting
S3 Bulk Writer Issues
"Required property not set: spark.easydblab.s3.bucket"
The S3 bulk writer requires an S3 bucket for staging SSTables. Get the bucket from cluster state:
jq -r '.dataBucket' state.json
Then add it to your submit command:
--conf spark.easydblab.s3.bucket=<bucket-from-state>
"Failed to resolve AWS credentials"
The EMR instance profile should provide credentials automatically via IMDS. If this fails:
- Verify EMR cluster has
EasyDBLabEMREC2Roleinstance profile attached - Check IAM role has
s3:*permissions on the data bucket - Verify instance metadata service (IMDS) is accessible from EMR nodes
"Unable to detect AWS region"
Region is auto-detected from EC2 metadata. This should work automatically on EMR. If it fails, the EMR cluster may not have proper metadata access.
"S3 bucket name must be between 3 and 63 characters"
Bucket names must follow AWS S3 naming rules (3-63 characters, lowercase, DNS-compliant). Verify the bucket name in state.json.
Job succeeds but no data imported
Check Cassandra 5.x compatibility:
- Verify
storage_compatibility_mode: NONEincassandra.yaml - Cassandra 5.x defaults to
UPGRADINGmode which uses legacy SSTable format - The bulk writer requires
NONEto use the new SSTable format
To fix:
echo "storage_compatibility_mode: NONE" >> cassandra.patch.yaml
easy-db-lab cassandra update-config
easy-db-lab cassandra restart
Bundles uploaded but Sidecar didn't import
- Check Sidecar is running:
curl http://<cassandra-host>:9043/api/v1/health - Verify Sidecar has S3 read permissions (IAM role)
- Check Sidecar logs for download or validation errors
- Verify token ranges match between SSTables and Cassandra ring
Direct Bulk Writer Issues
"Connection refused" to port 9043
- Verify Cassandra Sidecar is running on all nodes
- Check security groups allow EMR → Cassandra on port 9043
- Ensure contact points use correct IP addresses (private IPs if in same VPC)
Slow writes / backpressure
The direct transport streams data and can be throttled if Sidecar can't keep up. Consider:
- Reduce
spark.easydblab.parallelismto lower write rate - Use S3 staging transport for large datasets
- Check Cassandra disk I/O and compaction status
Connector Writer Issues
"No route to host" or timeout
- Check security groups allow EMR → Cassandra on port 9042
- Verify contact points are reachable from EMR
- Ensure Cassandra native protocol is enabled
Slow performance
The connector uses the standard write path (not bulk write). For large datasets, use a bulk writer instead.
Kits
A kit is a self-contained package of configuration and scripts that installs, starts, stops,
and optionally backs up a workload on your cluster. Each kit defines its full lifecycle in a
kit.yaml file using typed steps — no Kubernetes YAML wrangling required.
easy-db-lab ships with built-in kits (ClickHouse, Presto, Trino, TiDB, memcached, Neo4j, sysbench, clickhouse-benchmark). You can also create your own kits for any workload you want to benchmark or test.
Discovering kits
List all available kits:
easy-db-lab kit list
Inspect a kit before installing it — see its args, endpoints, and available commands:
easy-db-lab kit info clickhouse
Run from a cluster workspace, kit info lists each endpoint at its node's private IP. Before
the cluster has hosts of that node type, it lists the bare NodePort (:30123).
Installing a kit
easy-db-lab kit install clickhouse --clickhouse-version 25.4 --size 100Gi
Args vary by kit. Run kit info <name> to see what a kit accepts, or pass --help:
easy-db-lab kit install clickhouse --help
After install, the kit's files are written into a subdirectory of the cluster workspace. The kit's lifecycle commands are registered automatically.
Bench kits — benchmarking a database
Bench kits are a special class of kit that run against an already-running database kit. They
require a --target flag pointing at the installed database kit you want to benchmark.
# Install sysbench targeting your running TiDB instance
easy-db-lab kit install sysbench --target tidb
# Run the prepare, start, and stop lifecycle as usual
easy-db-lab sysbench-tidb prepare
easy-db-lab sysbench-tidb start
easy-db-lab sysbench-tidb stop
The kit is installed into a directory named <bench-kit>-<target> (e.g. sysbench-tidb).
This lets you run the same bench kit against multiple databases simultaneously and compare results:
easy-db-lab kit install sysbench --target tidb
easy-db-lab kit install sysbench --target my-custom-db
# Both run at the same time — compare results in Grafana
easy-db-lab sysbench-tidb start
easy-db-lab sysbench-my-custom-db start
The target must expose a wire protocol endpoint the bench tool can speak — sysbench supports MySQL and PostgreSQL. See Sysbench for the full lifecycle, flags, and metrics.
TARGET_* environment variables
When a bench kit starts, easy-db-lab reads the target database's endpoint configuration and injects it as environment variables into every phase script:
| Variable | Description |
|---|---|
TARGET_JDBC_URL | Full JDBC connection URL (e.g. jdbc:clickhouse://10.0.1.5:8123/default) |
TARGET_JDBC_USER | Database username for JDBC connections |
TARGET_JDBC_DRIVER | Fully-qualified JDBC driver class name |
TARGET_PG_HOST | Host for PostgreSQL wire protocol connections |
TARGET_PG_PORT | Port for PostgreSQL wire protocol connections |
TARGET_PG_USER | Username for PostgreSQL wire protocol connections |
TARGET_PG_DATABASE | Database name for PostgreSQL wire protocol connections |
TARGET_MYSQL_HOST | Host for MySQL wire protocol connections |
TARGET_MYSQL_PORT | Port for MySQL wire protocol connections |
TARGET_MYSQL_USER | Username for MySQL wire protocol connections |
TARGET_MYSQL_DATABASE | Database name for MySQL wire protocol connections |
TARGET_HTTP_URL | Full URL for HTTP endpoint connections |
Which variables are populated depends on what endpoints the target kit declares. A kit that supports both JDBC and PostgreSQL wire protocol will populate both sets.
Running kit commands
Every installed kit gains a set of subcommands:
easy-db-lab clickhouse start # deploy and start the workload
easy-db-lab clickhouse status # show running state and connection endpoints
easy-db-lab clickhouse stop # stop and remove the workload
easy-db-lab clickhouse backup --name my-backup # back up data
easy-db-lab clickhouse restore --name my-backup # restore from backup
easy-db-lab clickhouse uninstall # stop and remove all kit resources
memcached
The memcached kit runs one memcached pod on a db node. By default it runs from RAM only and
creates no persistent volumes; --extstore-size adds extstore on the db node's NVMe (see
extstore below).
Install it, optionally setting the cache size in megabytes with --memory (default 1024,
passed to memcached as -m):
easy-db-lab kit install memcached --memory 4096
Start it:
easy-db-lab memcached start
memcached is published on NodePort 31211, so it is reachable on any node's private IP.
memcached start, memcached status and kit info memcached all print the endpoint resolved
to each db node's private IP:
easy-db-lab kit info memcached
# Endpoints:
# memcached db <db node private IP>:31211 native
Connect from a pod in the cluster, or from your machine over Tailscale:
printf 'set greeting 0 0 5\r\nhello\r\nget greeting\r\nquit\r\n' | nc <db node private IP> 31211
A memcached-exporter sidecar serves Prometheus metrics on port 9150. The collector finds the
pod by label and scrapes it, so the series land in Mimir under job="memcached". The
kit's METRICS.md lists them.
memcached extstore
extstore lets memcached keep item values on flash once RAM is full. It is off unless you pass
--extstore-size, which sets the size of the extstore file (a whole number with an M, G or T
suffix):
easy-db-lab kit install memcached --memory 4096 --extstore-size 100G
With extstore on, kit install creates a local PersistentVolume on the db node's NVMe, and the
memcached container mounts it at /data and runs with -o ext_path=/data/extstore:<size>.
These install options tune extstore. Each one is passed to memcached only when you set it, so
memcached's own default applies otherwise. Without --extstore-size they are ignored.
| Option | memcached option | Meaning |
|---|---|---|
--extstore-page-size | ext_page_size | Page size in MB |
--extstore-wbuf-size | ext_wbuf_size | Write buffer size in MB |
--extstore-threads | ext_threads | IO threads |
--extstore-item-size | ext_item_size | Smallest item, in bytes, that goes to flash |
easy-db-lab kit install memcached --extstore-size 100G --extstore-threads 8 --extstore-item-size 512
memcached uninstall also deletes the extstore volume claim, the PersistentVolume, and its
directory on the node.
Stopping and uninstalling
Stop it, or uninstall it to also remove the kit directory. stop deletes the Deployment, its
ReplicaSet and pods, the Service and the ConfigMap labelled easydblab/kit=memcached; uninstall
deletes those plus the extstore volume claim, then the extstore PersistentVolume:
easy-db-lab memcached stop
easy-db-lab memcached uninstall
Installing memcached a second time fails with a collision error and exits non-zero, leaving the
running kit untouched. Pass --force to overwrite the scaffold. Running memcached start while
memcached is already running also fails with a collision error; run memcached stop first.
stop returns once the memcached pod is gone, so start can follow it straight away.
Installing a custom kit
Place your kit directory under the profile kits folder and it will appear in kit list and be
installable by name like any built-in kit:
~/.easy-db-lab/profiles/default/kits/<kit-name>/
Custom kits in the profile directory take precedence over built-in kits with the same name.
mkdir -p ~/.easy-db-lab/profiles/default/kits/my-kit
cp -r /path/to/my-kit/* ~/.easy-db-lab/profiles/default/kits/my-kit/
# Now it appears in kit list and can be installed by name:
easy-db-lab kit install my-kit
Using kits from external projects
If you keep kit definitions alongside a private project (a POC, internal tooling, etc.), you can register that project's kits directory without copying files into your profile.
A typical project structure looks like this:
myapp/
├── src/
├── kits/
│ └── myapp-workload/
│ ├── kit.yaml
│ └── bin/
│ ├── start.sh
│ └── stop.sh
└── README.md
Clone your project and register the kits directory by name:
git clone https://github.com/myorg/myapp ~/myapp
easy-db-lab kit source add myapp ~/myapp/kits
The kits it contains now appear in kit list and can be installed by name:
easy-db-lab kit list
easy-db-lab kit install myapp-workload
Registered sources are persisted in ~/.easy-db-lab/profiles/<profile>/kit-sources.yaml and
survive CLI restarts. When you kit install a kit from an external source, its files are copied
into the cluster workspace exactly like any other kit — the installed kit is self-contained.
Managing registered sources
# List all registered sources (shows name and path, flags missing paths)
easy-db-lab kit source list
Output looks like:
Registered kit sources:
myapp /Users/jon/myapp/kits
If a registered path no longer exists on disk, [missing] appears next to it so you know
which sources need attention.
# Remove a source by name
easy-db-lab kit source remove myproject
Updating a path (upsert behavior): Sources are identified by name. If you move or reclone a project to a different location, just re-add the source with the new path — no need to remove the old registration first:
# If you move or reclone the project, just update the path — no need to remove first
easy-db-lab kit source add myapp ~/new-location/myapp/kits
# Updated kit source 'myapp': /new-location/myapp/kits
Resolution priority
When multiple sources provide a kit with the same name, the first match wins:
- Profile kits directory (
~/.easy-db-lab/profiles/<profile>/kits/) - Registered additional sources (in registration order)
- Built-in kits
For a full walkthrough of building and publishing your own kit, see the Kit Development guide.
ClickHouse
easy-db-lab supports deploying ClickHouse clusters on Kubernetes for analytics workloads alongside your database cluster.
Overview
The clickhouse kit deploys ClickHouse with the Altinity ClickHouse operator. The operator runs the servers from a ClickHouseInstallation (CHI) named clickhouse and the coordination service from a ClickHouseKeeperInstallation (CHK) named clickhouse-keeper. Both run only on db nodes.
Quick Start
Create a 3-node cluster and deploy ClickHouse as one shard with 3 replicas:
# Initialize and start a 3-node cluster
easy-db-lab init my-cluster --db 3 --up
# Install the kit: the operator, and the manifests rendered with your settings
easy-db-lab kit install clickhouse
# Deploy ClickHouse (1 shard x 3 replicas, one per db node)
easy-db-lab clickhouse start
Configuring ClickHouse
All ClickHouse settings are options of kit install clickhouse. They are applied when the kit is
installed: the manifests in the workspace's clickhouse/ directory are rendered with them, and
clickhouse start deploys those rendered manifests.
# Pin the server version and give each replica a 100Gi volume
easy-db-lab kit install clickhouse --version 25.4 --size 100Gi
# Larger S3 cache, no caching on write
easy-db-lab kit install clickhouse --s3-cache 50Gi --s3-cache-on-write false
| Option | Type | Default | Description |
|---|---|---|---|
--version | string | latest | ClickHouse server image tag (clickhouse/clickhouse-server:<version>), e.g. 25.4, 24.8 |
--size | string | 10Ti | Storage size per node (e.g. 100Gi) |
--replicas | int | number of db nodes | Number of ClickHouse replicas; also the number of Keeper replicas |
--s3-cache | string | 10Gi | Local cache size for the S3-backed storage policies |
--s3-cache-on-write | boolean | true | Populate the S3 disk cache on write |
--s3-tier-move-factor | float | 0.2 | move_factor of the s3_tier policy: data moves to S3 when local free space falls below this fraction |
--force | boolean | false | Overwrite the clickhouse/ directory if the kit is already installed |
To change a setting after installing, run kit install clickhouse --force with the new options,
then clickhouse stop and clickhouse start.
Starting ClickHouse
easy-db-lab clickhouse start
start creates the Local PersistentVolumes on the db nodes, applies the ClickHouseKeeperInstallation
and waits for the Keeper pods to be Ready (up to 300 seconds), then applies the ClickHouseInstallation
and waits for its status to reach Completed (up to 900 seconds) and for every server pod to be
Ready (up to 300 seconds). Finally it creates the clickhouse-nodeport service. The phase takes no
options of its own.
Example with Custom Settings
# 6 db nodes, run 3 replicas
easy-db-lab kit install clickhouse --replicas 3
easy-db-lab clickhouse start
Cluster Topology
ClickHouse is deployed by the Altinity ClickHouse operator as a ClickHouseInstallation named
clickhouse, with one shard and --replicas replicas (default: one per db node). The ClickHouse
cluster is also named clickhouse, which is the name to use in ON CLUSTER DDL.
Pod Names
The operator names each server pod chi-clickhouse-clickhouse-<shard>-<replica>-0, with shard
and replica counted from 0. A 3-replica deployment runs:
| Pod | Shard | Replica |
|---|---|---|
chi-clickhouse-clickhouse-0-0-0 | 0 | 0 |
chi-clickhouse-clickhouse-0-1-0 | 0 | 1 |
chi-clickhouse-clickhouse-0-2-0 | 0 | 2 |
List them with:
kubectl get pods -l clickhouse.altinity.com/chi=clickhouse -o wide
Pod-to-Node Placement
Server pods run only on db nodes, each on a Local PersistentVolume. A replica is placed on a db
node when it first starts, and its volume keeps it on that node across pod restarts, so data stays
local and does not move. Which db node a given replica lands on is chosen by the scheduler, not by
its replica number; use kubectl get pods -o wide (above) to see the mapping.
Shell Helpers
After source env.sh, two helpers connect to ClickHouse without looking up pod names or IPs:
# Interactive clickhouse-client in the first running server pod; extra args are passed through
clickhouse-client
clickhouse-client --query "SELECT version()"
# Send one query over HTTP to db0 (NodePort 30123)
clickhouse-query "SELECT 1"
clickhouse-query <<< "SELECT count() FROM system.tables"
Checking Status
easy-db-lab clickhouse status
This prints whether ClickHouse is running (with the count of Ready server pods) and the connection endpoints on each db node: HTTP and JDBC (30123), Native (30900), MySQL wire (30904) and PostgreSQL wire (30905).
Accessing ClickHouse
After deployment, ClickHouse is accessible on NodePorts of every db node. The default user has
no password.
| Interface | URL/Port | Description |
|---|---|---|
| Play UI | http://<db-node-ip>:30123/play | Interactive web query interface |
| HTTP API | http://<db-node-ip>:30123 | REST API for queries |
| Native Protocol | <db-node-ip>:30900 | High-performance binary protocol |
| MySQL wire | <db-node-ip>:30904 | MySQL-compatible protocol (mysql -h <ip> -P 30904 -u default) |
| PostgreSQL wire | <db-node-ip>:30905 | PostgreSQL-compatible protocol (psql -h <ip> -p 30905 -U default) |
The MySQL and PostgreSQL interfaces are protocol-compatible, not dialect-compatible: queries sent over them are parsed as ClickHouse SQL. They are handy for connecting standard clients and drivers, but tools that emit MySQL- or PostgreSQL-specific DDL will not work unmodified.
The kit also adds a sql command that runs a statement over JDBC as the default user:
easy-db-lab clickhouse sql "SELECT version()"
easy-db-lab clickhouse sql --file schema.sql
Creating Tables
The kit deploys one shard, so every replica holds all of the data. Use ReplicatedMergeTree to
keep the replicas in sync through ClickHouse Keeper, and create tables ON CLUSTER clickhouse so
the DDL runs on every replica:
CREATE TABLE events ON CLUSTER clickhouse (
id UInt64,
timestamp DateTime,
event_type String,
data String
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/events', '{replica}')
ORDER BY (timestamp, id);
Key points:
clickhouseis the cluster name defined in the ClickHouseInstallation{shard}and{replica}are macros the operator sets on each server- An insert on any replica is replicated to the others
- A query on any replica sees the whole table
Distributed Tables
A Distributed table routes queries and inserts across shards. With one shard it adds nothing
over querying the replicated table directly, but the pattern works unchanged:
CREATE TABLE events_dist ON CLUSTER clickhouse AS events
ENGINE = Distributed(clickhouse, default, events, rand());
INSERT INTO events_dist VALUES (1, now(), 'click', '{"page": "/home"}');
SELECT count(*) FROM events_dist WHERE event_type = 'click';
Table Engine Comparison
| Engine | Use Case | Replication | Sharding |
|---|---|---|---|
MergeTree | Single-node, no replication | No | No |
ReplicatedMergeTree | Replicated within shard | Yes | No |
Distributed | Query/insert across shards | Via underlying table | Yes |
Storage Policies
ClickHouse is configured with three storage policies. You select the policy when creating a table using the SETTINGS storage_policy clause.
Policy Comparison
| Aspect | default | s3_main | s3_tier |
|---|---|---|---|
| Storage Location | Local disk (local_disk) | Cluster's S3 data bucket, through a local cache | Hybrid: starts local, moves to S3 when disk fills |
| Performance | Best latency, highest throughput | Higher latency, cache-dependent | Good initially, degrades as data moves to S3 |
| Capacity | Limited by disk size | Virtually unlimited | Virtually unlimited |
| Cost | Included in instance cost | S3 storage + request costs | S3 storage + request costs |
| Data Persistence | Lost when cluster is destroyed | Kept in the data bucket after down | Kept in the data bucket after down |
| Best For | Benchmarks, low-latency queries | Large datasets, cost-sensitive workloads | Mixed hot/cold workloads with automatic tiering |
Local Storage (default)
The default policy stores data on the local disk of the db node, in the replica's Local
PersistentVolume. This provides the best performance for latency-sensitive workloads.
CREATE TABLE my_table (...)
ENGINE = MergeTree()
ORDER BY id
SETTINGS storage_policy = 'default';
If you omit the storage_policy setting, tables use this policy.
When to use local storage:
- Performance benchmarking where latency matters
- Temporary or experimental datasets
- Workloads with predictable data sizes that fit on local disks
- When you don't need data to persist after cluster teardown
S3 Storage (s3_main)
The S3 policy stores data in the cluster's S3 data bucket (under clickhouse/), which up creates,
with a local cache for frequently accessed data. The cache size defaults to 10Gi and is set with
kit install clickhouse --s3-cache. Caching on write is enabled by default
(--s3-cache-on-write true), so data written is cached and subsequent reads can be served from
cache immediately. This is ideal for large datasets where storage cost matters more than latency.
The servers reach S3 with the instance profile credentials; no keys are configured.
CREATE TABLE my_table (...)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/default/my_table', '{replica}')
ORDER BY id
SETTINGS storage_policy = 's3_main';
When to use S3 storage:
- Large analytical datasets (terabytes+)
- Datasets larger than the local disks
- Cost-sensitive workloads where storage cost > compute cost
down sets no expiry on the data bucket and deletes nothing in it, so data in it outlives the
cluster until you delete it. Use Backup and Restore to keep a dataset in a
place that is not tied to the cluster.
How the cache works:
- Hot (frequently accessed) data is cached locally for fast reads
- Cold data is fetched from S3 on demand
- Cache is automatically managed by ClickHouse
- First query on cold data will be slower; subsequent queries use cache
S3 Tiered Storage (s3_tier)
The S3 tiered policy moves data from local disk to S3 based on disk space. It has a hot volume on
the local disk and a cold volume on the cached S3 disk: data is written locally and moved to S3
when local free space runs low, giving local performance for hot data and S3 capacity for cold data.
Configure the tiering behavior when installing the kit:
# Move data to S3 when local disk free space falls below 20% (default)
easy-db-lab kit install clickhouse --s3-tier-move-factor 0.2
# More aggressive tiering - move when free space < 50%
easy-db-lab kit install clickhouse --s3-tier-move-factor 0.5
Then create tables with S3 tiered storage:
CREATE TABLE my_table (...)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/default/my_table', '{replica}')
ORDER BY id
SETTINGS storage_policy = 's3_tier';
When to use S3 tiered storage:
- Workloads with mixed hot/cold data access patterns
- Growing datasets that may outgrow local disk capacity
- Want automatic cost optimization without manual intervention
- Need local performance for recent data with S3 capacity for historical data
How automatic tiering works:
- New data is written to local disks first (fast writes)
- When local disk free space falls below the configured threshold (default: 20%), ClickHouse automatically moves data to S3
- Data on S3 is still queryable but with higher latency
- The local cache (configured with
--s3-cache) helps performance for frequently accessed S3 data - Manual moves are also possible:
ALTER TABLE my_table MOVE PARTITION tuple() TO DISK 's3'
Backup and Restore
The kit provides backup and restore commands. Backups are stored in your account-level S3 bucket, outside the per-cluster prefix, so they survive cluster teardown and can be restored into a new cluster.
ClickHouse's native BACKUP/RESTORE SQL is used. The backup destination is a named disk (s3_backup) configured in the ClickHouseInstallation, which points to:
s3://<account-bucket>/clickhouse-backups/<backup-name>/
The S3 disk uses IAM instance profile credentials — no AWS keys are stored anywhere in the cluster configuration. The required S3 permissions on the account-level bucket are handled automatically by the easy-db-lab IAM setup — no manual configuration is needed for standard clusters.
Creating a Backup
easy-db-lab clickhouse backup --name <backup-name>
Without --name, the backup is named backup-<yyyyMMdd-HHmmss>. The command fails if a backup with
that name already exists in S3, and prints the aws s3 rm command that deletes it. Otherwise it
runs, in the first ClickHouse server pod:
BACKUP DATABASE default TO Disk('s3_backup', '<backup-name>/');
Restoring a Backup
easy-db-lab clickhouse restore --name <backup-name>
In the first ClickHouse server pod, this first drops stale replica entries in Keeper for replicated
tables that no longer exist locally (left behind by a DROP TABLE without SYNC, they make the
restore fail with REPLICA_ALREADY_EXISTS), then runs:
RESTORE DATABASE default FROM Disk('s3_backup', '<backup-name>/');
Note: The restore command does not drop existing tables first. If tables with conflicting names exist, the restore will fail. Drop or truncate the conflicting tables before restoring.
Common Workflows
Snapshot before a destructive operation:
# Take a named snapshot before running a migration
easy-db-lab clickhouse backup --name pre-migration-snapshot
# Run the migration
# ...
# If something goes wrong, restore
easy-db-lab clickhouse restore --name pre-migration-snapshot
Persist data across cluster rebuilds:
# Back up before tearing down
easy-db-lab clickhouse backup --name my-dataset
easy-db-lab down
# In a new cluster workspace: create the cluster, install, start, and restore
easy-db-lab init my-cluster --db 3 --up
easy-db-lab kit install clickhouse --size 100Gi
easy-db-lab clickhouse start
easy-db-lab clickhouse restore --name my-dataset
Stopping ClickHouse
easy-db-lab clickhouse stop
This deletes the ClickHouseInstallation clickhouse (and with it the server pods) and the
clickhouse-nodeport service. Keeper, the operator, and the PersistentVolumes stay in place, so
clickhouse start redeploys onto the same volumes.
To remove everything the kit created:
easy-db-lab clickhouse uninstall
This deletes the ClickHouseKeeperInstallation clickhouse-keeper, the kit's PersistentVolumes, and
the clickhouse-operator Helm release.
Monitoring
ClickHouse metrics are automatically integrated with the observability stack. On clickhouse start
the kit registers two Prometheus scrape jobs. Both use pod discovery, so each pod is scraped once,
by the collector on its own node, with instance set to the pod name:
| Job | Pods | Port | Path |
|---|---|---|---|
clickhouse | server pods (clickhouse.altinity.com/chi=clickhouse) | 9363 | /metrics |
clickhouse-keeper | Keeper pods (clickhouse-keeper.altinity.com/chk=clickhouse-keeper) | 7000 | /metrics |
start also installs the kit's Grafana dashboards, one for metrics and one for logs, into a Grafana
folder named clickhouse.
Architecture
The ClickHouse deployment includes:
- Altinity ClickHouse operator: Helm release
clickhouse-operatorinkube-system, installed bykit install clickhouse - ClickHouse Server: ClickHouseInstallation
clickhouse, one cluster namedclickhousewith 1 shard and--replicasreplicas, on db nodes only - ClickHouse Keeper: ClickHouseKeeperInstallation
clickhouse-keeperwith--replicasreplicas on db nodes, used for replication andON CLUSTERDDL (ZooKeeper-compatible); the CHI references it by name - Service:
clickhouse-nodeport, a NodePort service in front of the server pods - Local PersistentVolumes: One PV per db node for data locality
Storage Architecture
ClickHouse uses Local PersistentVolumes to guarantee pod-to-node pinning:
- During cluster creation, each
dbnode is labeled with its ordinal (easydblab.com/node-ordinal=0, etc.) - Local PVs are created with node affinity matching these ordinals and the
type=dblabel - Each PV is labelled
app.kubernetes.io/name=clickhouse, and the claims select that label, so they never bind another kit's PV - The installation's volumeClaimTemplate requests
--sizeof storage from these PVs
Once a replica's claim binds a PV, that replica always runs on the PV's db node, providing:
- Data locality (no network storage overhead)
- No data movement when pods restart
The binding is made when the replica first starts, so replica numbers do not map to node numbers; see Pod-to-Node Placement.
Ports
Clients connect to the NodePorts on any db node. The container ports are what the pods listen on inside the cluster; the metrics scrapes use them directly.
| Container port | NodePort | Purpose |
|---|---|---|
| 8123 | 30123 | HTTP interface (also Play UI and JDBC) |
| 9000 | 30900 | Native protocol |
| 9004 | 30904 | MySQL wire protocol |
| 9005 | 30905 | PostgreSQL wire protocol |
| 9363 | 30936 | Server metrics (scraped on the container port) |
| 7000 | — | Keeper metrics |
| 2181 | — | Keeper client |
| 9444 | — | Keeper Raft |
Install Kafka
The kit install kafka command sets up Apache Kafka in KRaft mode (no ZooKeeper) using the Strimzi operator. Installation provisions local persistent volumes on db nodes and installs the Strimzi operator via Helm. easy-db-lab kafka start then applies the Kafka custom resource and waits for the cluster to become Ready; easy-db-lab kafka stop tears it back down.
Prerequisites
- Cluster is up (
easy-db-lab up) - At least one db node is provisioned
- Environment is sourced:
source env.sh
Quick Start
easy-db-lab kit install kafka
easy-db-lab kafka start
Install Flags
| Flag | Default | Description |
|---|---|---|
--version | 4.2.0 | Kafka version |
--brokers | 1 | Number of broker/controller nodes |
--storage-size | 10Ti | Persistent volume size per broker |
Managing Kafka
start
Deploys the Kafka cluster and waits for it to become Ready:
- Applies the JMX metrics ConfigMap
- Applies the
KafkaandKafkaNodePoolcustom resources - Waits for the Kafka cluster to report
Ready(up to 300s) - Applies NodePort services for the kafka-exporter and JMX exporter
Grafana dashboards in kafka/dashboards/ are installed automatically after a successful start.
stop
Removes the Kafka and KafkaNodePool resources and NodePort services. Persistent volumes are retained — topic data survives a stop/start cycle.
uninstall
Removes all Kafka resources including PVCs and PVs, then uninstalls the Strimzi operator.
Bootstrap Addresses
Internal (in-cluster pods, e.g. a Cassandra sidecar or another kit):
kafka.default.svc.cluster.local:9092
External (from outside the cluster via Tailscale):
<control-node-private-ip>:32100
The external address is printed in the kafka/README.md generated at install time.
Benchmarking
Producer performance test
easy-db-lab kafka producer-perf
| Flag | Default | Description |
|---|---|---|
--num-records | 1000000 | Number of records to produce |
--record-size | 1024 | Record size in bytes |
--throughput | 1000 | Target msg/sec (-1 for unlimited) |
--topic | perf-test | Topic name |
Consumer performance test
easy-db-lab kafka consumer-perf
| Flag | Default | Description |
|---|---|---|
--num-records | 1000000 | Number of records to consume |
--topic | perf-test | Topic name |
--group | bench-consumer | Consumer group ID |
Topic management
easy-db-lab kafka create-topic --topic my-topic --partitions 3 --replication-factor 3
| Flag | Default | Description |
|---|---|---|
--topic | perf-test | Topic name |
--partitions | 3 | Number of partitions |
--replication-factor | 1 | Replication factor |
Metrics
Two Prometheus scrape jobs are registered automatically by kafka start:
| Job | Container port | NodePort | Description |
|---|---|---|---|
kafka-exporter | 9404 | 32309 | Consumer lag, topic offsets, partition health |
kafka-jmx | 9404 | 32404 | Per-broker throughput, request latency, JVM metrics |
Both are scraped by pod discovery on the container port: each pod once, by the collector on its
own node, with instance set to the pod name. The NodePorts expose the same endpoints for manual
inspection.
Metric names are lowercase. See kafka/METRICS.md for the full catalog.
The Kafka Overview Grafana dashboard is installed automatically and shows broker health, throughput, consumer lag, and topic health panels.
CDC: Cassandra → Kafka
When using Kafka as a CDC target for Cassandra, configure the sidecar connector with the internal bootstrap address to avoid NodePort overhead:
kafka.default.svc.cluster.local:9092
Replication and Multi-Broker Setup
The default install uses a single combined broker/controller node. For replication testing, install with multiple brokers and set the replication factor when creating topics:
easy-db-lab kit install kafka --brokers 3
easy-db-lab kafka start
easy-db-lab kafka create-topic --topic my-topic --partitions 3 --replication-factor 3
--brokers must not exceed the number of db nodes in the cluster.
Cleaning Up
To remove Kafka and free disk space:
easy-db-lab kafka stop
easy-db-lab kit uninstall kafka
kit uninstall deletes the Kafka resources, PVCs/PVs, and the Strimzi operator.
Apache Ignite 3
easy-db-lab supports deploying Apache Ignite 3 clusters on Kubernetes for distributed SQL and in-memory computing workloads.
Overview
Apache Ignite 3 is a distributed database with ACID transactions, distributed SQL, and a pluggable storage engine. It runs as a StatefulSet on K3s with configurable storage profiles ranging from pure in-memory to fully disk-backed persistence.
Metrics are automatically pushed to the cluster's OTel Collector via OTLP and appear in Grafana.
Quick Start
# Initialize and start a 3-node cluster
easy-db-lab init my-cluster --db 3 --up
# Install and start Ignite 3 with default settings
easy-db-lab kit install ignite3
easy-db-lab ignite3 start
Configuration Options
| Option | Description | Default |
|---|---|---|
--replicas | Number of Ignite server nodes | db node count |
--storage | Storage profile (see below) | aipersist |
--version | Apache Ignite 3 Docker image version | 3.0.0 |
Storage Profiles
Ignite 3 supports three storage engines, selectable at start time:
| Profile | Description | Data survives restart? |
|---|---|---|
aimem | Pure in-memory, volatile | No |
aipersist | In-memory with disk persistence (default) | Yes |
rocksdb | Disk-based LSM, suited for large datasets | Yes |
# Start with pure in-memory storage (fastest, no persistence)
easy-db-lab ignite3 start --storage aimem
# Start with disk-based storage for large datasets
easy-db-lab ignite3 start --storage rocksdb
SQL Queries
Run SQL directly against the cluster using the thin client JDBC driver:
# Execute a SQL statement
easy-db-lab ignite3 sql "CREATE TABLE t1 (id INT PRIMARY KEY, val VARCHAR)"
easy-db-lab ignite3 sql "INSERT INTO t1 VALUES (1, 'hello')"
easy-db-lab ignite3 sql "SELECT * FROM t1"
# Execute SQL from a file
easy-db-lab ignite3 sql --file query.sql
Lifecycle
# Start the cluster
easy-db-lab ignite3 start
# Stop (preserves PVCs and data for aipersist/rocksdb profiles)
easy-db-lab ignite3 stop
# Start again — existing data is available immediately
easy-db-lab ignite3 start
# Remove all resources including data
easy-db-lab kit uninstall ignite3
Endpoints
| Name | Port | Protocol |
|---|---|---|
| REST / Management | 30300 | HTTP |
| Thin Client / JDBC | 30800 | TCP |
Metrics
Ignite 3 metrics are pushed to the cluster's OTel Collector via OTLP automatically at start time. No additional configuration is needed. Metrics appear in Mimir and are accessible from Grafana.
Neo4j
The neo4j kit runs Neo4j Community Edition as a single-replica StatefulSet on a db node. Its
data directory is on a local persistent volume that kit install provisions. Authentication is
off (NEO4J_AUTH=none), so clients connect without credentials.
Quick Start
easy-db-lab init my-cluster --db 3 --up
easy-db-lab kit install neo4j
easy-db-lab neo4j start
Configuration Options
| Option | Description | Default |
|---|---|---|
--version | Neo4j Community version: 5.6.0 or later 5.x, or a calendar-versioned release (2025.x, 2026.x, and later). The image is neo4j:<version>-community | 2026.09.0 |
--storage-size | Size of the data volume | 10Ti |
easy-db-lab kit install neo4j --version 5.26.0
start refuses unsupported versions before it applies anything:
- 4.x and earlier use different configuration keys from 5.x and the calendar-versioned releases.
- 5.0 through 5.5 are refused too; 5.6.0 is the first supported 5.x release. The kit loads the
Java agent through
NEO4J_server_jvm_additional. Images before 5.6.0 replace the stockserver.jvm.additionallines inneo4j.confwith that value instead of appending to them, so Neo4j would start without its stock JVM flags.
Endpoints
Neo4j is published on two NodePorts, reachable on any node's private IP:
| Name | Port | Protocol |
|---|---|---|
| Bolt | 30687 | Bolt (bolt://, neo4j://) |
| HTTP | 30474 | HTTP (Neo4j Browser and HTTP API) |
neo4j start, neo4j status and kit info neo4j print both endpoints resolved to each db
node's private IP: <db node private IP>:30687 for Bolt and http://<db node private IP>:30474
for HTTP.
The server advertises <first db node private IP>:30687 as its Bolt address, so a neo4j://
client that fetches the routing table gets an address it can dial, not a pod IP.
Connecting
Over Bolt, with cypher-shell or any Neo4j driver:
cypher-shell -a bolt://<db node private IP>:30687 "RETURN 1"
Over HTTP, with the transactional Cypher endpoint:
curl -s -H 'Content-Type: application/json' \
-d '{"statements":[{"statement":"RETURN 1"}]}' \
http://<db node private IP>:30474/db/neo4j/tx/commit
Neo4j Browser is at http://<db node private IP>:30474/.
Metrics
The OpenTelemetry Java agent from the base AMI is mounted into the pod and loaded by the Neo4j
JVM. It pushes metrics over OTLP to the collector on the pod's own node every 5 seconds. The
series land in Mimir under job="neo4j".
Lifecycle
# Stop: deletes the StatefulSet, Services, pods, and ConfigMap labelled easydblab/kit=neo4j.
# The data volume is kept.
easy-db-lab neo4j stop
# Start again with the existing data
easy-db-lab neo4j start
# Uninstall: deletes those plus the data volume claim, then the PersistentVolume and its
# directory on the db node
easy-db-lab neo4j uninstall
Installing Neo4j a second time fails with a collision error and exits non-zero, leaving the
running kit untouched. Pass --force to overwrite the scaffold. Running neo4j start while Neo4j
is already running also fails with a collision error; run neo4j stop first. stop returns once
the Neo4j pod is gone, so start can follow it straight away.
Install Presto
The install presto command scaffolds a Presto deployment using the Presto Helm chart. It generates scripts and a values.yaml in a local presto/ directory. Once installed, use easy-db-lab presto start and easy-db-lab presto stop to manage it.
Presto is stateless — it does not use persistent volumes. Queries run in-memory on app (type=app) nodes.
Prerequisites
- Cluster is up (
easy-db-lab up) - App nodes are provisioned (at least one
ServerType.Stressnode) kubectlandhelmare available in your PATH (or run via the easy-db-lab container)- Environment is sourced:
source env.sh
Quick Start
easy-db-lab kit install presto
easy-db-lab presto start
Flags
| Flag | Default | Description |
|---|---|---|
--workers | app node count | Number of Presto worker pods |
What Gets Generated
presto/
├── README.md # Usage instructions for this cluster
├── values.yaml # Helm values for the Presto chart
├── catalogs/
│ ├── cassandra.properties # Cassandra connector config
│ └── clickhouse.properties # ClickHouse JDBC connector config
└── bin/
├── start.sh # Deploy sequence
├── stop.sh # Teardown sequence
└── update-catalogs.sh # Re-renders Presto catalog config from running kits
Managing Presto
After installation, use the CLI to start and stop:
# Deploy Presto
easy-db-lab presto start
# Tear down Presto
easy-db-lab presto stop
start
helm upgrade --install presto prestodb/presto --version 0.4.0 -f values.yaml
# update-catalogs.sh runs automatically after helm install
kubectl wait --for=condition=Ready pods -l app=presto,component=coordinator --timeout=180s
No platform create-pvs step — Presto is stateless.
stop
helm uninstall presto
Automatic Catalog Management
Presto has built-in support for two catalogs that wire up automatically:
- Cassandra — uses the Cassandra connector, pointing at the cluster's Cassandra nodes
- ClickHouse — uses the ClickHouse JDBC connector, pointing at the cluster's ClickHouse nodes
When either of those kits starts or stops, Presto detects the change via a post-workload-start
/ post-workload-stop hook and re-renders its catalog configuration automatically. No restart
of Presto is needed.
# Start Cassandra — Presto wires up the cassandra catalog automatically
easy-db-lab cassandra start
# Start ClickHouse — Presto wires up the clickhouse catalog automatically
easy-db-lab kit install clickhouse
easy-db-lab clickhouse start
You do not need to restart Presto or manually update any config when adding or removing either kit.
Node Placement
Presto workers are scheduled on type=app nodes using nodeSelector: type: app. The coordinator runs on the control plane or app nodes depending on cluster size.
Connecting
After easy-db-lab presto start completes, the Presto coordinator is accessible within the cluster. Use kubectl port-forward or the SOCKS5 proxy to connect from your workstation:
kubectl port-forward svc/presto 8080:8080
Then connect with any Presto-compatible client at localhost:8080.
Adding Presto Catalogs for Custom Kits
If you install a custom kit via --from and want Presto to connect to it, drop a
presto-catalog.properties file in the kit's installed directory:
my-kit/
├── bin/
│ ├── start.sh
│ └── stop.sh
└── presto-catalog.properties # Presto picks this up automatically
The file follows the standard Presto connector properties format:
connector.name=jdbc
connection-url=jdbc:postgresql://localhost:5432/mydb
connection-user=presto
connection-password=
update-catalogs.sh scans all sibling kit directories for this file. When found, the catalog
is added to Presto's configuration under a name matching the kit directory name. No restart of
Presto is required — the script runs helm upgrade with the merged catalog values.
Install Trino
The install trino command scaffolds a Trino deployment using the Trino Helm chart. It generates scripts and a values.yaml in a local trino/ directory. Once installed, use easy-db-lab trino start and easy-db-lab trino stop to manage it.
Trino is stateless — it does not use persistent volumes. Queries run in-memory on app (type=app) nodes.
Prerequisites
- Cluster is up (
easy-db-lab up) - App nodes are provisioned (at least one
ServerType.Stressnode) kubectlandhelmare available on the control node- Environment is sourced:
source env.sh
Quick Start
easy-db-lab kit install trino
easy-db-lab trino start
Flags
| Flag | Default | Description |
|---|---|---|
--version | 474 | Trino release version to deploy |
--workers | app node count | Number of Trino worker pods |
What Gets Generated
trino/
├── README.md # Usage instructions for this cluster
├── values.yaml # Helm values for the Trino chart
├── catalogs/
│ ├── cassandra.properties # Cassandra connector config
│ └── clickhouse.properties # ClickHouse JDBC connector config
└── bin/
├── start.sh # Deploy sequence
├── stop.sh # Teardown sequence
├── uninstall.sh # Remove Helm release
└── update-catalogs.sh # Re-renders Trino catalog config from running kits
Managing Trino
After installation, use the CLI to start and stop:
# Deploy Trino
easy-db-lab trino start
# Stop Trino (scale to zero, no data loss)
easy-db-lab trino stop
Automatic Catalog Management
Trino has built-in support for two catalogs that wire up automatically:
- Cassandra — uses the Cassandra connector, pointing at the cluster's Cassandra nodes
- ClickHouse — uses the ClickHouse JDBC connector, pointing at the cluster's ClickHouse nodes
When either of those kits starts or stops, Trino detects the change via a post-workload-start
/ post-workload-stop hook and re-renders its catalog configuration automatically. No restart
of Trino is needed.
# Start Cassandra — Trino wires up the cassandra catalog automatically
easy-db-lab cassandra start
# Start ClickHouse — Trino wires up the clickhouse catalog automatically
easy-db-lab kit install clickhouse
easy-db-lab clickhouse start
Node Placement
Trino workers are scheduled on type=app nodes using nodeSelector: type: app.
Connecting
After easy-db-lab trino start completes, the Trino coordinator is accessible within the cluster. Connect using the SOCKS5 proxy or a port-forward:
kubectl port-forward svc/trino 8080:8080
Then connect with any Trino-compatible client at jdbc:trino://localhost:8080.
You can also use the built-in SQL command:
easy-db-lab trino sql "SELECT count(*) FROM cassandra.mykeyspace.mytable"
Adding Trino Catalogs for Custom Kits
If you install a custom kit via --from and want Trino to connect to it, drop a
trino-catalog.properties file in the kit's installed directory:
my-kit/
├── bin/
│ ├── start.sh
│ └── stop.sh
└── trino-catalog.properties # Trino picks this up automatically
The file follows the standard Trino connector properties format:
connector.name=postgresql
connection-url=jdbc:postgresql://localhost:5432/mydb
connection-user=trino
connection-password=
update-catalogs.sh scans all sibling kit directories for this file. When found, the catalog
is added to Trino's configuration under a name matching the kit directory name. No restart of
Trino is required — the script runs helm upgrade with the merged catalog values.
Presto vs Trino
Trino is the open-source fork of Presto (previously known as PrestoSQL). Both are supported as independent kits. Choose Trino for the active open-source community and Presto for compatibility with Meta's Presto ecosystem.
TiDB
The tidb kit deploys a TiDB HTAP cluster using the
TiDB Operator. TiDB combines a MySQL-compatible SQL layer with two storage engines:
TiKV (row store, for OLTP) and TiFlash (columnar store, for analytics) — letting you run
transactional and analytical queries against the same data.
Prerequisites
- Cluster is up (
easy-db-lab up) - At least 1 db node (runs TiKV and TiFlash)
- At least 1 app node (runs TiDB and PD)
Quick Start
easy-db-lab kit install tidb
easy-db-lab tidb start
easy-db-lab tidb sql "SELECT tidb_version()"
tidb start deploys the TiDB Operator-managed cluster and waits for each component
(PD, TiKV, TiDB, TiFlash) to become Ready. TiFlash takes the longest — expect a few
minutes on first start while images pull.
Flags
| Flag | Default | Description |
|---|---|---|
--version | v8.5.2 | TiDB version to deploy |
--replicas | db node count | Number of TiKV and TiFlash replicas (one per db node) |
Cluster Layout
| Component | Node type | Replicas | Role |
|---|---|---|---|
| PD | app | 1 | Placement driver / metadata |
| TiDB | app | one per app node | MySQL-compatible SQL layer |
| TiKV | db | --replicas | Row store (Raft) |
| TiFlash | db | --replicas | Columnar store (HTAP) |
Connecting
TiDB speaks the MySQL wire protocol, exposed as NodePort 30400 on every cluster node:
mysql -h <node-ip> -P 30400 -u root
Or use the built-in SQL command, which resolves the endpoint for you:
easy-db-lab tidb sql "SHOW DATABASES"
Using TiFlash
Tables are not automatically replicated to TiFlash. Enable replication per table:
ALTER TABLE my_table SET TIFLASH REPLICA 1;
Once the replica is in place, TiDB's optimizer routes analytical queries to TiFlash automatically. To force it for a specific query:
SELECT /*+ read_from_storage(tiflash[my_table]) */ count(*) FROM my_table;
Lifecycle
easy-db-lab tidb start # deploy the TiDB cluster
easy-db-lab tidb status # show running state and endpoints
easy-db-lab tidb stop # tear down the TiDB cluster
easy-db-lab tidb uninstall # stop the cluster if running, delete its PVCs, remove the TiDB Operator
uninstall of a running TiDB kit runs tidb stop first. It still refuses to run when a
TidbCluster exists that the kit does not know is running — run tidb stop first.
Monitoring
The kit registers four Prometheus scrape jobs with the cluster's observability stack automatically — no configuration needed:
| Job label | Component | What it covers |
|---|---|---|
tidb-sql | TiDB | Connections, query throughput, plan cache, errors |
pd | PD | Cluster health, TSO, region scheduling |
tikv | TiKV | Raft, RocksDB storage, coprocessor |
tiflash | TiFlash | MPP tasks, data exchange, storage throughput |
Metrics are available in Grafana and Mimir as soon as the kit starts.
TiDB also exports traces to Tempo (via the OTel Collector's Jaeger receiver, since TiDB
v8.x has no native OTLP support). Search for them in Grafana with service.name=TiDB —
the tag is case-sensitive.
Benchmarking
TiDB declares the sql capability, so bench kits can target it directly. See
Sysbench:
easy-db-lab kit install sysbench --target tidb
Sysbench
The sysbench kit runs sysbench OLTP benchmarks
against a running database kit. It is a bench kit: it does not deploy a database itself,
but targets one you've already started, connecting over the MySQL or PostgreSQL wire
protocol. Benchmark pods run inside the Kubernetes cluster, and per-interval results are
pushed to Mimir so you can watch throughput and latency live in Grafana.
Prerequisites
- Cluster is up (
easy-db-lab up) and has an app node (init --app.count 1or more). Every sysbench pod (the run, prepare, createdb and cleanup pods) runs on an app node (type=app), so it does not compete with the database it measures. With no app node,prepare,startandstopfail before they create a pod, withsysbench needs an app node (type=app); this cluster has none. Astopthat fails this way leaves the kit marked as running. - A database kit with the
sqlcapability is installed and running (e.g. TiDB) - The target kit exposes a MySQL or PostgreSQL wire protocol endpoint — sysbench connects over the wire protocol, not JDBC
Quick Start
# Start a database to benchmark
easy-db-lab kit install tidb
easy-db-lab tidb start
# Install sysbench pointed at it
easy-db-lab kit install sysbench --target tidb
# Load data, run the benchmark, clean up
easy-db-lab sysbench-tidb prepare
easy-db-lab sysbench-tidb start
easy-db-lab sysbench-tidb stop
The kit installs as sysbench-<target> — the instance name and the CLI subcommand both
include the target, so multiple sysbench instances can run against different databases
at the same time. See Bench kits for how
cross-kit targeting works.
Flags
| Flag | Default | Description |
|---|---|---|
--target | (required) | Name of the running database kit to benchmark |
--threads | 4 | Number of concurrent threads |
--duration | 60 | Benchmark duration in seconds |
--workload | oltp_read_write | sysbench built-in workload (oltp_read_write, oltp_read_only, oltp_write_only) |
--scale | 10 | Number of rows per table, in thousands |
--tables | 10 | Number of tables |
--rate | 0 | Target transactions/sec (0 = unlimited, thread-bound). See Rate limiting and overload testing |
--skip-trx | off | Run statements in autocommit instead of BEGIN/COMMIT transactions (on/off) |
--rand-type | special | Key access distribution: uniform, gaussian, special, or pareto |
--target is baked in at install time — to point sysbench at a different database, install
another instance. Every other flag is passed per invocation, so you can vary them run to run
without reinstalling:
easy-db-lab sysbench-tidb start --threads 32 --duration 300
Lifecycle
prepare
easy-db-lab sysbench-tidb prepare
Creates the sbtest database on the target if it doesn't exist, then loads the test
tables (--tables tables with --scale thousand rows each). Run this once before the
first benchmark run.
start
easy-db-lab sysbench-tidb start
Runs the benchmark for --duration seconds, streaming sysbench's interval output to
your terminal. Each 10-second interval report (TPS, QPS, p99 latency, errors/s) is also
pushed to Mimir.
When the run finishes, the final sysbench summary (SQL statistics, throughput, latency
percentiles, and errors) is written to last-run.txt in the kit's workspace directory
(e.g. sysbench-tidb/last-run.txt), prefixed with the run's parameters and its exit code. Each
run overwrites the file, a failed run too, so a run's numbers survive after the terminal output
scrolls away and the file never shows an older run.
When the run pod ends, start reads its exit code. When sysbench exits non-zero (for example after
FATAL: event queue is full), start says the run failed, gives the exit code, names
last-run.txt (which then holds the end of the run's log when sysbench printed no summary) and
exits non-zero, and does not print Run summary written to .... A run pod that cannot pull its image
fails start at once, with the reason and the pod's events; one that stays Pending fails it after
10 minutes.
Only one run per instance can be in progress. start fails with a collision error while a
run pod for the same instance (e.g. sysbench-tidb) is still pending or running; a finished
run does not block the next one. Instances pointed at different targets run side by side.
stop
easy-db-lab sysbench-tidb stop
Kills any running benchmark pod and runs sysbench cleanup, dropping the test tables from
the target database. If the target database is already stopped, stop still finishes and
removes its pods; it prints why the cleanup did not finish (the cleanup pod's log, or its events if it never started) and says the test tables were left.
Rate limiting and overload testing
By default (--rate=0) sysbench is thread-bound: each of the --threads worker threads
issues transactions as fast as the target will answer them, so throughput settles at
whatever the database can sustain. Setting --rate to a non-zero value switches sysbench
to a fixed target rate — it generates events on a schedule of that many transactions per
second and hands them to the worker threads, regardless of how fast the target is
actually responding.
That distinction matters when the requested rate exceeds what the target can sustain. The generated events queue up faster than the workers can drain them, sysbench's internal event queue fills, and the run hard-aborts with:
FATAL: event queue is full
The abort is fast — under 15 seconds into the run in the case that prompted this section,
regardless of the --duration you asked for. A --rate set well above capacity does not
produce a sustained high-latency window; it produces a run that dies almost immediately
with no useful results.
An aborted run is easy to miss after the fact, because it does not look like a failure
downstream. last-run.txt holds only the seeded parameter header with no SQL statistics
block, and the run's p50/p95/p99 series on the Grafana dashboard flatline at 0 — which
reads as a suspiciously excellent result rather than a crash. If latency drops to zero and
the summary is truncated, check the pod output for the FATAL line.
For overload and latency testing, drive the target past its limit with concurrency instead
of with a target rate: leave --rate=0 and raise --threads until latency climbs. A
thread-bound run applies backpressure naturally — slower responses mean fewer transactions
issued — so it degrades into a high-latency window instead of overflowing the event queue.
It is not immune to aborting for other reasons: sysbench still exits on unhandled SQL
errors, which a heavily overloaded target is more likely to return. If you do want a
fixed rate, first measure the target's sustainable throughput with a thread-bound run, then
set --rate at or just above that measured number rather than far above it.
Comparing Databases
Because each install is a separate named instance, you can benchmark several databases simultaneously and compare them side by side in Grafana:
easy-db-lab kit install sysbench --target tidb
easy-db-lab kit install sysbench --target my-custom-db
easy-db-lab sysbench-tidb prepare && easy-db-lab sysbench-tidb start
easy-db-lab sysbench-my-custom-db prepare && easy-db-lab sysbench-my-custom-db start
Of the built-in kits, TiDB and ClickHouse expose MySQL and PostgreSQL wire endpoints.
Any custom kit that declares a mysql or
postgresql endpoint and the sql capability in its kit.yaml can be targeted the
same way.
Note that ClickHouse's wire interfaces parse queries as ClickHouse SQL, and sysbench's
built-in oltp_* workloads issue MySQL-specific DDL during prepare — running them
against ClickHouse unmodified will fail at table creation. Benchmarking ClickHouse with
sysbench requires a custom Lua workload with ClickHouse-compatible schemas.
Metrics & Dashboard
The kit ships a Sysbench Benchmark Grafana dashboard, installed automatically. During
a run, these metrics are pushed through the OTel collector to Mimir, labelled by instance (kit):
| Metric | Description |
|---|---|
sysbench_tps | Transactions per second |
sysbench_qps | Queries per second |
sysbench_lat_p99_ms | 99th percentile latency (ms) — pushed per interval, plus a whole-run value at completion |
sysbench_lat_p95_ms | 95th percentile latency (ms), whole-run |
sysbench_lat_p50_ms | 50th percentile (median) latency (ms), whole-run |
sysbench_errors_per_second | Errors per second |
The kit label carries the instance name (e.g. sysbench-tidb), so runs against
different targets plot as separate series on the same panel.
sysbench's interval reports only emit the single configured percentile (p99), so
p50/p95 cannot be sampled per interval. The kit runs sysbench with --histogram and
parses the final latency histogram to derive whole-run p50/p95/p99, pushed once when
the run completes.
clickhouse-benchmark
The clickhouse-benchmark kit runs ClickHouse's own
clickhouse-benchmark
tool against a running ClickHouse kit, over the ClickHouse native protocol. It is
a bench kit: it deploys no database, but targets the clickhouse kit you have already started. The
ClickBench dataset and its 43 queries are built in, and
you can run your own query file instead.
The benchmark runs in a pod on an app node, so it does not compete with the database it measures. Every 10 seconds the kit pushes the tool's figures to Mimir, so you can watch throughput and latency live in Grafana.
Prerequisites
- The cluster is up (
easy-db-lab up) and has an app node (init --app.count 1or more). Every pod of this kit runs on an app node (type=app). With no app node,prepareandstartfail before they create a pod, withclickhouse-benchmark needs an app node (type=app); this cluster has none. - The
clickhousekit is installed and started.
Quick Start
easy-db-lab kit install clickhouse
easy-db-lab clickhouse start
easy-db-lab kit install clickhouse-benchmark --target clickhouse
easy-db-lab clickhouse-benchmark-clickhouse prepare
easy-db-lab clickhouse-benchmark-clickhouse start
easy-db-lab clickhouse-benchmark-clickhouse stop
The kit installs as clickhouse-benchmark-<target>. The target must declare a native and an
http endpoint; kit install refuses a target that lacks one and names every missing type. See
Bench kits for how cross-kit targeting works.
Flags
| Flag | Command | Default | Description |
|---|---|---|---|
--target | install | (required) | Name of the running ClickHouse kit to benchmark |
--version | install | latest | clickhouse/clickhouse-server image version for the benchmark and prepare pods |
--workload | prepare | clickbench | Dataset to load; clickbench is the only one |
--source | prepare | the public ClickBench files | S3 URL of the parquet files to load; see Loading from your own S3 files |
--storage-policy | prepare | default | Storage policy of the hits table (default, s3_main, s3_tier) |
--recreate | prepare | false | Drop an existing hits table and load it again |
--workload | start | clickbench | Packaged query set to run; give this or --queries, not both |
--queries | start | (none) | Local file of queries to run instead of a packaged set |
--concurrency | start | 4 | Number of queries run at the same time |
--duration | start | 60 | Stop starting queries after this many seconds (0 = no time limit) |
--iterations | start | 0 | Stop after this many queries (0 = no limit) |
--randomize | start | true | Run the queries in random order; --randomize=false runs them in file order |
--continue-on-errors | start | false | Keep running when a query fails, instead of stopping the run |
--settings | start | (none) | ClickHouse settings for every query, as key=value,key=value |
--target and --version are set at install time. The prepare and start flags apply to one
run, so you can change them from run to run without reinstalling.
Lifecycle
prepare
easy-db-lab clickhouse-benchmark-clickhouse prepare
easy-db-lab clickhouse-benchmark-clickhouse prepare --storage-policy s3_main --recreate
prepare creates the ClickBench hits table on every replica (ON CLUSTER clickhouse, as a
ReplicatedMergeTree table) with the storage policy you give, and then loads it on the server with
INSERT INTO hits SELECT * FROM s3(...). The target has one shard, so every replica holds the whole
table. The load runs from a prepare pod with no client-side time limit, so a long load is waited
out. When the load is done, prepare waits for every replica to catch up and checks that they all
hold the same number of rows. It fails, and prints each replica's count, when they differ.
Before it creates the pod, prepare checks the target over its HTTP endpoint:
- When
hitsalready exists,preparefails, names the table and its storage policy, and tells you to use--recreate. It does not change the table. With--recreateit dropshitson every replica first. - While a benchmark run of this instance is in progress,
preparefails and names the run pod. Runstop, or wait for the run to finish.
When a statement fails, prepare shows the server's error and exits non-zero. It never drops
hits. When the load itself fails, it also says hits may be partly loaded; run prepare --recreate to reload it.
hits lives on the target's disks. When you stop and start the clickhouse kit, the table is gone
(issue 985), so run prepare again before the next start.
Loading from your own S3 files
With no --source, the server reads the public ClickBench files,
https://clickhouse-public-datasets.s3.amazonaws.com/hits_compatible/athena_partitioned/hits_{0..99}.parquet,
anonymously. A --source you give is read with the cluster's IAM role
(use_environment_credentials), so it must be in a bucket that role can read; the role can read
the easy-db-lab-* buckets. Recent ClickHouse servers refuse to use the server's own credentials
in a query unless the setting s3_allow_server_credentials_in_user_queries is on, so for a
--source load prepare turns that setting on for the INSERT on servers that have it. The
anonymous default load never uses it. The files must have the ClickBench hits columns. A --source that
holds a ' or a \ is refused before any pod is created.
start
easy-db-lab clickhouse-benchmark-clickhouse start
easy-db-lab clickhouse-benchmark-clickhouse start --concurrency 16 --duration 300 --randomize=false
start runs the 43 ClickBench queries against the target's native endpoint, from a pod on an app
node, and streams the tool's output to your terminal. With the ClickBench queries it first checks
that hits exists and tells you to run prepare if it does not.
--duration and --iterations can both be set; the run stops at the first limit it reaches.
Only one run per instance can be in progress. A second start fails while a run pod of the same
instance is pending or running; a finished run does not block the next one.
When the run pod ends, start reads the pod's exit code. When it is not 0, start says the run
failed, gives the exit code and exits non-zero. Without --continue-on-errors, the first query
that fails ends the run with the server's error code.
Your own queries
easy-db-lab clickhouse-benchmark-clickhouse start --queries my-queries.sql
--queries sends a file from your machine to the run pod, so it works from a Homebrew install. Put
one query per line; a query cannot span lines. A relative path is relative to the current directory.
start does not check for hits when you give --queries. The file and the run's scripts must fit
in one ConfigMap, 1,000,000 bytes.
Settings
easy-db-lab clickhouse-benchmark-clickhouse start --settings max_threads=8,use_query_cache=0
Each key=value entry becomes --key=value on the clickhouse-benchmark command line, so it
applies to every query. A value cannot hold a comma. A key must be letters, digits and _, so use
max_threads, not max-threads. These keys are refused, because start sets them itself or the
kit does not support them: host, port, user, password, database, secure,
queries_file, query, concurrency, iterations, timelimit, delay, json, randomize,
continue_on_errors, cumulative, reconnect, query_id, confidence, stage,
max_consecutive_errors and help.
stop
easy-db-lab clickhouse-benchmark-clickhouse stop
stop deletes this instance's benchmark and prepare pods and their ConfigMaps. It leaves hits in
place and does not touch other kits' pods.
Results
When a run ends, start writes last-run.txt into the kit's workspace directory (for example
clickhouse-benchmark-clickhouse/last-run.txt). It holds the run's settings, the exit code and
the final report: queries executed, QPS, rows and MiB per second, and the latency percentiles. A
failed run writes it too. Each run replaces the file.
The results come from the run pod's full log. The kubelet rotates pod logs at about 10 MiB; the final report is printed last, so it is always kept, but early interval reports of a long run can be missing from the log.
Dashboard
The first successful start installs the clickhouse-benchmark dashboard into a Grafana folder
for the kit, so the dashboard shows data from the second run on. Its panels show the ten gauges the
kit pushes for each 10-second report:
clickhouse_benchmark_qps,clickhouse_benchmark_rows_per_secondandclickhouse_benchmark_mib_per_second: throughput in the interval.clickhouse_benchmark_queries_executed: queries run so far in the run.clickhouse_benchmark_errors: queries that failed in the interval.clickhouse_benchmark_latency_p50_ms,_p90_ms,_p95_ms,_p99_msand_p999_ms: query latency in milliseconds.
Every gauge carries kit=<instance>, so the instance filter separates benchmark instances. A link
goes to the clickhouse dashboard for the same cluster, to see the server side of the same run.
Install PostgreSQL
The postgres kit deploys PostgreSQL on K8s db nodes via the CloudNativePG (CNPG) operator. Data is persisted on PersistentVolumes — stopping and restarting preserves your dataset.
Prerequisites
- Cluster is up (
easy-db-lab up) with at least one db node
Quick Start
easy-db-lab kit install postgres
easy-db-lab postgres start
Flags
| Flag | Default | Description |
|---|---|---|
--version | 17 | PostgreSQL major version (e.g. 17, 16) |
--instances | 1 | Number of PostgreSQL instances; values > 1 deploy a primary and read replicas |
--size | 10Ti | Storage size per db node (e.g. 100Gi) |
Managing PostgreSQL
# Start PostgreSQL
easy-db-lab postgres start
# Stop PostgreSQL (data is preserved)
easy-db-lab postgres stop
# Remove operator and delete all PersistentVolumes
easy-db-lab postgres uninstall
start
- Creates PersistentVolumes on db nodes via
platform-pvs - Applies the CNPG
Clustercustom resource - Waits for all pods to reach
Ready - Applies the NodePort service for external access
stop
Deletes the CNPG Cluster CR and the NodePort service. PersistentVolumes are retained — running postgres start again resumes from the existing dataset.
uninstall
Deletes PersistentVolumes and uninstalls the CNPG operator Helm release. The operator is shared by
postgres and every postgres-<extension> instance, so it is kept while any CNPG Cluster is
left and removed with the last one.
Connecting
The PostgreSQL primary is exposed as a NodePort on port 30432 of each db node.
# JDBC URL
jdbc:postgresql://<db-node-ip>:30432/postgres
# psql via SOCKS5 proxy or port-forward
kubectl port-forward svc/postgres-nodeport 5432:5432
psql -h localhost -U postgres postgres
The default user is postgres with password postgres (stored in the postgres-credentials K8s Secret).
Running SQL
Use the built-in sql capability to execute queries directly:
easy-db-lab postgres sql "SELECT version()"
easy-db-lab postgres sql --file query.sql
Extensions
PostgreSQL extensions that require custom container images are activated at install time via --extension on kit install postgres. Each extension installs as a separate named instance (e.g. postgres-duckdb) that starts and stops independently.
Listing available extensions
easy-db-lab postgres extensions
Outputs a table of alias names, image templates, shared_preload_libraries, and CREATE EXTENSION statements.
Built-in aliases
| Alias | Description | PostgreSQL NodePort | Metrics NodePort |
|---|---|---|---|
duckdb | DuckDB analytical query engine via pg_duckdb | 30435 | 30990 |
postgis | Geospatial types and functions | 30433 | 30988 |
timescaledb | Time-series storage and query optimization | 30434 | 30989 |
Each instance has its own NodePorts, so plain postgres (30432 / 30987) and every extension
instance can run at the same time.
Example
# Install with an extension
easy-db-lab kit install postgres --extension duckdb
# Start, stop, and use the named instance
easy-db-lab postgres-duckdb start
easy-db-lab postgres-duckdb sql "SELECT duckdb_version()"
easy-db-lab postgres-duckdb stop
Versioning
Built-in alias images use __PG_MAJOR__ as a placeholder for the PostgreSQL major version (e.g. 17). This is substituted automatically from the --version flag set at install time.
Presto Integration
When both postgres and presto are running, Presto automatically exposes a postgres catalog using the PostgreSQL JDBC connector pointed at the CNPG primary service. No manual configuration is needed.
easy-db-lab kit install postgres
easy-db-lab postgres start
easy-db-lab kit install presto
easy-db-lab presto start
# postgres catalog is available automatically
easy-db-lab presto sql "SHOW CATALOGS"
Platform Substrate
The platform substrate is the set of Kubernetes primitives that easy-db-lab provisions on every cluster. It provides a stable foundation so any kit can be deployed without bespoke manifest code.
Two-Layer Model
┌─────────────────────────────────────┐
│ Kit Layer │
│ (ClickHouse, Presto, custom, …) │
│ deployed via install + helm/kubectl│
└────────────────┬────────────────────┘
│ uses
┌────────────────▼────────────────────┐
│ Platform Substrate │
│ StorageClasses · Node Labels · PVs │
│ provisioned at cluster `up` time │
└─────────────────────────────────────┘
The platform substrate is provisioned automatically when you run easy-db-lab up. You do not need to configure it manually.
StorageClasses
Two StorageClasses are created at up time:
| Name | Binding Mode | Reclaim Policy | Use |
|---|---|---|---|
local-storage | Immediate | Retain | Legacy / direct-bound PVs |
local-storage-wfc | WaitForFirstConsumer | Delete | Per-kit PVs via install / platform create-pvs |
local-storage-wfc is used for StatefulSet kits. Kubernetes must know which node a pod schedules on before binding the volume — WaitForFirstConsumer enforces this ordering.
Node Labels
All cluster nodes are labeled at up time:
| Label | Values | Applied to |
|---|---|---|
type | db, app, control | All nodes |
easydblab.com/node-ordinal | 0, 1, 2, … | db and app nodes |
Use nodeSelector: type: db (or app) in pod specs to constrain placement. The ordinal label is used by the PV pre-binding mechanism so each StatefulSet replica lands on the right node.
Persistent Volumes
Per-kit PVs are created lazily at install time, not at cluster-up time. Run platform create-pvs before starting a stateful kit:
easy-db-lab platform create-pvs --kit clickhouse --size 100Gi
This creates one PV per db node with:
- Path:
/mnt/db1/<kit>on each host - StorageClass:
local-storage-wfc - Node affinity:
type=<node-type>andeasydblab.com/node-ordinal=Nfor deterministic binding. Both pools carry ordinals, so the node type keeps a db PV off the app node with the same ordinal. - ClaimRef: pre-bound to
<volumeClaimTemplateName>-<kit>-N
The command is safe to re-run. If a PV exists with a stale claimRef (the PVC was deleted), the UID is cleared and the PV is returned to Available.
platform Commands
platform create-pvs
easy-db-lab platform create-pvs --kit <name> --size <Gi> [--node-type db|app] [--pvc-name <name>]
Creates one PV per node of the specified type. Defaults to db nodes. For stateless kits that need app-node storage, use --node-type app. --pvc-name sets the volumeClaimTemplate name to pre-bind against (default: data).
platform info
easy-db-lab platform info
Displays StorageClasses, available PV counts per node pool, node selector labels, and the ordinal label key. Use this to verify substrate readiness before deploying a kit.
platform cni
easy-db-lab platform cni
Shows the pod-network datapath. On a Cilium cluster it reads the cilium-config ConfigMap and the CiliumNode objects from the control node and prints the routing mode, IPAM mode, kube-proxy replacement, masquerade interfaces, native routing CIDR, the Hubble UI URL, and per node the ENI count, subnet CIDRs, and IPs allocated, used, and available. On a Flannel cluster it prints one line that names Flannel. Cilium is the default datapath; init --cni=flannel selects Flannel. Read-only. See Pod Networking (CNI).
Custom Templates
The install command can render templates from a custom directory:
easy-db-lab kit install --from ./my-kit/ --kit my-kit --size 50Gi
The directory must contain a kit.yaml. Without it the install is rejected before anything is
written to the workspace, because kit.yaml is what marks an installed directory as a kit.
Template Variable Contract
All templates receive these standard variables from cluster state:
| Variable | Description |
|---|---|
__CLUSTER_NAME__ | Cluster name |
__CONTROL_HOST__ | Control node public IP (alias for __CONTROL_HOST_PUBLIC__) |
__CONTROL_HOST_PUBLIC__ | Control node public IP |
__CONTROL_HOST_PRIVATE__ | Control node private IP (use for intra-cluster connectivity) |
__DB_NODE_COUNT__ | Number of database nodes |
__APP_NODE_COUNT__ | Number of app (stress) nodes |
__DB_NODE_IPS__ | Private IPs of database nodes |
__APP_NODE_IPS__ | Private IPs of app nodes |
__BUCKET_NAME__ | Per-cluster S3 bucket prefix |
__ACCOUNT_BUCKET__ | Account-level S3 bucket (survives cluster teardown) |
__REGION__ | AWS region |
__VPC_CIDR__ | VPC CIDR block |
__STORAGE_CLASS_WFC__ | local-storage-wfc |
__KIT_NAME__ | Kit name |
__STORAGE_SIZE__ | Storage size (e.g., 100Gi) |
__KUBECONFIG__ | Path to local kubeconfig |
__EASY_DB_LAB_EXEC__ | Path to the easy-db-lab executable |
__RUNNING_KITS__ | Names of currently running kits |
__OPENSEARCH_ENDPOINT__ | OpenSearch domain endpoint, if provisioned |
__PYROSCOPE_URL__ | Where a kit's Pyroscope agent ships profiles: the control node's Pyroscope, or the external stack on a telemetry-redirect cluster |
__TENANT__ | The cluster's observability tenant; send it as -Dpyroscope.tenant.id (it becomes X-Scope-OrgID) |
Unresolved __VAR__ placeholders emit a warning but do not fail the render.
Template Directory Layout
my-kit/
├── kit.yaml
├── README.md.template
├── values.yaml.template
└── bin/
├── start.sh.template
└── stop.sh.template
Files without .template suffix are copied verbatim.
Profile Templates
Place templates in ~/.easy-db-lab/profiles/<profile>/kits/<name>/ to make them discoverable via kit list. Additional template directories can be registered with kit source add. Resolution priority: profile templates override additional sources, which override built-in templates of the same name.
Port Exposure Model
Kits use standard pod networking (not hostNetwork). Client and metrics ports are surfaced on each EC2 instance's network interface in one of two ways:
- NodePort services — stateful db kits (ClickHouse, TiDB) expose their ports through a NodePort service, remapping native ports into the NodePort range (30000–32767). Example: ClickHouse HTTP
8123 → 30123. - hostPort patches — helm-based app kits (Presto, Trino) patch
hostPortmappings onto the coordinator pod at start time, keeping the native port (e.g.8080 → 8080).
Why ports must reach the host
The OTel collector DaemonSet runs with hostNetwork: true so it can scrape host processes and kit metrics endpoints, and so host JVMs such as Cassandra can push OTLP to it at localhost:4318. Built-in kits are scraped by pod discovery: each collector scrapes the kit pods on its own node directly, on the pod IP and container metrics port, so a metrics port does not need to reach the host for the collector (see Kit Observability). The NodePorts and hostPorts that still expose metrics ports are there for manual inspection (curl <node-ip>:<port>/metrics). Client ports do need to reach the host, and a NodePort-range port can never collide with a database listening on its native port on the host.
Port Assignments
| Kit | Protocol | Native port | Node port | Exposure |
|---|---|---|---|---|
| ClickHouse | HTTP | 8123 | 30123 | NodePort |
| ClickHouse | Native TCP | 9000 | 30900 | NodePort |
| ClickHouse | MySQL wire | 9004 | 30904 | NodePort |
| ClickHouse | PostgreSQL wire | 9005 | 30905 | NodePort |
| ClickHouse | Prometheus | 9363 | 30936 | pod SD (per-replica); NodePort also exposed |
| TiDB | MySQL (SQL layer) | 4000 | 30400 | NodePort |
| TiDB | Prometheus (tidb-sql) | 10080 | 31080 | pod SD (per-pod); NodePort also exposed |
| TiDB | Prometheus (tikv) | 20180 | — | pod SD (per-store) |
| TiDB | Prometheus (pd) | 2379 | 32379 | pod SD (per-pod); NodePort also exposed |
| TiDB | Prometheus (tiflash) | 8234 | 32234 | pod SD (per-pod); NodePort also exposed |
| Presto | HTTP (coordinator) | 8080 | 8080 | hostPort |
| Presto | Prometheus | 9090 | 9090 | pod SD; hostPort also exposed |
| Trino | HTTP (coordinator) | 8080 | 8080 | hostPort |
When adding a new kit, choose ports that do not conflict with any host process or existing kit in the table above. Each kit's ports are declared in its kit.yaml (metrics and endpoints sections).
Kit Observability
Each kit declares its metrics targets in kit.yaml. metrics is a list — kits with multiple components declare one entry per scrape target, each with a unique job name:
metrics:
- type: scrape # pod service discovery — each pod scraped directly, per-pod `instance`
job: tidb-sql
pod-selector: "app.kubernetes.io/component=tidb,app.kubernetes.io/instance=tidb"
port: 10080 # container metrics port, not a NodePort
path: /metrics
- type: scrape
job: tikv
pod-selector: "app.kubernetes.io/component=tikv,app.kubernetes.io/instance=tidb"
port: 20180
path: /metrics
If job is omitted, the kit name is used.
Static NodePort vs. pod discovery. By default a scrape target is a static localhost:<port>
NodePort — every collector scrapes it and instance is the collector's hostname. When a target
sets pod-selector (a comma-separated K8s label selector), the OTel collector instead uses
Prometheus pod service discovery (kubernetes_sd_configs, role: pod): each collector scrapes only
the matching pods co-located on its own node, and instance becomes the pod name. Use this when a
component has multiple pods behind one service and you need per-pod attribution — a NodePort
load-balances scrapes across all pods, so a single store/pod cannot be distinguished. TiKV uses this
so each of the 3 stores reports under its own instance (e.g. tidb-tikv-0). Every built-in kit
uses pod discovery: a static job runs on every collector, so even a single-instance kit behind a
NodePort reports one duplicate series per node.
Three modes are supported:
| Mode | How metrics reach the OTel collector |
|---|---|
scrape | OTel DaemonSet scrapes a Prometheus endpoint via hostPort |
java-agent | OTel Java agent inside the JVM pushes OTLP to localhost:4317 |
helm-native | Kit has built-in OTLP support configured via helm values |
Metrics Registration Lifecycle
When a kit with type: scrape targets starts successfully, easy-db-lab:
- Creates one ConfigMap
easydblab-metrics-<job>per scrape target in thedefaultnamespace, labeledeasydblab.com/workload-metrics=trueandeasydblab.com/kit=<kit>, containing the job name, port, and path. - Regenerates the OTel collector ConfigMap to include a Prometheus scrape job per target.
- Applies the updated OTel ConfigMap so the running collector picks it up.
When the kit stops:
- Deletes all of the kit's metrics ConfigMaps via the
easydblab.com/kitlabel selector. - Regenerates and applies the OTel collector ConfigMap without the kit's scrape jobs.
This is fully automatic — no manual OTel configuration is required when starting or stopping kits.
Verifying the Substrate
# Check StorageClasses and PV availability
easy-db-lab platform info
# List all nodes and their labels
kubectl get nodes --show-labels
# List PVs for a kit
kubectl get pv | grep clickhouse
Monitoring
Grafana Dashboards
Grafana is deployed automatically as part of the observability stack (k8 apply). It is accessible on port 3000 of the control node.
Cluster Identification
When running multiple environments side by side, Grafana displays the cluster name in several places to help you identify which environment you're looking at:
- Browser tab - Shows the cluster name instead of "Grafana"
- Dashboard titles - Each dashboard title is prefixed with the cluster name
- Sidebar org name - The organization name in the sidebar shows the cluster name
- Home dashboard - The System Overview dashboard is set as the home page instead of the default Grafana welcome page
Tenant pickers and the current cluster
Every dashboard has a picker for each kind of data it shows: Metrics, Logs and Traces. Each picker lists the cluster's own tenant (the default), every tenant in the shared store by name, and one "all tenants" choice. Change the pickers to read another tenant's tests; every panel, variable, annotation and link on the dashboard follows them, and a link to another dashboard keeps them. Profiles have one datasource only, so there is no profiles picker.
A dashboard with a cluster variable opens on the current cluster, so it shows this cluster's data with no selection by hand. The variable lists every cluster of the tenant the Metrics picker selects. You can select more than one cluster, or "All". With the "all tenants" datasource in each picker and "All" in cluster, a dashboard shows every cluster of every tenant. Every place a dashboard shows a cluster names it by its short name: its name and the first 8 characters of its id, for example test-1a2b3c4d. That covers the cluster pickers, legends, panel titles and tables. The pickers still select the full id, so queries and links read the whole <name>-<id>.
Tests
The Tests dashboard (Infrastructure folder) lists every test (cluster) of the tenant the Metrics picker selects, with its start, end and duration: its first and last up sample within Lookback (default 180 days). The dashboard opens on the last 24 hours; the list and the Test picker still cover the whole Lookback. Torn-down clusters are listed too. Click a cluster for:
- System Overview or Cassandra Overview, opened on that test's window;
- Compare with the current cluster, which opens Cluster Comparison with that test as the baseline and the current cluster as the candidate;
- Show documents, which selects the test and shows its documents below the list.
Comparing two tests
Cluster Comparison, A/B Comparison and System A/B Comparison compare two runs of any lengths: pick them in Baseline and Candidate (any cluster of the tenant within Lookback; both default to the current cluster). Three rows at the top show:
- Overlay: both runs on one time axis, from a common start. The axis covers the longer run.
- Side by side: each run on its own time range, at its real times.
- Summary and documents: each figure over each run's whole window, the difference in percent, and both runs' documents.
A/B Comparison compares builds within one cluster, so its deltas are per cluster. To compare two clusters, use Cluster Comparison.
These rows need the dashboard's relative time range (the default, ending now); an absolute range turns their own time ranges off. In the overlay, annotations sit at their real time, so they line up only with a run that did not move.
Test documents
Attach notes and results to a test as markdown files:
easy-db-lab report upload results.md notes.md
The files go to the test's folder in the account bucket, reports/<tenant>/<name>-<id>/, each with an HTML copy, and one index.html holds them all, each under its own heading. Only .md files named with letters, digits, ., _ and - are accepted, and index.md is refused. Each name can occur only one time in a command. The command names every file it refuses and uploads nothing. If an upload fails, the command names the failed file and each document it already stored; run the command again. A file with the name of a stored document replaces it. The command uses your own AWS credentials and needs only the workspace, so it works after down. up writes the index of a new test, which says "No documents yet".
The Tests dashboard and the comparison dashboards show the documents through a read-only web server in the Grafana pod (port 3080 of the control node). The server answers only GET requests for document paths under /reports/; it answers 400 for a path with other characters, and it drops the query string. Pick the tenant folder with Documents tenant; it defaults to the cluster's own tenant.
Two report upload runs for one test at the same moment can each rebuild the index without the other's document. The document itself is stored; the next upload puts it back in the index.
System Dashboard
Shows CPU, memory, disk I/O, network I/O, and load average for all cluster nodes via OpenTelemetry metrics.
System Overview and System A/B Comparison have a Role picker. It selects hosts by the role in their name:
| Role | Hosts |
|---|---|
| db | db0, db1, ... |
| app | app0, app1, ... |
| control | control0 |
| spark | EMR nodes, which EC2 names ip-... |
Pick one or more roles to show every host of those roles, with no host selection by hand. The host pickers list only hosts of the selected roles, and every panel shows only them. The default is All, which shows every host. A link from these dashboards to another dashboard keeps the selected roles.
AWS CloudWatch Overview
A combined dashboard showing S3, EBS, and EC2 metrics via CloudWatch. Available after running easy-db-lab up.
S3 metrics:
- Throughput: BytesDownloaded, BytesUploaded
- Request Counts: GetRequests, PutRequests
- Latency: FirstByteLatency (p99), TotalRequestLatency (p99)
EBS volume metrics:
- IOPS: VolumeReadOps, VolumeWriteOps (mirrored read/write chart)
- Throughput: VolumeReadBytes, VolumeWriteBytes (mirrored read/write chart)
- Queue Length: VolumeQueueLength
- Burst Balance: BurstBalance (percentage)
EC2 status checks:
- Status Check Failures: StatusCheckFailed_Instance, StatusCheckFailed_System (red threshold at >= 1)
Use the dropdowns at the top to select S3 bucket, EC2 instances, and EBS volumes.
How it works:
- S3 request metrics are automatically enabled for the cluster's prefix in the account S3 bucket during
easy-db-lab up - EBS and EC2 metrics are published automatically by AWS for all instances and volumes
- Grafana queries CloudWatch using the EC2 instance's IAM role (no credentials needed)
- During
easy-db-lab down, the S3 metrics configuration is automatically removed to stop CloudWatch billing
Note: S3 request metrics take approximately 15 minutes to appear in CloudWatch after being enabled. EBS and EC2 metrics are available immediately.
EMR Overview
Shows Spark/EMR node metrics via OpenTelemetry. Available when an EMR cluster is provisioned. Each EMR node runs an OTel Collector that collects host metrics and receives JVM telemetry from the OTel and Pyroscope Java agents.
Host Metrics:
- CPU Usage: Per-node CPU utilization percentage
- Memory Usage: Used and cached memory per node
- Disk I/O: Read/write throughput per node (mirrored chart)
- Network I/O: Receive/transmit throughput per node (mirrored chart)
- Load Average: 1m and 5m load per node
- Filesystem Usage: Root filesystem utilization percentage
Spark JVM Metrics:
- JVM Heap Memory: Used and committed heap per node/pool
- GC Duration Rate: Garbage collection duration rate per collector
- JVM Threads: Thread count per node
- JVM Classes Loaded: Class count per node
Use the Hostname dropdown to filter by specific EMR nodes.
OpenSearch Overview
Shows OpenSearch domain metrics via CloudWatch. Available when an OpenSearch domain is provisioned.
Metrics displayed:
- Cluster Health: ClusterStatus (green/yellow/red), FreeStorageSpace
- CPU / Memory: CPUUtilization, JVMMemoryPressure
- Search Performance: SearchLatency (p99), SearchRate
- Indexing Performance: IndexingLatency (p99), IndexingRate
- HTTP Responses: 2xx, 3xx, 4xx, 5xx (color-coded)
- Storage: ClusterUsedSpace
Use the Domain dropdown to select which OpenSearch domain to view.
Cassandra Overview
A deep-dive into Cassandra cluster health. Shows:
- Cluster Overview: Request throughput, errors by status, read and write latency spread across nodes, pending compactions, active tasks by pool
- Hardware / Operating System: CPU, load average, memory, disk throughput, network I/O
- Per-Node Latency: Read and write latency per node
- Data Status: Data size, SSTable count, compaction backlog against completion rate, SSTables per read
- Cassandra Internals: Thread pool pending and blocked tasks, dropped messages, hinted handoff
- JVM / Garbage Collection: Application throughput, GC time and pause percentiles, heap memory
- Storage & Capacity: Live against total disk space per table, storage growth rate per node
The metrics come from the OpenTelemetry Java Agent, which runs inside the Cassandra JVM and reads
Cassandra's own MBeans. It exports over OTLP to the node's OTel collector on port 4318, so no
Prometheus scrape port is involved. One agent serves every Cassandra release, and it needs no
per-release selection: a version installed with cassandra install --url or --branch reports the
same metrics as a stock release.
Series carry the Prometheus label job="cassandra", and each node also reports a cassandra_build
label naming the build it is running. That label is what lets a dashboard compare two versions in
one cluster. See OpenTelemetry for the metric names, the rule file,
and how a node derives its own labels.
If the agent jar or the rule file is missing, Cassandra says so on stderr at startup — check
journalctl -u cassandra on the node when a dashboard is empty.
Latency arrives as pre-aggregated percentile gauges read from Cassandra's own EstimatedHistogram,
the same reservoir nodetool proxyhistograms prints. histogram_quantile() does not apply to them,
and they cannot be re-aggregated into a cluster-wide percentile.
Annotations
Annotations mark a moment on the dashboards' timeline. Use them as A/B config-change markers; for example, drop one before and one after you change a Cassandra setting, so a latency shift lines up with the change that caused it.
Create an annotation with grafana annotate:
# A point marker at the current time
easy-db-lab grafana annotate --text "raised concurrent_writes to 128" --tags config
# A region marker spanning a window
easy-db-lab grafana annotate --text "load test" --tags test --time -30m --time-end now
A plain global marker (no --dashboard and no --panel scope) is automatically tagged easydblab, in addition to any --tags you pass. Every annotation is also mirrored to Loki as it is created (source="annotation"), and the core dashboards read their markers from Loki, so a global marker renders on every core dashboard and stays readable from S3 after the cluster is gone. A scoped marker (--dashboard or --panel) is not auto-tagged; it renders on its target dashboard. See Command Reference for all options.
Backing up annotations
The cluster is ephemeral, but the annotations are worth keeping. Back them up to the tenant's annotations directory in the account bucket (grafana/annotations/<tenant>/):
easy-db-lab grafana backup
This backup also runs automatically before teardown. When you run easy-db-lab down, the tool mirrors the annotations to Loki and stops the OTel collector, then flushes Loki and Mimir, waits for Tempo to upload its last traces, and backs up the annotations, all at once, before it removes any infrastructure. If any of that fails, down removes nothing and never starts a backend again; the report names every signal that is not saved. Pass --force to skip these steps and tear down anyway; it lists what it will not save before the confirmation prompt. See down for details.
Where observability data is stored
Every cluster belongs to an observability tenant, chosen at init --tenant <name> (default default) and fixed for the life of the cluster. Clusters that share a tenant share one store: their metrics, logs, traces, profiles and annotations sit side by side and can be read together.
All of a cluster's observability data lands in the account bucket. Each backend has its own root at the top level of the bucket, named for the tool whose file format it holds:
| Location | What | Written by |
|---|---|---|
tempo/<tenant>/ | Tempo blocks | Tempo, while the cluster runs |
pyroscope/ | Pyroscope v2 segments and blocks | the Pyroscope server, while the cluster runs |
mimir/<tenant>/ | Mimir blocks | Mimir, while the cluster runs, and its flush at down |
loki/ | Loki chunks (under the tenant) and TSDB index (under index/) | Loki, while the cluster runs, and its flush at down |
grafana/annotations/<tenant>/<yyyyMMdd-HHmmss>_<name>-<clusterId>.json | Grafana annotations | grafana backup, and down |
reports/<tenant>/<name>-<id>/ | Test documents: your .md files, an HTML copy of each, and index.html | report upload, and up (the index) |
An annotations backup never overwrites another: when one of the same cluster already exists under that second's name (a grafana backup just before down, say), the new one takes the next free second.
Cluster configuration stays under clusters/<name>-<id>/config/. The observability stack writes nothing to the per-cluster data bucket. easy-db-lab status shows the tenant and every location.
Native multi-tenancy. Mimir, Loki, Tempo and Pyroscope run with multi-tenancy on; the tenant is their tenant ID. Every writer sends it in the X-Scope-OrgID header — the OTel collector's metrics, logs and trace exporters, Tempo's metrics generator, the Alloy eBPF profiler, the Pyroscope Java agent (stress jobs, the Cassandra sidecar, EMR Spark, the Trino and Presto kits) and the Cassandra JFR shipper — on redirected clusters too. Every Grafana datasource sends it on every query, so a query from the cluster's Grafana returns its tenant's data. A query that sends no tenant, such as one made directly against a backend's HTTP API, reads nothing; add -H 'X-Scope-OrgID: <tenant>'. Pyroscope's own UI on port 4040 asks for the tenant once per browser; see Profiling.
Nothing is deleted automatically. No S3 lifecycle, expiry or retention rule is set on any bucket, and down deletes no data. The cluster's IAM role is denied deletes under mimir/, loki/, tempo/, grafana/ and reports/. pyroscope/ is left out: Pyroscope's own compaction writes a merged block and then removes the segments it merged, which loses nothing. Mimir runs no compactor and no retention. Loki's compactor is idle, its retention is off and its delete API is not served. Tempo runs with compaction — which is also what runs retention in Tempo 3 — turned off for every tenant, so no block is ever deleted. Pyroscope runs pure v2 storage with the metastore's retention cleanup off and no retention period; its v2 compaction merges this cluster's segments into blocks, which keeps their data.
Uploads within minutes. Each backend uploads while the cluster runs, so other clusters in the tenant can read the data: a metric sample is in mimir/ in about 2 minutes, a span in tempo/ in about 2 minutes, and a log line of a stream that keeps writing in loki/ in about an hour, with its index file at Loki's next 15-minute index rotation. Profiles are in pyroscope/ as soon as Pyroscope accepts them.
Durability across restarts. Tempo cuts a block at most every minute and uploads it while the cluster runs. Both of its write-ahead logs live on the control node's disk (/mnt/db1/tempo), so a restart of the Tempo pod does not lose spans it received but has not yet uploaded. Tempo acknowledges a push while the spans are still in memory and writes them to the WAL about 2 seconds later (10 seconds at most for a trace that is still receiving spans). A graceful restart writes every in-memory trace to the WAL first and loses nothing. A Tempo process killed outright loses only the spans from those last 2 seconds. The WAL is not fsynced, so a crash of the control node itself can also lose WAL data the operating system had not yet written to disk. Tempo keeps every span it receives: its per-tenant limits on live traces and trace size are off, and its ingestion rate limit is 1 GB/s, so a stress load is not thinned out there. The stress job itself traces only 1% of its requests, so request rates for cassandra-easy-stress on the Trace RED and Tempo dashboards show 1% of the real rate. It also stores every attribute value in full: the distributor's max_attribute_bytes is 0, where Tempo's default of 2048 bytes would truncate long values such as process.command_line. Any span Tempo does drop is counted in tempo_discarded_spans_total. The Pyroscope metastore index lives on /mnt/db1/pyroscope, so profiles written before a Pyroscope restart are still returned after it.
Known limit: Pyroscope after teardown. Pyroscope v2 finds blocks only through the cluster's local metastore index and cannot rebuild it from S3. After down, the profiles stay in pyroscope/ but cannot be queried without that index, which down does not save.
Restarts. up and grafana update-config restart an observability workload only when its configuration changed: each workload's pod template carries a hash of the pod template itself (image, probes, environment, volumes) and of the ConfigMaps it reads. A dashboard edit no longer restarts Tempo or Pyroscope. Both commands print, for each workload, whether its configuration changed (and it is rolling) or is unchanged (and it is left running). A new image or probe counts as a change, because Kubernetes rolls the workload for it.
Versions. Tempo 3.0.3, Pyroscope 2.3.1, Grafana 13.2.2 with image renderer v5.12.4, Alloy v1.20.0, Beyla 3.36.0, Mimir 3.2.1, Loki 3.7.8, the OpenTelemetry collector 0.161.0 (cluster, stress-job sidecar and EMR), and Fluent Bit 5.1.2. No observability image runs latest. The collector also scrapes its own telemetry (:8888, job otel-collector), so exporter send failures are visible in Mimir. In local mode it also scrapes the Mimir (:9009, job mimir), Loki (:3100, job loki), Tempo (:3200, job tempo) and Pyroscope (:4040, job pyroscope) servers' own metrics. Only the collector on the node running each server scrapes it, so each job has one target, and instance is the server's pod name. A redirect cluster runs none of these servers and renders none of these jobs.
eBPF Observability
The cluster deploys eBPF-based agents on all nodes for deep system observability:
Beyla (L7 Network Metrics)
Grafana Beyla uses eBPF to automatically instrument network traffic and provide RED metrics (Rate, Errors, Duration) for:
- Cassandra CQL protocol (port 9042) and inter-node communication (port 7000)
- ClickHouse HTTP (port 8123) and native (port 9000) protocols
Metrics are scraped by the OTel collector and stored in Mimir.
Beyla observes from outside the process only: its Java agent injection is turned off (javaagent.enabled: false), so it never attaches an agent to the database JVM.
ebpf_exporter (Low-Level Metrics)
Cloudflare's ebpf_exporter provides kernel-level metrics via eBPF:
- TCP retransmits — count of retransmitted TCP segments
- Block I/O latency — histogram of block device I/O operation latency
- VFS latency — histogram of filesystem read/write operation latency
These metrics are scraped by the OTel collector and stored in Mimir.
kube-state-metrics
kube-state-metrics runs on the control node of every cluster and exposes the state of Kubernetes objects as Prometheus metrics:
- Pods —
kube_pod_status_phase,kube_pod_container_status_restarts_total,kube_pod_container_status_waiting_reason - Workloads —
kube_deployment_status_replicas_available,kube_daemonset_status_number_ready,kube_statefulset_status_replicas_ready,kube_job_status_succeeded - Nodes and storage —
kube_node_status_condition,kube_persistentvolumeclaim_status_phase
The OTel collector scrapes it once, through pod discovery on the control node, so each series appears one time. The metrics carry the cluster label like every other scrape.
Cilium metrics
On a Cilium cluster (the default CNI), the OTel collector also scrapes the Cilium agent (localhost:9962 on every node), Hubble (localhost:9965 on every node), and the Cilium operator (port 9963 on the node that runs it). A Flannel cluster has none of these jobs. See Pod Networking (CNI) for the job definitions, the Hubble UI NodePort, and platform cni.
See Profiling for continuous profiling with Pyroscope.
Redirecting Telemetry to an External Stack
By default each cluster stands up its own local observability backends on the control node: Mimir, Loki, Tempo, the Pyroscope server, and Grafana. Redirect mode ships all telemetry to an external observability stack instead. A redirect cluster stands up no local backends and no local Grafana.
Redirect mode is useful when you run several clusters and want one place to view them all. A common case is a second data center that reports into the first data center's stack.
Enabling Redirect Mode
Set the redirect target once, at init, with --redirect-telemetry <host>:
easy-db-lab init --redirect-telemetry 10.0.0.9 --up
<host> is the hostname or IP of the external stack. The tool derives the four signal endpoints from that host and the known stack ports:
- Metrics:
http://<host>:9009/api/v1/push(Mimir remote write) - Logs:
http://<host>:3100/otlp(Loki OTLP ingest) - Traces:
<host>:4320(Tempo OTLP gRPC receiver) - Profiles:
http://<host>:4040(Pyroscope ingest)
The traces endpoint uses the OTLP receiver port 4320, not Tempo's query port 3200.
Redirect mode is a life-of-cluster choice made at init. All four signals move together; you cannot redirect one signal and keep the rest local. You cannot switch a running cluster between local and redirect mode.
Overriding Individual Endpoints
If the external stack does not use the easy-db-lab port layout, override any signal. An override wins over the derived value; the other signals still derive from the base host.
easy-db-lab init --redirect-telemetry 10.0.0.9 \
--redirect-metrics-endpoint http://metrics.example.com:9009/api/v1/push \
--redirect-traces-endpoint tempo.example.com:4320 \
--up
The four override options are --redirect-metrics-endpoint, --redirect-logs-endpoint, --redirect-traces-endpoint, and --redirect-profiles-endpoint.
Origin Identity
Every signal carries the source cluster's name as its origin identifier. Metrics and traces carry a cluster label; logs carry a cluster field; profiles carry a cluster label. Use that identifier to tell one cluster's telemetry from another on the shared stack.
Commands That Need the Local Stack
A redirect cluster has no local backends or Grafana to act on. These commands refuse to run on a redirect cluster and tell you why:
grafana update-configlogs query
down also skips its pre-teardown save (annotation mirror, collector stop, flushes, Tempo drain and annotations backup) on a redirect cluster.
Run these commands against the external stack instead. Kit dashboards are not installed on a redirect cluster; the dashboards live on the external stack's Grafana.
Validation
The tool validates the four endpoints for well-formedness at init and again at the start of up, before it creates any AWS resource. If a signal endpoint is missing or malformed, the tool names the offending signal and stops without standing anything up. Validation checks structure only; it does not probe the external stack for reachability. An unreachable but well-formed endpoint surfaces later as a collector send failure.
Profiling
Continuous profiling is provided by Grafana Pyroscope, deployed automatically as part of the observability stack.
There are two capture mechanisms in the fleet, and which one applies depends on the workload:
- Cassandra uses runtime-controlled async-profiler, attached to the already-running JVM and driven by the
cassandra profilecommand group. Nothing profiling-related is injected at JVM startup. - Every other JVM workload — stress jobs, Presto/Trino, Spark/EMR, the Cassandra Sidecar — still uses the Pyroscope Java agent, loaded with
-javaagentat startup. - Everything else (ClickHouse, TiDB/TiKV/PD, and all host processes) is covered by the Grafana Alloy eBPF profiler.
Architecture
Profiling data is collected from these sources and sent to the Pyroscope server on the control node (port 4040):
- async-profiler, attached at runtime (Cassandra) — A systemd timer on every Cassandra node runs
edl-profiling-reconcile, which attaches async-profiler to the live Cassandra JVM, writes rotating JFR chunks to/mnt/db1/cassandra/profiles, uploads completed chunks to Pyroscope's/ingest, and prunes the directory. What is being profiled changes without restarting Cassandra. - Pyroscope Java agent (Stress jobs) — Runs as a
-javaagentinside cassandra-easy-stress K8s Jobs. Collects CPU, allocation, and lock profiles. The agent JAR is mounted from the host via a hostPath volume. - Pyroscope Java agent (Presto) — Runs as a
-javaagentinside both the Presto coordinator and worker JVMs, injected viaJAVA_TOOL_OPTIONSduringpresto start. Profiles appear underservice_name=prestowithcomponent=coordinatororcomponent=worker. - Pyroscope Java agent (Spark/EMR) — Runs as a
-javaagenton Spark driver and executor JVMs, installed via EMR bootstrap action to/opt/pyroscope/pyroscope.jar. Profiles appear underservice_name=spark-<job-name>. - Grafana Alloy eBPF profiler — Runs as a DaemonSet on all nodes via Grafana Alloy. Profiles all processes at the system level using eBPF, including kernel stack frames. Pod processes are attributed per pod/container/service_name; see the eBPF Agent section below.
Cassandra profiling at runtime
A cluster is profiling as soon as it comes up — setup-instances seeds each Cassandra node with profiling enabled and -e cpu --alloc 512k --lock 10ms. Nothing needs to be run to get CPU, allocation and lock-contention flame graphs. Only wall-clock sampling is exclusive of a CPU event, so all three of these run in one recording.
Why it is not a startup agent
The Pyroscope Java agent takes exactly one primary profiler.event, fixed for the JVM's lifetime. Changing what was being profiled therefore meant restarting Cassandra, which discards page cache and compaction state — precisely what an operator on a benchmarking rig is trying to observe. Attaching at runtime makes the event set a runtime property instead. It also takes profiling out of the startup path entirely, so a bad profiler configuration can no longer abort Cassandra's start.
The cost, worth knowing: attach-based profiling dies with the JVM, so a node-resident reconciler re-establishes it. After a Cassandra restart or a mode switch, expect up to one reconcile interval (60s by default) with no profiling. That is expected behaviour, not a bug.
Switching modes
There is one async-profiler session per JVM, so one mode at a time. Switch by stopping and starting:
# what is running right now, per node
easy-db-lab cassandra profile status
# switch the whole cluster to wall-clock profiling
easy-db-lab cassandra profile stop
easy-db-lab cassandra profile start -- -e wall -i 10ms
# back to CPU, with allocation sampling alongside it
easy-db-lab cassandra profile stop
easy-db-lab cassandra profile start -- -e cpu --alloc 512k
# only two nodes, rotating every 30 seconds
easy-db-lab cassandra profile start --hosts db0,db1 --loop 30s -- -e cpu
stop finalizes the in-flight JFR chunk before detaching, so a mode switch loses no data — the outgoing chunk still ships, and fetch and flamegraph can reach it once nothing is writing to it. stop also leaves your retention and byte ceiling exactly as start set them; it only turns profiling off. If a node's configuration document cannot be read — truncated by an interrupted write, say — stop has nothing to preserve and falls back to the defaults, and it says so with a Profiling.DesiredStateUnreadable event naming the node and the bounds it is applying, rather than resetting them silently.
Every profiling command applies to all Cassandra nodes by default; --hosts narrows it to a subset.
Do not run -e cpu --wall 10ms (or any equivalent single recording that captures both). The
resulting CPU profile is silently corrupted — see The cpu+wall hazard below.
Run one mode at a time and switch with stop/start.
async-profiler's own parameters pass through
Anything after -- is handed to asprof byte-identically. easy-db-lab does not model, enumerate, or re-parse async-profiler's option surface, so options added by newer async-profiler releases work with no change here.
easy-db-lab cassandra profile start -- -e wall -i 10ms
easy-db-lab cassandra profile start -- -e cpu --lock 10ms --alloc 512k
The nodes run async-profiler 4.5, and you own the flags you pass. Both of these changed silently:
--cstack dwarfno longer yields DWARF unwinding on HotSpot. async-profiler promotes it tovmwhenever VMStructs are available, with no error and no warning. Forcing real DWARF now needsfeatures=agct. Upstream's owndocs/StackWalkingModes.mdis stale on this point.--cstack lbrwas removed. An unrecognised--cstackvalue falls through to "no C stacks", so passinglbrsilently turns off native stack collection rather than failing.
Reserved parameters
Five parameters are rejected, at the CLI, before any node is contacted. Four of them easy-db-lab supplies itself, because the node-side JFR shipper depends on them; the fifth changes which JFR event types reach that shipper at all:
| Concept | Rejected spellings | Why |
|---|---|---|
| Output file | -f, --file, file= | The shipper needs a known path carrying %p/%t |
| Output format | -o, --output, and bare format words (jfr, collapsed, flamegraph, tree, otlp, flat, traces) | The shipper needs JFR |
| Rotation | --loop, loop= | Chunk-completion detection depends on it |
| Auto-stop | -d, --duration, --timeout, timeout= | A self-terminating session would be restarted by the reconciler, thrashing forever |
| Wall-clock batching | --nobatch, nobatch | It makes wall samples arrive as jdk.ExecutionSample, indistinguishable from CPU samples, and Pyroscope merges them into process_cpu — see The cpu+wall hazard |
Rejection covers both of async-profiler's spelling systems (CLI flags and the comma-separated agent-option form) and reserved options smuggled inside another value after a comma, such as -e cpu,file=/tmp/elsewhere.jfr. That last case is the reason the check exists at all: without it, profiling would appear to work while chunks landed somewhere the shipper never looks and Pyroscope stayed silently empty.
The first four remove no capability. Every profiling parameter async-profiler offers remains reachable, and the one knob from that set people actually want — rotation — is a first-class option on the command itself:
easy-db-lab cassandra profile start --loop 30s -- -e cpu
Retention on the node
Shipped chunks are pruned by age and by total size: their data is already in Pyroscope. A chunk that has not shipped, or that Pyroscope rejected, is never deleted — it is data you cannot get back.
When the directory reaches --max-bytes and only unshipped or rejected chunks are left, the node stops recording instead of deleting anything, so an unreachable Pyroscope still cannot fill /mnt/db1 and take the database down. Recording resumes on its own once the directory is back under the bound — when shipping recovers and shipped chunks are pruned, or when you fetch and remove chunks. The stop is reported by cassandra profile status (STOPPED), a typed Profiling.RecordingStoppedAtBound event, the jfr_recording_stopped journal line, and the edl_jfr_recording_stopped_at_bound metric.
easy-db-lab cassandra profile start --retention 30 --max-bytes 1073741824 -- -e cpu
| Option | Default | Meaning |
|---|---|---|
--loop | 15s | JFR rotation interval: a whole number of seconds (30) or a number with a unit (30s, 5m, 1h). Anything else is refused before any node is contacted. async-profiler's hh:mm:ss time-of-day form is deliberately not accepted — it rotates once a day, and because easy-db-lab ships every completed chunk continuously and sizes the completion and upload windows from a fixed interval, a daily rotation yields no usable profiles. The interval also has a floor of 10s: a node ships at most 6 chunks per 60-second pass, so anything faster produces chunks quicker than they can be drained and the queue grows until the directory reaches --max-bytes and recording stops. That floor is refused before any node is contacted too. |
--retention | 60 | Minutes of profile data kept on each node. Must be long enough to hold the rotation: a chunk is not shippable until one --loop interval plus 10s have passed and the node only looks every 60s, so a window shorter than that prunes every chunk before it can ship. That combination is refused before any node is contacted, naming both flags. |
--max-bytes | 2 GiB | Byte ceiling for the profile directory. Shipped chunks are pruned oldest-first to stay under it; when only unshipped or rejected chunks remain at the ceiling, recording stops until the directory is back under it. Must be at least 1 MiB. |
Profiles live in /mnt/db1/cassandra/profiles, deliberately not artifacts/ — that directory is world-writable and holds operator-dropped heap dumps and jstacks, which automatic pruning must never delete.
Fetching chunks and building flame graphs
# download the 5 most recent completed chunks from every node into ./profiles/<host>/
easy-db-lab cassandra profile fetch
easy-db-lab cassandra profile fetch --last 20 --hosts db0
# convert the 10 most recent chunks into a flame graph on the node and download it
easy-db-lab cassandra profile flamegraph --last 10
# split stacks by thread — see below
easy-db-lab cassandra profile flamegraph --last 10 -- --threads
Conversion runs on the node, because jfrconv is installed there and not on your machine, and because the rendered HTML is far smaller than the raw JFR.
jfrconv arguments pass through after -- the same way async-profiler's do. Only its input and output are reserved — positional arguments and -o/--output are rejected, because easy-db-lab supplies the input chunks (--last) and the output destination (--format).
The chunk currently being written is never offered by fetch or flamegraph: async-profiler finalizes constant pools when a chunk closes, so an unfinalized chunk is unreadable by any tool. Everything else on the node is offered, including chunks already shipped to Pyroscope — shipping marks a chunk with a -shipped.jfr infix rather than moving it aside, so --last 20 really does give you twenty chunks. Once you stop profiling, the final chunk of the run becomes available too, since nothing is writing to it any more.
Thread-pool attribution
jfrconv --threads on a fetched chunk is the only way to see Cassandra thread-pool attribution (ReadStage-3, CompactionExecutor:4, MutationStage-7).
Pyroscope's JFR ingest discards thread identity: the JFR carries sampledThread and Grafana's parser decodes it, but the pprof converter never reads it. Passing -t to async-profiler does not help either — in JFR output mode it deliberately puts thread identity in the constant pool rather than into the stack. So the continuous-profiling path structurally cannot show you which pool a stack came from, and the flamegraph path exists alongside it precisely for this:
easy-db-lab cassandra profile flamegraph --last 20 -- --threads
Status
easy-db-lab cassandra profile status
Reports per node: what you asked for (desired) and what is actually attached to the JVM (attached), reported separately, the process being profiled, how long the session has been running, your arguments verbatim, the full command line as actually invoked (including the -o jfr --loop ... -f ... the tool appended), chunks pending/shipped/rejected, bytes held on disk, and the most recent shipping and attach errors.
It also reports pruned: — how many shipped chunks the node reclaimed for age and for size — and on disk: as bytes held against the --max-bytes ceiling.
A node that stopped recording at its byte ceiling is marked STOPPED and reports attached: no (stopped at the size bound). It emits a typed Profiling.RecordingStoppedAtBound event, and no Profiling.AttachFailed: nothing is wrong with the JVM. Fix shipping, raise --max-bytes, or fetch and remove chunks; recording resumes on the next pass once the directory is back under the ceiling.
The report is a snapshot the node's reconciler left behind, not a live reading, so status also prints updated: — how long ago that node last completed a pass. If the reconciler has stopped running, the report is marked STALE above everything else in it and names the unit to check, and status emits a typed Profiling.StateStale event. Without that, a node whose timer is masked keeps reporting attached: yes with a session age that grows convincingly against the current clock while nothing is running.
A node whose configuration document cannot be read is marked CONFIG instead, never STALE, and emits Profiling.NodeConfigUnreadable. The two are opposite diagnoses. A pass that cannot read /etc/easy-db-lab/profiling.json refuses only the attach/detach decision — it keeps shipping and pruning under the bounds the last good pass recorded, and keeps reporting every metric — so the reconciler is running perfectly and the file it reads is what is wrong. Run cassandra profile start to rewrite it. A node with no document at all has simply never been configured; it is not marked CONFIG.
desired and attached are two lines rather than one because they answer different questions. A node showing desired: enabled with attached: no is a node whose profiler will not attach — usually PrivateTmp hiding the attach socket, a java.io.tmpdir mismatch, or perf_event_paranoid — and status emits a typed Profiling.AttachFailed event with the reconciler's captured error, rather than letting it look like a deliberate stop.
A node that has just started is marked WAITING instead, and reports attached: no (waiting for the database to become ready). The reconciler will not attach to a database whose native transport is not yet listening, because async-profiler attaches with jattach, jattach signals SIGQUIT, and a process that has not installed a handler for that signal yet is killed by it — which is how attaching to a starting node used to kill it. This state clears itself on the first pass after the database is up, so it emits a typed Profiling.AttachDeferred event, which is not an error, and no Profiling.AttachFailed. If it does not clear, see edl_jfr_attach_deferred_total below.
Shipping and attach problems are also visible without the CLI. The reconciler writes structured logfmt lines to journald, which Fluent Bit ships to Loki, and exports counters to the node's OTel collector, which forwards them to Mimir:
| Metric | Meaning |
|---|---|
edl_jfr_ship_failures_total | Transient upload failures (5xx or network). Rising means Pyroscope or the network is unwell; those chunks are retried. |
edl_jfr_ship_rejected_total | Chunks Pyroscope refused (4xx). Rising means chunks are being produced it cannot parse, usually truncation after an unclean shutdown. These are never retried, so one bad chunk cannot wedge the queue. |
edl_jfr_profiling_desired / edl_jfr_session_attached | What was asked for and what the JVM actually has, as 1/0. Together they separate the three states that otherwise look identical: 0/0 is profiling deliberately off, 1/1 is profiling running, and 1/0 is a node that wants to profile and is not — the condition to alert on. |
edl_jfr_attach_failures_total | Failed profiler attaches. Rising means asprof refused: usually PrivateTmp hiding the attach socket, a java.io.tmpdir mismatch, or perf_event_paranoid. The journald line carries asprof's own message. |
edl_jfr_attach_skipped_total | Passes that wanted to attach and found no database process — a stopped database, a missing cassandra-pid, or the reconciler running as the wrong user. Counted apart from a refused attach because the two are fixed in completely different places. |
edl_jfr_attach_deferred_total | Passes that wanted to attach, detach or replace a session against a database that was present but not ready to be signalled. Every node restart moves this once or twice and then stops; a rate that never returns to zero means the database is not coming up, or its native transport is on a port the node's cassandra-ready probe was not told about. The journald line carries the reason: reason=database_not_ready for the ordinary case, reason=readiness_probe_missing if the node image is missing cassandra-ready altogether. Counted apart from both neighbours above because all three are fixed in different places. |
edl_jfr_attach_deferred | 1 when the last pass deferred for that reason. Pair it with edl_jfr_profiling_desired == 1 and edl_jfr_session_attached == 0 so a node that has just restarted does not fire that alert. |
edl_jfr_session_starts_total | Profiler attaches that succeeded, counting every restart. A steady rate here means the node is tearing the session down and re-attaching every pass — samples are lost across each detach — rather than profiling continuously. |
edl_jfr_pruned_for_age_total / edl_jfr_pruned_for_size_total | Shipped chunks reclaimed by the retention window and by the byte ceiling. Only shipped chunks are ever pruned. |
edl_jfr_recording_stopped_at_bound | 1 while the node is not recording because the profile directory is at --max-bytes with only unshipped or rejected chunks left. Profiling is paused, not failing; it resumes once the directory is back under the ceiling. |
edl_jfr_chunks_pending | Chunks the shipper could have shipped and did not. Both the chunk currently being written and any chunk still inside its completion window are excluded — neither has had its chance yet — so on a healthy node this sits at 0 and any non-zero value is a real backlog. |
edl_jfr_config_unreadable | 1 when the node's desired-state document is present but could not be read on the last pass. A node that was never given one — every app and control node — reports 0, so any non-zero value is a real finding. The pass still ships, prunes and reports; it will not change what it profiles until the document is rewritten. Pair this with edl_jfr_profiling_desired, which keeps reporting the last known answer rather than dropping out — a series that stops being written silently resolves an alert instead of firing it. |
edl_jfr_ship_truncated | 1 when a pass hit its per-pass upload budget with chunks still queued. A backlog draining normally sets this on a few consecutive passes and then clears it, so the reconciler is not unwell. Watch it together with edl_jfr_chunks_pending: if this stays at 1 while pending keeps climbing, the node is producing faster than it can ship and nothing will ever drain — it will stop recording when the directory reaches its ceiling (edl_jfr_recording_stopped_at_bound). |
edl_jfr_bytes_on_disk | Size of the profile directory. If the byte ceiling ever engages, this is the signal. |
edl_jfr_ship_last_success_timestamp_seconds | When a chunk last shipped successfully. |
Each successful attach also logs event=jfr_session_started ... ready=<code>, naming which readiness answer allowed it. ready=listening is the healthy answer. A node — or a whole cluster — attaching on ready=uptime is profiling ten minutes late every time: the socket check is not finding the native transport, and the ten-minute backstop is doing work it was only meant to do for a transport that is deliberately off.
Every series carries the node's host_name and the cluster label, the same as the rest of the stack.
The cpu+wall hazard
Combining a CPU event and wall-clock sampling in one recording corrupts the CPU sample weights, silently, by up to three orders of magnitude.
The defect is upstream, in Pyroscope's JFR ingest. Pyroscope delegates to github.com/grafana/jfr-parser (pinned at v0.18.0), whose pprof/parser.go:56 declares the sample value buffer once, outside the event loop:
var values = [2]int64{1, 0}
The wall-clock branch assigns values[0] = WallClockSample.Samples and never restores it, and the execution-sample branch reuses the buffer without re-initialising it. After the first wall sample, every subsequent CPU sample carries that wall event's batch count as its weight. Wall sampling coalesces idle threads into batches of up to 1000, and Cassandra runs hundreds of mostly-idle pool threads — hence the magnitude.
Three things follow:
- The corruption is scoped to a single upload. Separate sessions are uploaded separately and are each clean. Switching modes with
stop/startis the supported approach and is not affected. --nobatchis not a mitigation, and easy-db-lab rejects it. It does not merely disable coalescing: it changes the emitted event type, so wall samples arrive asjdk.ExecutionSample— indistinguishable from CPU samples — and Pyroscope merges them all intoprocess_cpu. It causes exactly the failure it appears to prevent. async-profiler itself refused the flag until 4.5 accepted it, so the guard now lives in easy-db-lab.- Getting CPU, wall, and allocation profiles requires more than one session, in sequence. cpu+alloc in one session is fine; wall belongs in its own.
Note also that CPU and wall sampling have very different costs. A CPU sample only fires on a running thread, so its rate is bounded by core count. A wall tick targets threads regardless of run state, so its rate is bounded by thread count — far higher on a Cassandra node. That is why wall is treated as its own mode rather than something left on by default.
Accessing profiles
Profiling dashboard
- Open Grafana (port 3000)
- Navigate to Dashboards and select the Profiling dashboard
- Use the Service dropdown to select a service (e.g.
cassandra,cassandra-easy-stress,clickhouse-server) - Use the Hostname dropdown to filter by specific nodes
- Select a time range
The dashboard includes panels for:
- CPU Flame Graph — CPU time spent in each method
- Wall Clock Flame Graph — Wall-clock time, for finding I/O and blocking
- Memory Allocation Flame Graph — Heap allocation hotspots
- Lock Contention Flame Graph — Time spent waiting for monitors
- Mutex Contention Flame Graph — Mutex delay analysis
One async-profiler session runs per JVM, so only one mode runs at a time. Which panels carry data therefore depends on the session that is running, and the panels for the other mode are empty. That is expected, and it is not a broken dashboard or a broken pipeline.
- The default session, seeded at cluster up as
-e cpu --alloc 512k --lock 10ms, fills CPU, Memory Allocation, Lock Contention and Mutex Contention. Wall Clock is empty. - A wall-clock session,
-e wall -i 100ms, fills Wall Clock. Memory Allocation, Lock Contention and Mutex Contention are empty. CPU keeps data, because Pyroscope routes runnable-thread wall samples intoprocess_cpuas well.
Run cassandra profile status to see what each node is actually running, and switch modes with
stop/start as shown in Switching modes. The dashboard repeats this in a text
panel at the top.
This applies only to Cassandra. Workloads profiled by the Pyroscope Java agent or the eBPF agent are not affected.
Grafana Explore
- Open Grafana (port 3000) and navigate to Explore
- Select the Pyroscope datasource
- Choose a profile type (e.g.
process_cpu,memory,mutex,wall) - Filter by labels:
service_name,hostname,cluster
Cassandra profiles arrive under service_name=cassandra with hostname and cluster labels.
Pyroscope runs native multi-tenancy, and the Grafana Pyroscope datasource sends the cluster's tenant (init --tenant, default default) on every query, so Explore and the dashboards show the cluster's tenant. Every profile writer sends the same tenant in X-Scope-OrgID. A request made directly against Pyroscope's HTTP API (port 4040) must send it too: curl -H 'X-Scope-OrgID: <tenant>' ....
Pyroscope UI
Pyroscope 2's own UI is served on port 4040, and the profiling dashboard links to it as Pyroscope UI. On the first visit the UI finds that the server runs multi-tenancy (its first query is refused with 401 no org id) and shows an Enter a Tenant ID dialog. Enter the cluster's tenant: the value of init --tenant, default if none was given. The UI remembers it per browser (in localStorage, as pyroscope:tenantID) and sends it as X-Scope-OrgID on every query; the navbar can change it. The dashboard link cannot preselect the tenant, because the UI reads no URL parameter for it.
Profile types
Cassandra (runtime async-profiler)
Which types are populated depends on the session that is running, since one session runs at a time.
| Profile | Produced by | Description |
|---|---|---|
process_cpu | -e cpu | CPU time spent in each method |
memory | --alloc <n> | Allocation by method. <n> is a sampling interval in bytes, not a size filter — setting it below a thread's natural TLAB forces extra slow-path refills |
mutex | --lock <t> | Lock contention |
wall | -e wall | Wall-clock time, for finding I/O and blocking. Own session only |
Thread identity is not available through this path; use flamegraph -- --threads (see above).
Java agent (stress jobs, Presto, Spark, sidecar)
| Profile | Description |
|---|---|
cpu | CPU time spent in each method |
alloc | Memory allocation by method |
lock | Lock contention |
eBPF agent (all processes)
| Profile | Description |
|---|---|
process_cpu | CPU usage by process, including kernel frames |
The eBPF agent profiles all processes on every node, including ClickHouse and other kit databases (TiDB, TiKV, PD). Since these are written in C++/Go, only CPU profiles are available.
Processes running inside Kubernetes pods are attributed to their pod: they carry namespace, pod, container, and a service_name derived as <namespace>/<container> (for example tidb-cluster/tikv). Host processes that don't run in a pod are still profiled, just without pod labels.
Stress job profiling
Stress jobs are automatically profiled via the Pyroscope Java agent. No configuration is needed — when you start a stress job, the agent is mounted from the host node and configured to send profiles to the Pyroscope server. Profiles appear under service_name=cassandra-easy-stress with cluster and job_name labels.
Configuration
Cassandra runtime profiling
| Location | Purpose |
|---|---|
/etc/easy-db-lab/profiling.json | Desired state, written atomically by the CLI, read by the reconciler each pass |
/mnt/db1/cassandra/profiles/ | JFR chunks, plus effective-state.json and metrics.prom (the textfile is a convenience when you are already on the node; the counters that reach Grafana are pushed to the node's OTel collector) |
/usr/local/bin/edl-profiling-reconcile | The reconciler |
/usr/local/bin/cassandra-ready | The readiness gate. Exits 0 once the database is safe to signal, and prints a reason code saying which answer it gave: listening (something is listening on port 9042 — the normal path), uptime (nothing is listening but the process has been up ten minutes, which covers a native transport deliberately turned off), starting, or no-process. It reads the socket table with ss, never a connection to an assumed address — the native transport binds to rpc_address, the node's private IP, so a loopback connect answers "closed" on every healthy node. Run it by hand as cassandra-ready $(cassandra-pid). |
edl-profiling-reconcile.timer | systemd timer, 60s interval, runs as the cassandra user |
You should not need to edit these by hand; cassandra profile start/stop own the desired-state document, and stopping is recorded as an explicit disabled state rather than by deleting the file.
Attaching depends on async-profiler's jattach finding the HotSpot attach socket by falling back
to /tmp. Adding PrivateTmp=yes or RootDirectory= to cassandra.service, or setting
-Djava.io.tmpdir, silently breaks attach. There is a comment in the unit file recording this.
eBPF agent
The eBPF profiler runs as a privileged Grafana Alloy DaemonSet (pyroscope-ebpf) and profiles all processes on each node. Configuration is in the pyroscope-ebpf-config ConfigMap (Alloy River format). It uses discovery.kubernetes to discover the pods on each node, discovery.process (joined to those pods by container id) to discover host processes, and pyroscope.ebpf to collect CPU profiles. The DaemonSet runs under the pyroscope-ebpf ServiceAccount, whose ClusterRole grants read access to pods so samples can be attributed to a pod/container/service_name.
Pyroscope server
The Pyroscope server (2.3.1) runs on the control node with pure v2 storage in the account bucket under s3://<account-bucket>/pyroscope/, and native multi-tenancy. Nothing is deleted by age: the metastore's retention cleanup is off and there is no retention period. Its metastore index lives on the control node at /mnt/db1/pyroscope, so profiles stay queryable across a Pyroscope restart. Pyroscope accepts a push only after its segment is in S3, so down needs no flush for profiles and reports that. After down the profiles stay in S3, but v2 cannot find them without the cluster's index (see Where observability data is stored). Configuration is in the pyroscope-config ConfigMap.
Data flow
Cassandra is the one path where nothing is injected into the JVM at startup and nothing pushes from inside the process — the reconciler on each node reads finished chunks off disk and uploads them.
Cassandra JVM
│ (async-profiler attached at runtime, writes JFR chunks)
▼
/mnt/db1/cassandra/profiles/*.jfr
│ (edl-profiling-reconcile.timer, every 60s: ship completed chunks, prune)
├──────────────► Pyroscope Server (:4040) ──┐
│ │
└──(fetch / flamegraph)──► your workspace │
│
Stress Jobs ──(Java agent)────────────────────►│
Presto JVMs ──(Java agent)────────────────────►│
Spark JVMs ──(Java agent)────────────────────►│
All Processes ──(eBPF agent)────────────────────►│
│
▼
S3 storage
Grafana (:3000)
Pyroscope datasource
+ Profiling dashboard
Metrics (Mimir)
Grafana Mimir stores the metrics from every node in the cluster. It runs on the control node and writes its blocks to the account bucket, so a cluster's metrics outlive the cluster. Every cluster's Mimir reads the whole shared store, so each cluster's Grafana shows the metrics of every tenant and of every past cluster.
How metrics get there
The OpenTelemetry Collector runs on every node. It gathers host metrics, scrapes every database, kit and infrastructure exporter, and receives OTLP pushes (the OpenTelemetry Java agent, stress jobs, kits). It sends everything to Mimir with Prometheus remote write, at http://mimir.default.svc.cluster.local:9009/api/v1/push, with the cluster's tenant in the X-Scope-OrgID header. Tempo's metrics generator writes the span metrics (traces_spanmetrics_*) and the service graph to the same place.
Every series carries a cluster label (<name>-<clusterId>), so clusters that share a tenant stay distinguishable.
Configuration
Mimir 3.2.1 runs as one process on the control node:
- Ports: 9009 (HTTP), 9097 (gRPC), 7947 (memberlist, loopback only), 11211 (memcached, loopback only)
- Local data:
/mnt/db1/mimiron the control node (the write-ahead log, the last 15 minutes of blocks, and the store-gateway's index headers) - Object storage:
s3://<account-bucket>/mimir/<tenant>/ - Tenancy: native multi-tenancy; the tenant is the cluster's observability tenant
Mimir cuts a one-minute block and ships it within seconds, so a sample is in S3 about 2 minutes after it is written, while the cluster runs. A head with no writes for 2 minutes is compacted, so the last partial block ships too.
The read path
Queries read the ingester and, through the store-gateway, every tenant's blocks in S3. The store-gateway finds blocks through each tenant's bucket index, which the account compactor rewrites every minute, and it syncs every minute. The ingester keeps local blocks for 15 minutes; older data is read from S3. At 15 minutes a query opens far fewer one-minute local blocks than it did at 2 hours, and the local copies it drops are already in S3. A block the compactor merged away is no longer read 10 minutes after its deletion mark (5 minutes for a running query), so a read opens the merged block instead of its many 1-minute sources.
Mimir queues up to 5000 queries per tenant (query_scheduler.max_outstanding_requests_per_tenant; the default is 100). One load of the heaviest dashboard sends about 150 queries, and the query frontend splits each by day and runs as many as 14 parts at once. Queries are not sharded: on one Mimir process, sharding split each query into about 20 parts and made a dashboard load several times slower. With the default, a full dashboard load filled the queue and Mimir refused the rest with HTTP 429, so panels showed errors. The limit of 5000 holds two full loads of that dashboard at once.
A memcached sidecar in the Mimir pod (2 GB, on the control node's loopback, port 11211) holds Mimir's caches. The store-gateway caches the index, chunks and bucket metadata it reads from S3, and the query frontend caches query results. So a dashboard refresh reads from memory what the last load already read from S3. Mimir also keeps its S3 connections open and reuses them, instead of opening a new TLS connection for each read. The caches only hold copies; a restart empties them, and nothing is lost.
A tenant that has no bucket index yet returns no stored data rather than an error. A stale bucket index is accepted for about 10 years, so metrics queries still succeed while the compactor is stopped; blocks shipped after it stopped become readable once it runs again.
Nothing in the cluster deletes a block. Mimir on a cluster runs no compactor and no retention. The account compactor merges blocks and removes the sources only after it wrote the merged block. Every sample stays in S3 until you delete it yourself.
Querying metrics
Grafana
Grafana's default datasource is Mimir (uid mimir). It sends the cluster's own tenant on every query, so it returns this tenant's data from every cluster in it. Dashboards narrow to one cluster with their cluster variable.
Grafana also has one metrics datasource per tenant in the shared store, Mimir (<tenant>) (uid mimir-<tenant>), and one for all of them, Mimir (all tenants) (uid mimir--all), which sends every tenant joined with |. up and grafana update-config list the tenants under mimir/ in the account bucket and rebuild these datasources, so run grafana update-config to pick up a tenant that appeared after up. A UID longer than 40 characters is shortened to mimir-<prefix>-<8 hex characters>.
Dashboards reach these datasources through their Metrics picker, not a fixed uid. The picker opens on Mimir (the cluster's own tenant); pick another tenant, or all tenants, and every metric panel, variable and link on the dashboard follows. See Monitoring.
HTTP API
Mimir serves the Prometheus API under /prometheus. A query that carries no tenant reads nothing, so always send X-Scope-OrgID:
source env.sh
# Every metric name
with-proxy curl -H 'X-Scope-OrgID: <tenant>' \
"http://control0:9009/prometheus/api/v1/label/__name__/values"
# An instant query
with-proxy curl -H 'X-Scope-OrgID: <tenant>' \
"http://control0:9009/prometheus/api/v1/query?query=up"
easy-db-lab status prints the tenant and the Mimir URL.
Teardown
down flushes Mimir before it removes anything, at the same time as it saves the other signals: it stops the ingester, which cuts and ships every block it holds before it answers, then scales Mimir to 0. It checks nothing else. The metrics are recorded as saved the moment this succeeds, so a later down skips them. If a step fails, down removes nothing and Mimir stays as that step left it: it is never started again, and the report names the step, each backend's state, and down --force. See down.
Troubleshooting
No metrics appearing
-
Check that Mimir is running and ready:
kubectl get pods -l app.kubernetes.io/name=mimir kubectl logs -l app.kubernetes.io/name=mimir -
Check that the collector is sending. It scrapes its own telemetry into Mimir (job
otel-collector); a failing exporter shows up inotelcol_exporter_send_failed_metric_points_total, and in the collector's log:kubectl logs -l app.kubernetes.io/name=otel-collector -
Check that the
cluster-configConfigMap carries the tenant:kubectl get configmap cluster-config -o yaml
An empty result from the HTTP API
The request had no X-Scope-OrgID header, or named another tenant.
Logs (Loki)
Grafana Loki stores the logs from every node in the cluster. It runs on the control node and writes its chunks and index to the account bucket, so a cluster's logs outlive the cluster and can be read from any later cluster in the same tenant.
How logs get there
The OpenTelemetry Collector runs on every node and reads:
| Source | Path | source label |
|---|---|---|
| Cassandra JVM GC log | /mnt/db1/cassandra/logs/gc.log* | cassandra-gc |
| System logs | /var/log/**/*.log, /var/log/messages, /var/log/syslog | system |
Tools run with exec run | /var/log/easydblab/tools/*.log | tool-runner |
| Pod stdout and stderr (including ClickHouse) | /var/log/pods/** | none |
Fluent Bit reads the systemd journal on every node and forwards it to the collector with source="journald". OTLP logs (the OpenTelemetry Java agent, Spark jobs) arrive at the collector directly.
Cassandra's application logs (system.log, debug.log) reach Loki only over OTLP, from the OpenTelemetry Java agent in the Cassandra JVM. They carry service_name="cassandra" and no source label, and are stored once. The collector tails only the JVM GC log from disk, because the JVM writes it directly and the agent never sees it.
The collector sends everything to Loki's OTLP endpoint, http://loki.default.svc.cluster.local:3100/otlp, with the cluster's tenant in the X-Scope-OrgID header.
Labels
Four resource attributes become stream labels. Loki adds service_name, and for pod logs the Kubernetes labels (k8s_namespace_name, k8s_pod_name, k8s_container_name):
| Label | Value |
|---|---|
cluster | <name>-<clusterId>; every stream carries it |
host_name | the node (db0, app0, control0) |
node_role | db, app or control |
source | see the table above |
service_name | the OTel service name; unknown_service when the sender sets none |
Every other attribute (the systemd unit, the log file, the trace and span ids, the severity) is stored as structured metadata. LogQL reads it with a label filter after the selector, such as | systemd_unit="docker.service".
Configuration
Loki 3.7.8 runs as one process (target: all) on the control node:
- Ports: 3100 (HTTP), 9098 (gRPC)
- Local data:
/mnt/db1/lokion the control node (write-ahead log, open chunks and the index not yet shipped) - Object storage:
s3://<account-bucket>/loki/; chunks sit under the tenant, the TSDB index underindex/ - Tenancy: native multi-tenancy; the tenant is the cluster's observability tenant
Loki flushes a chunk to S3 once it spans an hour, so a stream that keeps writing reaches S3 within about an hour. Its index uploads on Loki's own 15-minute rotation, and Loki re-reads the other clusters' index from S3 every minute. A line from another cluster in the tenant becomes readable here once its index file is in S3.
Out-of-order window. Loki rejects a line that is more than 30 minutes older than the newest line of its stream (half of the 1-hour chunk age). The window is wide so that a slow Loki restart does not drop logs. A line replayed or backdated by more than 30 minutes on a stream that is still writing is refused. The annotation mirror is not affected: each annotation is a stream of its own.
Nothing in the cluster deletes a chunk or an index file. The cluster's Loki compactor is idle, retention is off, and the delete API is not served. The account compactor merges each day's index files into one, with retention off, and removes the sources only after the merged file is written. Every log line stays in S3 until you delete it yourself.
Every cluster's Loki reads every tenant's chunks and index from the shared store; nothing about this changes with the account compactor.
Querying logs
The CLI
easy-db-lab logs query reads this cluster's logs:
# The last hour
easy-db-lab logs query
# By source, host or systemd unit (`cassandra` is the application log, `cassandra-gc` the JVM GC log)
easy-db-lab logs query --source cassandra --host db0
easy-db-lab logs query --source cassandra-gc --host db0
easy-db-lab logs query --source journald --unit docker.service
# Lines containing text
easy-db-lab logs query --grep "OutOfMemory"
# Time range and limit
easy-db-lab logs query --since 30m --limit 500
# A raw LogQL query, sent unchanged
easy-db-lab logs query -q '{service_name="cassandra"} |= "Exception"'
| Option | Description | Default |
|---|---|---|
--source, -s | Log source: cassandra (application logs, selects service_name="cassandra"), cassandra-gc (JVM GC log), journald, system, tool-runner, emr | All sources |
--host, -H | Hostname (db0, app0, control0) | All hosts |
--unit | systemd unit | All units |
--since | Time range (1h, 30m, 1d) | 1h |
--limit, -n | Most lines to return | 100 |
--grep, -g | Only lines containing this text | None |
--query, -q | Raw LogQL query | None |
Every option but --query is scoped to this cluster. A raw query is sent as written, so it reads every cluster in the tenant unless it names a cluster.
Grafana
The Loki datasource (uid loki) sends the cluster's own tenant on every query. Grafana also has one logs datasource per tenant in the shared store, Loki (<tenant>) (uid loki-<tenant>), and one for all of them, Loki (all tenants) (uid loki--all). Each links a trace ID to the Tempo datasource of the same tenant view. See the Mimir page for how the tenants are found. Dashboards reach them through their Logs picker, which opens on Loki; the annotation markers follow it too.
The Log Investigation dashboard (Dashboards → Log Investigation) filters by:
| Filter | Description |
|---|---|
| Cluster | One cluster, or all of them in the tenant |
| Service | The service_name label |
| Source | The source label |
| Severity | Log severity |
| Search | Free text |
| Filters | Ad-hoc label = value filters |
For anything else, open Explore, choose the Loki datasource, and write LogQL:
{cluster="<name>-<clusterId>", service_name="cassandra"} |= "Exception"
{cluster=~".+", host_name="db0"} | systemd_unit="cassandra.service"
sum by (source) (count_over_time({cluster="<name>-<clusterId>"}[5m]))
A LogQL selector needs at least one matcher that cannot match an empty value, which is why "all clusters" is written cluster=~".+". See the LogQL documentation.
A trace in Tempo links to its logs through the trace_id.
HTTP API
A query that carries no tenant reads nothing, so always send X-Scope-OrgID:
source env.sh
with-proxy curl -G -H 'X-Scope-OrgID: <tenant>' \
--data-urlencode 'query={service_name="cassandra"}' \
"http://control0:3100/loki/api/v1/query_range"
Annotations
Every Grafana annotation is also written to Loki as its own stream (source="annotation", with the cluster label and an annotation_id), so the annotations stay readable from S3 after the cluster and its Grafana are gone. The core dashboards read them back from Loki. Loki accepts entries only from the last 8760 hours to 24 hours ahead, so an annotation outside that window is not mirrored; the tool warns with its id, and the annotation stays in Grafana's JSON backup.
Teardown
down flushes Loki before it removes anything, at the same time as it saves the other signals: it stops the ingester, which answers only once every open chunk is in S3, then scales Loki to 0 and waits for the pod to go, which builds and uploads its index. It checks nothing else. The logs are recorded as saved the moment this succeeds, so a later down skips them. If a step fails, down removes nothing and Loki stays as that step left it: it is never started again, and the report names the step, each backend's state, and down --force. See down.
Troubleshooting
No logs appearing
-
Check that Loki is running and ready:
kubectl get pods -l app.kubernetes.io/name=loki kubectl logs -l app.kubernetes.io/name=loki -
Check that the collector is sending. A failing exporter shows up in
otelcol_exporter_send_failed_log_records_totalin Mimir, and in the collector's log:kubectl logs -l app.kubernetes.io/name=otel-collector -
Check that the
cluster-configConfigMap carries the tenant:kubectl get configmap cluster-config -o yaml
Connection errors from logs query
logs query reaches Loki through the cluster's SOCKS proxy, which it starts when needed. Check that the cluster is up (easy-db-lab status) and that the control node answers (ssh control0 hostname).
Account Compactor
Every cluster writes its metrics, logs and traces to one shared store in the account bucket. The account compactor keeps that store readable: it merges small objects into larger ones and keeps Mimir's bucket index current, so every cluster's Grafana can read every tenant's metrics.
What it runs
The compactor is one ECS Fargate service per AWS account, easy-db-lab-compactor in the ECS cluster easy-db-lab. It runs in the account bucket's region, in its own small VPC, easy-db-lab-compactor: one public subnet, an internet gateway, and a security group with no ingress. The subnet is in the first availability zone, by name, where Fargate runs; up never places it in a zone without Fargate, such as use1-az3.
The service runs exactly 1 task (ARM64, 2 vCPU, 8 GiB, 100 GiB of disk). ECS never runs a second one: a new task starts only after the old one stopped. The task runs:
| Container | What it does |
|---|---|
mimir-compactor | Mimir's compactor over mimir/. It rewrites every tenant's bucket index every minute. Every 5 minutes it merges the clusters' 1-minute blocks, once they are 2 minutes old, into 10-minute blocks, and those into 2-hour, 12-hour and 24-hour blocks. |
loki-compactor | Loki's compactor over loki/. It compacts every day's index, today's included. |
tempo-backend-scheduler, tempo-backend-worker | Tempo's compaction over tempo/. Tempo 3 runs one target per process, so they are two containers. |
config | Writes the configuration files for the others, then exits. |
Mimir and Loki use the same configuration files as the clusters, with flag overrides, so the compactor always reads the store the way the clusters write it. Profiles are not compacted here: Pyroscope compacts its own segments on each cluster.
Logs go to the CloudWatch Logs group /easy-db-lab/compactor, with no retention policy.
No data is deleted by age
Compaction is on; retention and every other deletion path are off:
- Mimir:
-compactor.blocks-retention-period=0and-compactor.partial-block-deletion-delay=0. - Loki: retention disabled, deletion mode
disabled. - Tempo: block retention
876000h(100 years), no per-trace size limit, and empty tenants are never deleted.
The compactor removes a source object only after it wrote a merged copy that holds its data. Its task role, EasyDBLabCompactorTaskRole, is the only role that may delete under mimir/, loki/ and tempo/, and it has no access to grafana/ or pyroscope/. The account bucket policy denies those deletes to every cluster-side role: the EC2 instance role, the EMR service role and the EMR EC2 role. Deletes you make yourself from your workstation are not affected.
Lifecycle
upstarts the compactor when it is not running: it creates it when it is missing and starts it at 1 task when it is stopped. When it already runs,upleaves it as it is.updoes not wait for the task to run.- A new compactor configuration or version takes effect only when the service starts. To apply it to a running compactor, run
observability compactor stop, thenobservability compactor start. down, after its teardown succeeds, stops the compactor when no other cluster uses the account bucket. It counts the VPCs taggedeasy_cass_lab=1, in every enabled region, whosebuckettag names the bucket. The compactor's own VPC has nobuckettag, so it is never counted.down --allkeeps the compactor's VPC, as it keeps the packer VPC.
Metrics queries keep working while the compactor is stopped. Blocks shipped after it stopped become readable once it runs again.
Commands
easy-db-lab observability compactor start # start it as up does
easy-db-lab observability compactor stop # set its desired count to 0
easy-db-lab observability compactor status # state, why the task stopped, service events, the last 20 log lines
They work outside a cluster workspace. See Observability Commands.
Permissions
The compactor needs the EasyDBLabCompactor user policy: ECS, its CloudWatch Logs group, iam:PassRole on its two roles, and ECS's service-linked role. easy-db-lab show-iam-policies compactor prints it.
Cost
A 2 vCPU, 8 GiB ARM64 Fargate task costs about $0.10 an hour, while any cluster exists.
Kubernetes
easy-db-lab uses K3s to provide a lightweight Kubernetes cluster for deploying supporting services like ClickHouse, monitoring, and stress testing workloads.
Overview
K3s is automatically installed on all nodes during provisioning:
- Control node: Runs the K3s server (Kubernetes control plane)
- Cassandra nodes: Run as K3s agents with label
type=db - Stress nodes: Run as K3s agents with label
type=app
Accessing the Cluster
kubectl
After running source env.sh, kubectl is automatically configured:
source env.sh
kubectl get nodes
kubectl get pods -A
The kubeconfig is downloaded to your working directory. source env.sh puts the workspace's
bin/ first on your PATH, and the kubectl there is a wrapper that reaches the cluster through
the SOCKS5 tunnel, or directly on a Tailscale cluster. See
Tool Wrappers.
k9s
k9s provides a terminal-based UI for Kubernetes:
source env.sh
k9s
k9s uses the workspace kubeconfig and, like kubectl, runs through the workspace wrapper.
Port Forwarding
easy-db-lab uses a SOCKS5 proxy for accessing the private Kubernetes cluster.
Starting the Proxy
Any easy-db-lab command that needs the cluster starts the tunnel on its own. source env.sh
does not start it. To start it for your shell or a browser:
easy-db-lab socks start
Manual Proxy Control
# Start the SOCKS5 tunnel, or reuse the running one, and print its port
easy-db-lab socks start
# Check the recorded port (after source env.sh)
socks5-status
# Stop the tunnel; the cluster keeps running
easy-db-lab socks stop
Running Commands Through the Proxy
kubectl, helm, cilium, curl, skopeo and k9s use the tunnel through the workspace
wrappers. ssh, scp, sftp and rsync have wrappers too, which use the workspace sshConfig
and do not need the tunnel. For other commands:
# Route any command through the proxy
with-proxy curl http://10.0.1.50:8080/api
Pushing Docker Images with Jib
easy-db-lab includes a private Docker registry accessible via HTTPS. You can push custom images using Jib.
Gradle Configuration
Add Jib to your build.gradle.kts:
plugins {
id("com.google.cloud.tools.jib") version "3.4.0"
}
jib {
from {
image = "eclipse-temurin:21-jre"
}
to {
// Use the control node's registry
image = "control0:5000/my-app"
tags = setOf("latest", project.version.toString())
}
container {
mainClass = "com.example.MainKt"
}
}
Pushing to the Registry
# Build and push to the cluster registry
./gradlew jib
# Or build locally first
./gradlew jibDockerBuild
Using Images in Kubernetes
Reference your pushed images in Kubernetes manifests:
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: my-app
image: control0:5000/my-app:latest
Node Labels
Nodes are automatically labeled for workload scheduling:
| Node Type | Labels |
|---|---|
| Cassandra | type=db |
| Stress | type=app |
| Control | (no labels) |
Using Node Selectors
Schedule pods on specific node types:
apiVersion: v1
kind: Pod
metadata:
name: stress-worker
spec:
nodeSelector:
type: app
containers:
- name: worker
image: my-stress-tool:latest
Useful Commands
# List all nodes
kubectl get nodes
# List pods in all namespaces
kubectl get pods -A
# Watch pod status
kubectl get pods -w
# View logs
kubectl logs <pod-name>
# Execute command in pod
kubectl exec -it <pod-name> -- /bin/bash
# Port forward a service locally
kubectl port-forward svc/my-service 8080:80
Architecture
Networking
- K3s server runs on the control node
- All nodes communicate over the private VPC network
- External access is via SOCKS5 proxy through the control node
Storage
- Local path provisioner for persistent volumes
- Data stored on node-local NVMe drives at
/mnt/db1/
Kubeconfig
The kubeconfig file is:
- Downloaded automatically during cluster setup
- Stored as
kubeconfigin your working directory - Backed up to S3 for recovery
Pod Networking (CNI)
The K3s cluster runs one pod-network datapath. You select it at init time with --cni. Cilium is the default. --cni=flannel selects Flannel.
# Default: Cilium in ENI IPAM native-routing mode (no encapsulation)
easy-db-lab init my-cluster --db 3
# K3s's built-in Flannel VXLAN overlay
easy-db-lab init my-cluster --db 3 --cni=flannel
A cluster whose saved state records no CNI was provisioned before Cilium existed and is treated as Flannel.
AMIs built before the Cilium node fixes lack the base image's cloud-init hotplug setting and the systemd-networkd ENI drop-ins that Cilium's secondary ENIs need. If your AMI predates them, rebuild it with easy-db-lab build-image before provisioning a Cilium cluster.
On a Cilium cluster, up checks every node for those fixes before it starts K3s, and stops with an error naming each node that lacks them and the files it is missing:
/etc/systemd/network/05-cilium-eni-primary.network/etc/systemd/network/06-cilium-eni-unmanaged.network/etc/cloud/cloud.cfg.d/90-easydblab-no-network-hotplug.cfg
To recover, rebuild the images with easy-db-lab build-image, then run easy-db-lab down and easy-db-lab up so the nodes launch from the new AMI. Or initialize the cluster with --cni=flannel, which needs none of them. Without the check, such a node joins the cluster normally and drops off the network only once Cilium attaches its second ENI.
Flannel
Flannel is the K3s built-in CNI. Pod traffic between nodes is encapsulated in VXLAN. Nothing in this page beyond the platform cni one-liner applies to a Flannel cluster.
Cilium
With Cilium (the default, or --cni=cilium), Cilium runs in ENI IPAM native-routing mode. Each pod gets a real VPC-routable secondary IP on its node's ENIs, so the VPC routes cross-AZ pod traffic with no tunnel. K3s keeps its own kube-proxy; Cilium's kube-proxy replacement is off.
up is safe to re-run on a Cilium cluster. If Cilium is already installed, the hook upgrades the release in place with the same flags instead of failing on the release name.
Two K3s add-ons are not installed on a Cilium cluster: Traefik and ServiceLB. ServiceLB publishes every node IP as a LoadBalancer ingress, and Cilium's port-0 wildcard for a LoadBalancer IP rejects pod traffic to node IPs, which blocks kubelet probes. A Flannel cluster keeps both.
With Tailscale enabled, up installs a small nftables chain on the control node (table ip edl_tailscale, loaded at boot by edl-tailscale-masquerade.service). The control node is the tailnet subnet router, and Cilium's own NAT chain would otherwise pass tailnet packets to the db nodes without masquerading them, so the db nodes' private IPs would time out from your machine while the control node worked. The chain masquerades Tailscale-forwarded traffic before Cilium sees it. It is installed only when Tailscale is enabled; re-running up replaces it in place.
hostPort works on a Cilium cluster through portmap chaining. With kube-proxy replacement off, Cilium does not implement hostPort itself, so it is installed with cni.chainingMode=portmap: the portmap CNI plugin runs after cilium-cni for every pod and publishes its host ports on the node. The plugin binary is the one K3s bundles; each node links it into /opt/cni/bin when K3s starts. Kits that publish a port through hostPort (Trino, Presto, Flink) are reachable at their node's private IP and scraped, as on Flannel. A pod started before the chaining was applied keeps its old networking until it is restarted.
Cilium's devices are pinned to the ENA interfaces (ens+). The tailnet interface tailscale0 is not a Cilium device, so its 1280 MTU does not lower the NIC MTU below the 9001 AWS offers.
Reading the datapath back
platform cni reads the live configuration and the per-node ENI state off the cluster. It is read-only.
easy-db-lab platform cni
On a Cilium cluster it prints the routing mode, IPAM mode, kube-proxy replacement, masquerade interfaces, native routing CIDR, and the Hubble UI URL. Then it prints one block per node: ENI count, subnet CIDRs, and IPs allocated, used, and available. The values come from the cilium-config ConfigMap and the CiliumNode objects, so they show what the cluster runs, not what init requested.
On a Flannel cluster it prints one line that names Flannel and exits 0.
Hubble UI
Hubble UI is exposed as a NodePort on port 31234. Open it on any node's private IP over the tailnet or the SOCKS tunnel; no port-forward is needed:
http://<control node private IP>:31234
platform cni prints the exact URL.
Metrics
On a Cilium cluster the OTel collector scrapes three Cilium jobs:
| Job | Target | Source |
|---|---|---|
cilium-agent | localhost:9962 on every node | The Cilium agent (hostNetwork DaemonSet) |
hubble | localhost:9965 on every node | Hubble metrics (dns, drop, tcp, flow, port-distribution, icmp, http) |
cilium-operator | Port 9963 on the one node that runs the operator | Found by pod discovery in kube-system on the io.cilium/app=operator label |
The metrics land in Mimir with the cluster label like every other scrape. A Flannel cluster renders none of these jobs.
Logs
The Cilium agent, operator, and Hubble pods run in kube-system. Their stdout and stderr are collected by the same container log pipeline as every other pod, so they are in Loki under the stream label k8s_namespace_name="kube-system". Nothing needs to be enabled.
Install markers on the timeline
up marks the Cilium install on the Grafana timeline with two annotations tagged cilium: Cilium install started and Cilium install finished (or Cilium install failed: <error>). Cilium installs before Grafana exists, so the timestamps are taken when the install runs and the annotations are posted once the observability stack is up. A telemetry-redirect cluster has no local Grafana and posts nothing. See Annotations.
kube-state-metrics
Every cluster, on either CNI, runs kube-state-metrics on the control node. It turns the state of Kubernetes objects (pods, deployments, nodes, PVCs, jobs) into Prometheus metrics such as kube_pod_status_phase and kube_node_status_condition. The OTel collector scrapes it once, through pod discovery on the control node, and the metrics carry the cluster label. See Monitoring.
Network Connectivity
This guide covers how to connect to your easy-db-lab cluster from your local machine.
Overview
easy-db-lab clusters run in a private AWS VPC. By default, up picks a random 10.X.0.0/16 block that no existing VPC in the region uses, and prints the one it chose. If creating the VPC fails, it retries a few times, each time on a new random unused block. To choose the block yourself, pass --cidr; it is used as-is and not retried:
easy-db-lab init --cidr 10.14.0.0/20 ...
The block must be /20 or larger, and it must name its prefix length and be in network form: init rejects 10.0.0.0 (no prefix) and 10.0.0.5/16 (host bits set; the error names 10.0.0.0/16) before it calls AWS.
There are two methods to access your cluster:
| Method | Best For |
|---|---|
| Tailscale VPN (Recommended) | Production use, team sharing, persistent access |
| SOCKS Proxy | Quick testing when you don't want to set up Tailscale |
Both methods, and every command easy-db-lab runs on a node, travel over SSH. If your network blocks or re-routes outbound SSH (port 22), switch the profile's SSH transport to SSM Session Manager. It works underneath either method.
Tailscale VPN (Recommended)
Tailscale provides a persistent VPN connection to your cluster. Once connected, you can access cluster resources directly—no proxy configuration needed.
Why Tailscale?
- Native access - Use any tool (browsers, kubectl, ssh) without proxy configuration
- Persistent - Connection survives terminal sessions
- Team sharing - Share cluster access with teammates
- Reliable - No SSH tunnels to maintain or reconnect
Setup (One-Time)
Step 1: Configure Tailscale ACL
Go to Tailscale ACL Editor and add:
{
"tagOwners": {
"tag:easy-db-lab": ["autogroup:admin"]
},
"autoApprovers": {
"routes": {
"10.0.0.0/8": ["tag:easy-db-lab"]
}
}
}
The autoApprovers section automatically approves subnet routes, so you don't need to manually approve each cluster.
Step 2: Create OAuth Client
- Go to Tailscale OAuth Settings
- Click Generate OAuth Client
- Configure:
- Description: easy-db-lab
- Scopes: Auth Keys write (
auth_keys), to create the control node's key, and Devices › Core write (devices:core), sodownandtailscale stopcan remove the control node's device - Tags: Add
tag:easy-db-lab
- Click Generate and save the Client ID and Client Secret
Step 3: Configure easy-db-lab
easy-db-lab profile setup
Enter your Tailscale OAuth credentials when prompted.
Step 4: Connect this machine
Your own machine must be on the tailnet too. A Tailscale cluster routes every connection over the tailnet and starts no SOCKS proxy, so a logged-out client has no route to the cluster at all.
tailscale up
tailscale status
easy-db-lab up checks this before it creates any AWS resource. If the local client is logged out, or the tailscale command is missing, up stops immediately and names the cause.
Usage
Tailscale starts automatically with easy-db-lab up. Once connected:
# Direct access to private IPs
ssh ubuntu@10.0.1.50
curl http://10.0.1.50:3100/ready
kubectl get pods
# Web UIs work directly in your browser
# http://10.0.1.50:3000 (Grafana)
easy-db-lab down and easy-db-lab tailscale stop remove the cluster's control node from the tailnet, using the device ID recorded when Tailscale started on it (tailscale stop does this even when Tailscale is already down), so a later tailscale start does not leave the old device beside the new one. If the OAuth client is not allowed to delete devices, or no credentials are configured, the command exits non-zero and says so; the device ID stays in the cluster state, so running it again once the scope is granted removes it. tailscale start likewise removes a previously recorded device when the control node registers as a new one; if that removal fails it reports the old device (and the devices:core scope, when the OAuth client lacks it) so you can remove it by hand, records the new one, and still succeeds, so up carries on.
Manual Control
easy-db-lab tailscale start
easy-db-lab tailscale status
easy-db-lab tailscale stop
Cluster commands cannot reach this cluster until 'easy-db-lab tailscale start'.
Troubleshooting Tailscale
"requested tags are invalid or not permitted" - Add the tag to your ACL (Step 1).
Can't reach private IPs - Check subnet route is approved in Tailscale admin, or add autoApprovers to your ACL.
"the local Tailscale client is not connected" - up found your own machine off the tailnet. Run tailscale up, confirm tailscale status reports it connected, then run easy-db-lab up again.
"the 'tailscale' command was not found" - Install Tailscale from tailscale.com/download. If you use the macOS App Store build, its CLI is not on the PATH; see the Tailscale CLI docs.
"cannot reach it ... over the tailnet" - The control node joined the tailnet, but this machine has no route to the cluster's private network. up waits about two minutes for the route to arrive before it reports this. Approve the subnet route for the control node in Tailscale admin.
Using a custom tag:
easy-db-lab tailscale start --tag tag:my-custom-tag
SOCKS Proxy (Alternative)
If you don't want to set up Tailscale, the SOCKS proxy provides connectivity via an SSH tunnel through the control node.
┌─────────────────┐ SSH Tunnel ┌──────────────┐
│ Your Machine │ ──────────────────► │ Control Node │
│ 127.0.0.1:<port>│ │ (control0) │
└────────┬────────┘ └──────┬───────┘
│ │
SOCKS5 Proxy Private VPC
│ │
▼ ▼
kubectl, curl VPC network
Quick Start
source env.sh
kubectl get pods
curl http://control0:3100/ready
The easy-db-lab CLI is the only thing that starts or stops the tunnel. Any command that needs to
reach the cluster starts it, or reuses the one already running, before the command does its work.
The CLI reuses a running tunnel only after a connection through it reaches the control node. If the tunnel's SSH connection died while its local port stayed open, the CLI stops it, starts a new one, and records the new port.
When the CLI replaces a tunnel, it prints the old port, the new port and the reason: the tunnel went to another control node, it no longer reached the control node, its process had exited, or its PID now belongs to another process. If your browser uses the tunnel, change its SOCKS port to the new one. If the old tunnel does not stop, the CLI prints an error with its PID; end it with kill -9 <pid>. The CLI no longer records that tunnel, so it will not stop an ssh the tunnel leaves: the error also gives the pkill command that ends it.
The tunnel reconnects by itself. The CLI runs it through <workspace>/bin/edl-socks-tunnel, which easy-db-lab writes with the tool wrappers. That script runs ssh -N -D 127.0.0.1:<port> and, when ssh exits, starts it again on the same port after a two-second pause. The tunnel listens on 127.0.0.1 only, and every client of it uses 127.0.0.1, never localhost: if another process holds the port on one loopback address, a client of localhost could reach that process instead. The SSH keepalives make ssh exit about 90 seconds after its connection dies, for example after a laptop sleep or a network change. Because the port does not change, wrapped tools in an open shell work again as soon as the connection comes back, with no CLI command. socks stop and down stop the script and its ssh.
The script restarts ssh only for a tunnel the CLI has verified. The CLI records the port in .socks5-proxy.env only after a connection through the tunnel reaches the control node, and the script checks that record. A tunnel that never came up (its port was taken, its key was refused) ends at once, and the CLI reports the failure or retries on a new port. If another process takes the port while ssh waits to restart, the script ends after that one attempt. The wrapped tools then fail with the socks start message, which names <workspace>/logs/socks5-proxy.log, and the next CLI command starts a new tunnel. The log records each ssh start and why the script ended, with a UTC timestamp on every line.
The tunnel is shared by every command in the workspace, so Ctrl-C does not end it. Pressing Ctrl-C during an easy-db-lab command stops that command and leaves the tunnel running. Use easy-db-lab socks stop to stop it.
To start it for everything else (your shell, a browser), run:
easy-db-lab socks start
socks start prints the tunnel's local port. On a Tailscale cluster it starts nothing and says that
no tunnel is needed.
Each new tunnel listens on a random free local port, from 20000 to 29999; no port is preferred. The range is below the ports the OS gives outgoing connections (32768 and up on Linux, 49152 and up on macOS), so no outgoing connection can take the tunnel's port while ssh waits to restart. If 20 random ports in the range are all in use, the CLI fails with a message that names the range. Each workspace records its own port, so several clusters can run side by side, and two workspaces are unlikely to want the same port. If they do, the second one picks another port.
The CLI records the tunnel in the workspace's .socks5-proxy.env, as EDL_SOCKS_PORT (the port) and EDL_SOCKS_PID (the PID of the tunnel script), next to EDL_TAILSCALE_ACTIVE. The CLI is the only writer of this file; the wrappers and env.sh read it. Each write holds a lock on .socks5-proxy.env.lock, so two easy-db-lab commands in the same workspace never lose each other's values.
A wrapper checks only that some process still holds the recorded PID, because a shell script cannot read another process's arguments portably. The CLI checks more: it reuses a recorded tunnel only when the process with that PID is the workspace's tunnel script, with the recorded port and sshConfig. When another program now holds the PID, the next CLI command that needs the tunnel starts a new one.
Tool Wrappers
easy-db-lab writes a small wrapper script for each of these tools into the workspace's bin/
directory, at up and before every kit command:
| Command | Description |
|---|---|
kubectl | Kubernetes CLI |
helm | Kubernetes package manager |
cilium | Cilium CLI |
k9s | Kubernetes TUI |
curl | HTTP client |
skopeo | Container image tool |
ssh, scp, sftp | SSH to the nodes, with the workspace sshConfig |
rsync | File sync to the nodes, over SSH with the workspace sshConfig |
source env.sh puts bin/ first on your PATH, and kit scripts get it first on theirs.
The ssh, scp and sftp wrappers add -F <workspace>/sshConfig before your arguments, and the rsync wrapper adds -ave "ssh -F <workspace>/sshConfig", so ssh db0 reaches the node by its alias. They reach the nodes over SSH itself, not the SOCKS tunnel, so they leave your proxy variables and KUBECONFIG unchanged. If the workspace has no sshConfig file, they name the missing file and exit 1.
Each call of the other wrapped tools reads the workspace's .socks5-proxy.env, which the CLI writes, and then:
- on a SOCKS cluster, routes that one call through the tunnel;
- on a Tailscale cluster, runs the tool unchanged, so it connects directly;
- with no tunnel recorded, or when the recorded tunnel script (
EDL_SOCKS_PID) is no longer running, fails with exit code 1 and a message that says to runeasy-db-lab socks start. It never uses the port of a tunnel that is gone, because another process may hold that port now.
The kubectl, helm, cilium and k9s wrappers always use the workspace kubeconfig. They replace any KUBECONFIG you exported, because a workspace's wrapper reaches only that workspace's cluster. A bare <workspace>/bin/kubectl get ns works with no setup: you do not need to source env.sh or set KUBECONFIG. If the workspace has no kubeconfig file, the wrapper names the missing file and exits 1. The curl and skopeo wrappers do not change KUBECONFIG.
The wrappers replace any proxy variables you set in your shell for that one call, so an exported
NO_PROXY cannot send cluster traffic around the tunnel. They do not change your shell, and tools
without a wrapper, aws included, connect directly. A wrapped tool sends all of its traffic through
the tunnel, public URLs included.
command kubectl in a sourced shell also runs the wrapper, because the wrapper is a real file on
your PATH, not a shell function. To run the real binary directly, call it by its full path, for
example /usr/local/bin/kubectl.
If you sourced an env.sh from an older version in an open shell, source the new one again: it
removes the old kubectl, helm, cilium, curl, skopeo, k9s, ssh, scp, sftp and rsync shell functions.
Manual Proxy Usage
For other commands, use the with-proxy helper from env.sh. On a Tailscale cluster it runs the
command directly:
with-proxy wget http://10.0.1.50:8080/api
with-proxy http http://control0:3000/api/health
Kit commands over SOCKS
Kit lifecycle commands work transparently on SOCKS-only clusters, with no extra flags or setup, whether or not Tailscale is enabled.
kit install, <kit> start / stop and other lifecycle phases. Their shell steps, phase
scripts and hooks run kubectl, helm and similar tools on your machine to apply manifests, wait
on pods, and read pod state. Each of them runs with the workspace bin/ first on PATH and the
absolute workspace kubeconfig in KUBECONFIG, so those tools go through the wrappers above. The CLI
verifies the tunnel, and restarts it on a new port if it died, before the first step runs. Indirect
calls (timeout, xargs, nested scripts) go through the wrappers too. See
How local scripts reach the cluster.
easy-db-lab postgres start
sql. The sql command opens a short-lived in-process loopback bridge that forwards the
JDBC connection through the existing tunnel to the database's private IP, then tears it down when
the query finishes. This works for raw-TCP drivers (PostgreSQL, MySQL) as well as HTTP-based ones
(Trino, ClickHouse):
easy-db-lab postgres sql "SELECT 1"
On Tailscale-enabled clusters the same commands connect directly to the private IP with no proxy,
so behavior is identical either way. In neither path are the JVM-global socksProxyHost /
socksProxyPort properties touched — routing is scoped per client.
Browser Access
Run easy-db-lab socks start and note the port it prints. Then configure your browser's SOCKS5
proxy:
| Setting | Value |
|---|---|
| SOCKS Host | 127.0.0.1 |
| SOCKS Port | the port socks start printed (socks5-status shows it too) |
| SOCKS Version | 5 |
Then access cluster services:
- Grafana:
http://control0:3000 - Mimir:
http://control0:9009/prometheus(sendX-Scope-OrgID: <tenant>) - Loki:
http://control0:3100(sendX-Scope-OrgID: <tenant>)
Proxy Management
easy-db-lab socks start # Start the tunnel, or reuse the running one, and print its port
socks5-status # Show the recorded port (after source env.sh)
easy-db-lab socks stop # Stop the tunnel; the cluster keeps running
easy-db-lab down stops the tunnel too. After socks stop, wrapped tools fail with the
socks start message until a CLI command or socks start starts the tunnel again.
If the tunnel process does not end, socks stop prints its PID and exits non-zero. The PID and port stay recorded. Stop the process with kill <pid>, then run easy-db-lab socks stop again. down goes on with the teardown in that case and prints the same PID.
If the tunnel script was killed from outside (for example with kill -9), its ssh can keep running on its own. socks stop, down and the next tunnel start find that ssh by the recorded port and sshConfig, and stop it.
The Generated sshConfig
The sshConfig generated for your cluster applies its options to the cluster's hosts only. It
starts with one Host db* app* control* !*.* block: a wildcard per node type, so it covers every
node the cluster grows to, and !*.*, which keeps out every name with a dot, such as
db.example.com, and every IP address. OpenSSH host patterns have no character classes, so
db* alone would also match db.example.com. That block sets StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null,
User ubuntu, the cluster's IdentityFile, the keepalives and ConnectTimeout. Each node then has
its own Host block with its address and, under the ssm transport, its ProxyCommand.
Cluster hosts never read your personal ~/.ssh/config or the system /etc/ssh/ssh_config, so no
personal setting (such as ControlMaster, ProxyJump, ForwardAgent or another IdentityFile)
reaches a node or the SOCKS tunnel. Every other host, such as github.com for a git that runs
through the workspace's ssh wrapper, a dotted name like db.example.com, or an IP address, resolves
exactly as it would without -F. The file ends with two Match originalhost blocks that include
your personal config and then the system config for those names only. To see what applies to a
host, run ssh -F <workspace>/sshConfig -G <host>.
Because the patterns are wildcards, a name of your own with no dot that starts with db, app or
control, such as a personal alias dbprod, counts as a cluster host through the workspace
wrappers: it gets the cluster's SSH settings, and not your own settings for it. Call such a host by
its dotted name, or with the real ssh (for example /usr/bin/ssh dbprod).
Host Key Verification
For the cluster's nodes, the generated sshConfig sets UserKnownHostsFile=/dev/null alongside
StrictHostKeyChecking=no. ssh — and therefore the SOCKS tunnel, whose edl-socks-tunnel
script runs ssh -N -D against that config — never reads or writes your ~/.ssh/known_hosts for
cluster nodes.
This matters because AWS recycles public IPs across ephemeral cluster lifetimes. Without this
setting, a recycled IP that previously belonged to a different cluster (with a different host
key) would make ssh hard-fail with REMOTE HOST IDENTIFICATION HAS CHANGED — StrictHostKeyChecking=no
only auto-adds unknown hosts, it doesn't override a changed key for a host already recorded.
Every cluster is short-lived and gets fresh host keys on every provision, so there is nothing to
verify against across runs.
If you connected to easy-db-lab clusters before this change, their host keys may still be in
your ~/.ssh/known_hosts. They're no longer read by the tool, so you can prune them any time —
look for entries matching your cluster's Hostname lines in the generated sshConfig.
Tunnel Failures
If the SOCKS tunnel can't be established, the command that needed it fails immediately with a
non-zero exit code rather than silently continuing against a proxy port nothing is listening on.
The error names the SOCKS proxy as the failing component and points at socks5-proxy.log in
your cluster workspace directory — that file holds the ssh -v transcript from the tunnel
attempt and is the fastest way to find the real cause (a host-key mismatch, a security group
blocking port 22, the control node not yet accepting SSH, and so on). The error quotes only the
lines of the failed attempt.
If ssh is still trying to connect when the CLI stops waiting, the error says so: it names the control node, the address and port that ssh was dialing (on the ssm transport, that it was connecting through its ProxyCommand), and the wait. On the direct transport, when the cluster was not created with init --open, the most likely cause is a changed public IP. The cluster's security group allows SSH only from the IPs it lists, so after your public IP changes (a new network, a VPN, a new DHCP lease), ssh waits until its connect timeout and then fails.
To fix it, run this from the cluster's workspace:
easy-db-lab ingress allow
It adds an SSH rule for this machine's current public IP; give --cidr to add another address or range. It needs only AWS API access, not SSH. When you no longer need the rule for the old IP, remove it with easy-db-lab ingress revoke --cidr <old-ip>. See ingress allow and ingress revoke.
The log keeps the history of every tunnel in the workspace; nothing truncates it. Each launch adds
a header line with a UTC timestamp, the port and the control node, and the tunnel script adds a
timestamped line before each ssh run and each time ssh exits. Look for the last header to find
the current tunnel.
easy-db-lab status is the one exception: it still reports everything it can reach over SSH and
the AWS SDK even when the tunnel is down, marking only the section that requires the private
Kubernetes API (stress jobs) as unavailable. See the
status command reference for details.
Troubleshooting SOCKS Proxy
"no SOCKS tunnel is recorded for the workspace" or "the recorded SOCKS tunnel (PID ...) is not running":
easy-db-lab socks start # Start the tunnel and record its port
"Connection refused" errors:
The tunnel's ssh cannot reach the control node right now. The tunnel restarts ssh by itself;
if that does not help, start a new tunnel:
socks5-status # Shows the recorded port and whether anything listens on it
easy-db-lab socks start # Starts a new tunnel and records its port
ssh control0 hostname # Verify SSH works
Proxy not working after network change:
easy-db-lab socks stop
easy-db-lab socks start
Commands timing out:
- Check cluster status:
easy-db-lab status - Verify SSH works:
ssh control0 hostname - Restart the tunnel:
easy-db-lab socks stop && easy-db-lab socks start
easy-db-lab command fails with a SOCKS proxy error:
As of this change, easy-db-lab commands that need the tunnel (up, kit commands, Grafana
config updates, etc.) abort immediately if the tunnel can't be established, instead of silently
running against a dead proxy port. Check socks5-proxy.log in your cluster workspace directory
for the ssh -v transcript — it shows the actual reason the tunnel failed. See
Host Key Verification above for the most common cause on a
newly-provisioned cluster.
SSH over SSM Session Manager
Everything easy-db-lab does on a node travels over SSH: provisioning, the SOCKS proxy, ssh db0,
and the c0 aliases. By default SSH connects straight to each node's public IP on port 22. Some
networks don't allow that, typically a corporate egress proxy that only routes outbound traffic to
destinations registered with it in advance. A freshly provisioned instance can never be
pre-registered, so on those networks up times out waiting for SSH even though the instances are
healthy.
The ssm SSH transport tunnels every SSH connection through
AWS Systems Manager Session Manager.
The SSM agent on each node connects out to AWS over HTTPS, so your machine never needs to reach a
node on port 22. Nothing else about SSH changes: the keys, the generated sshConfig, and every
command stay the same.
Requirements
-
On your machine: the AWS CLI v2 and the Session Manager plugin.
brew install awscli brew install --cask session-manager-pluginFor other platforms, see the AWS CLI and Session Manager plugin install guides.
upchecks for both before it creates any AWS resource. -
IAM: your user or role must be allowed to open sessions. The EC2 policy printed by
easy-db-lab show-iam-policies ec2allowsssm:StartSessionwith the SSH and port-forwarding session documents, only to instances taggedeasy_cass_lab=1(every cluster node and AMI build instance is), and allowsssm:TerminateSessionandssm:ResumeSessionon your own sessions only (matched on the session'saws:ssmmessages:session-idtag, which works for IAM users and SSO or assumed roles alike), plusssmmessages:OpenDataChannel. -
On the cluster: nothing. Every node's instance role carries a small inline Session Manager policy (
SessionManagerInstance), which lets the SSM agent register and carry sessions and grants nothing else.up, and the IAM check that AMI builds run first, add it to roles created by older versions. The node image already runs the SSM agent.
The SSM sessions authenticate as your profile does: through its named AWS profile (including SSO)
when one is set, otherwise with its static keys. With static keys the AWS CLI is told to ignore
your ~/.aws/config, so a [default] SSO or role setting there cannot replace them.
Enabling it
Run profile setup and answer ssm at the SSH transport prompt:
SSH transport (direct, ssm)? [direct] ssm
easy-db-lab profile show reports the current transport.
Choose the transport before you run up. up writes the cluster's sshConfig, which the SOCKS
proxy and the env.sh helpers use, with the transport that was selected at the time.
AMI builds
AMI builds (profile setup, build-image, build-base, build-cassandra) follow the same
setting. Under ssm, Packer reaches its temporary build instance through Session Manager instead of
its public IP.
Packer runs in a container, and the stock Packer image has no Session Manager plugin. The first
ssm AMI build therefore builds a derived image, localhost/easy-db-lab/packer-ssm, which adds the
plugin. Both the Packer base image and the plugin are pinned to fixed versions, and the plugin
download is checked against a known SHA-256. Later builds reuse the image. Building it needs your container engine to reach the Alpine package
mirror and AWS's plugin download at s3.amazonaws.com. AMI builds need nothing extra installed on
your machine itself.
Troubleshooting SSM
ssh db0 fails with "Connection timed out during banner exchange": the Session Manager
session opened but carried no data for 30 seconds (ConnectTimeout in the generated sshConfig).
Run the command again; a new session usually works.
Each ProxyCommand in sshConfig runs through edl-ssm-proxy, a small wrapper in your profile
directory. It ends the aws and session-manager-plugin processes when ssh exits or is killed, so
none are left behind.
up stops with "these tools cannot run on this machine": each tool it lists says why.
"not found on PATH" comes with the install command; install it, then run easy-db-lab up again. A
tool that "exited" is installed but broken, and the line shows its own error output. A tool that
"did not finish" hung on --version.
TargetNotConnected while up waits for SSH: a freshly booted node's SSM agent takes a short
while to register with AWS. up keeps retrying, exactly as it does while sshd starts (a refused
connection under the direct transport), and each
"SSH still not up yet" line shows the error from the last attempt. If it never connects, check
that the node can reach the internet over HTTPS.
AccessDeniedException mentioning ssm:StartSession: your IAM identity is missing the SSM
permissions. Compare it against easy-db-lab show-iam-policies ec2.
Checking SSM outside easy-db-lab: take an instance ID from easy-db-lab status and open a
port-forwarding session to it. The policy allows only the SSH and port-forwarding documents, so a
plain shell session (no --document-name) is denied by design.
aws ssm start-session --target <instance-id> \
--document-name AWS-StartPortForwardingSession \
--parameters portNumber=22,localPortNumber=2222
It should print Waiting for connections....
If that also fails from your network, the proxy is blocking Session Manager itself (its data
channel is a WebSocket to ssmmessages.<region>.amazonaws.com on port 443). Ask your network
administrators to allow that endpoint.
Comparison
| Feature | Tailscale | SOCKS Proxy |
|---|---|---|
| Setup time | ~10 min (one-time) | Instant |
| Persistence | Persistent | Per-session |
Requires source env.sh | No | Yes |
| Browser access | Direct | Requires proxy config |
| Team sharing | Yes | No |
| External dependency | Tailscale account | None |
Shell Aliases
After running source env.sh, you get access to several helpful aliases and functions for managing your cluster.
SSH Aliases
SSH aliases for all Cassandra nodes are automatically created as c0-cN. The ssh command is not required. For example:
c0 nodetool status
This runs nodetool status on the first Cassandra node.
Cluster Management Functions
| Command | Description |
|---|---|
c-all | Executes a command on every node in the cluster sequentially |
c-start | Starts Cassandra on all nodes |
c-restart | Restarts Cassandra on all nodes (not a graceful operation) |
c-status | Executes nodetool status on db0 |
c-tpstats | Executes nodetool tpstats on all nodes |
c-collect-artifacts | Collects metrics, nodetool output, and system information |
Examples
Run a command on all nodes
c-all "df -h"
Check cluster status
c-status
Collect artifacts for performance testing
c-collect-artifacts my-test-run
This is useful when doing performance testing to capture the state of the system at a given moment.
Graceful Rolling Restarts
For true rolling restarts, we recommend using cstar instead of c-restart.
Server
easy-db-lab includes a server mode that provides AI assistant integration via MCP (Model Context Protocol), REST status endpoints, and live metrics streaming. This enables Claude to directly interact with your clusters, and provides programmatic access to cluster status.
The server exposes tools for all supported databases — Cassandra, ClickHouse, OpenSearch, and Spark — as well as cluster lifecycle management and observability.
Starting the Server
To start the server, run:
easy-db-lab server
By default, the server picks an available port. To specify a port:
easy-db-lab server --port 8888
The server automatically generates a .mcp.json configuration file in the current directory with the connection details.
Adding to Claude Code
Once the server is running, start Claude Code from the same directory:
claude
Claude Code automatically detects and uses the .mcp.json file generated by the server.
Available Tools
The server exposes commands annotated with @McpCommand as MCP tools to Claude. Tool names use underscores and are derived from the command's package namespace.
Cluster Lifecycle
| Tool Name | Description |
|---|---|
init | Initialize a directory for easy-db-lab |
up | Provision AWS infrastructure |
cassandra_down | Shut down AWS infrastructure |
clean | Clean up generated files |
status | Display full environment status |
hosts | List all hosts in the cluster |
ip | Get IP address for a host by alias |
Cassandra Management
| Tool Name | Description |
|---|---|
cassandra_use | Select a Cassandra version |
cassandra_list | List available Cassandra versions |
cassandra_start | Start Cassandra on all nodes |
cassandra_restart | Restart Cassandra on all nodes |
cassandra_update_config | Apply configuration patch to nodes |
Cassandra Stress Testing
| Tool Name | Description |
|---|---|
cassandra_stress_start | Start a stress job on K8s |
cassandra_stress_stop | Stop and delete stress jobs |
cassandra_stress_status | Check status of stress jobs |
cassandra_stress_logs | View logs from stress jobs |
cassandra_stress_list | List available workloads |
cassandra_stress_fields | List available field generators |
cassandra_stress_info | Show workload information |
ClickHouse
| Tool Name | Description |
|---|---|
clickhouse_start | Deploy ClickHouse cluster to K8s |
clickhouse_stop | Remove ClickHouse cluster |
clickhouse_status | Check ClickHouse cluster status |
OpenSearch
| Tool Name | Description |
|---|---|
opensearch_start | Create AWS OpenSearch domain |
opensearch_stop | Delete OpenSearch domain |
opensearch_status | Check OpenSearch domain status |
Spark
| Tool Name | Description |
|---|---|
spark_submit | Submit Spark job to EMR cluster |
spark_status | Check status of a Spark job |
spark_jobs | List recent Spark jobs |
spark_logs | Download EMR logs from S3 |
Kubernetes
| Tool Name | Description |
|---|---|
k8_apply | Apply observability stack to K8s |
Utilities
| Tool Name | Description |
|---|---|
prune_amis | Prune older private AMIs |
Tool Naming Convention
MCP tool names are derived from the command's package location:
- Top-level commands:
status,hosts,ip,clean,init,up - Cassandra commands:
cassandra_prefix (e.g.,cassandra_start,cassandra_use) - Nested commands:
cassandra_stress_prefix (e.g.,cassandra_stress_start) - Hyphens become underscores:
update-config→cassandra_update_config
Benefits of Server Integration
| Benefit | Description |
|---|---|
| Direct Control | Claude executes easy-db-lab commands directly without manual intervention |
| Context Awareness | Claude maintains context about your cluster state and configuration |
| Automation | Complex multi-step operations can be automated through Claude |
| Intelligent Assistance | Claude can analyze logs, metrics, and provide optimization recommendations |
Example Workflow
-
Start the server in one terminal:
easy-db-lab server -
In another terminal, start Claude Code from the same directory:
claudeClaude Code automatically detects the
.mcp.jsonfile generated by the server. -
Ask Claude to help manage your cluster:
- "Initialize a new 5-node cluster with i4i.xlarge instances"
- "Check the status of all nodes"
- "Select Cassandra version 5.0 and start it"
- "Start a KeyValue stress test for 1 hour"
- "Deploy ClickHouse and check its status"
- "Create an OpenSearch domain and monitor its progress"
- "Submit a Spark job to the EMR cluster"
Live Metrics Streaming
When Redis is configured via the EASY_DB_LAB_REDIS_URL environment variable, the server publishes live cluster metrics to the Redis pub/sub channel every 5 seconds. Metrics are queried from Mimir using the same PromQL expressions as the Grafana dashboards.
Enabling
export EASY_DB_LAB_REDIS_URL=redis://localhost:6379/easydblab-events
easy-db-lab server
Metrics events are published to the same channel as command events. Consumers filter by the event.type field.
Event Types
Only metrics for running services are published. If the cluster is running ClickHouse instead of Cassandra, no Cassandra metrics events are emitted.
Metrics.System
Published every 5 seconds with per-node CPU, memory, disk I/O, and filesystem metrics:
{
"timestamp": "2026-03-08T14:22:05.123Z",
"commandName": "server",
"event": {
"type": "Metrics.System",
"nodes": {
"db-0": {
"cpuUsagePct": 34.2,
"memoryUsedBytes": 17179869184,
"diskReadBytesPerSec": 52428800.0,
"diskWriteBytesPerSec": 104857600.0,
"filesystemUsedPct": 45.2
},
"db-1": {
"cpuUsagePct": 28.7,
"memoryUsedBytes": 16106127360,
"diskReadBytesPerSec": 41943040.0,
"diskWriteBytesPerSec": 83886080.0,
"filesystemUsedPct": 42.8
}
}
}
}
Metrics.Cassandra
Published every 5 seconds when the cluster is running Cassandra:
{
"timestamp": "2026-03-08T14:22:05.187Z",
"commandName": "server",
"event": {
"type": "Metrics.Cassandra",
"readP99Ms": 1.247,
"writeP99Ms": 0.832,
"readOpsPerSec": 15234.5,
"writeOpsPerSec": 12087.3,
"compactionPending": 3,
"compactionCompletedPerSec": 1.5,
"compactionBytesWrittenPerSec": 52428800.0
}
}
Field Reference
System — per node:
| Field | Type | Description |
|---|---|---|
cpuUsagePct | double | CPU usage percentage (0-100) |
memoryUsedBytes | long | Memory used in bytes |
diskReadBytesPerSec | double | Disk read throughput (bytes/sec) |
diskWriteBytesPerSec | double | Disk write throughput (bytes/sec) |
filesystemUsedPct | double | Filesystem usage percentage (0-100) |
Cassandra — cluster-wide:
| Field | Type | Description |
|---|---|---|
readP99Ms | double | Read latency p99 in milliseconds |
writeP99Ms | double | Write latency p99 in milliseconds |
readOpsPerSec | double | Read operations per second |
writeOpsPerSec | double | Write operations per second |
compactionPending | long | Number of pending compactions |
compactionCompletedPerSec | double | Compactions completed per second |
compactionBytesWrittenPerSec | double | Compaction write throughput (bytes/sec) |
Auto-Shutdown on Infrastructure Removal
When running the server in unattended or automated scenarios, you can enable automatic shutdown if the cluster's AWS infrastructure is torn down:
easy-db-lab server --auto-shutdown
When --auto-shutdown is set, the server checks whether the cluster VPC still exists on each status refresh cycle (controlled by --refresh). If the VPC is no longer found, the server emits a shutdown event and exits cleanly with code 0.
This is useful when:
- Running the server alongside an automated test workflow that tears down infrastructure when done
- Leaving the server running overnight and wanting it to stop automatically after
easy-db-lab down
Note: The check is skipped if no cluster state exists or the VPC name cannot be determined. AWS API errors during the check are logged and ignored — only a confirmed "VPC not found" result triggers shutdown.
Notes
- The server requires Docker to be installed
- Your AWS profile must be configured (
easy-db-lab profile setup) - The server runs in the foreground and logs to stdout
- Use Ctrl+C to stop the server
Command Reference
Complete reference for all easy-db-lab commands.
Global Options
| Option | Description |
|---|---|
--help, -h | Shows help information |
--vpc-id | Reconstruct state from existing VPC (requires ClusterId tag) |
--force | Force state reconstruction even if state.json exists |
Profile Commands
Profile inspection and setup. Not to be confused with cassandra profile, which controls runtime
profiling of a running cluster.
profile
Parent command for the profile group. Run without a subcommand to list what is available.
easy-db-lab profile
profile show
Report the active profile: its name, its directory, and the settings it holds.
easy-db-lab profile show
Reads only the profile directory, so it runs from anywhere — no cluster workspace needed. Secret
values are never printed: AxonOps and Tailscale are reported as ENABLED or DISABLED, and the
AWS access key and secret are not shown at all.
The SSH transport (direct or ssm) is shown as-is.
The profile reported is the one named by EASY_DB_LAB_PROFILE, or default when that is unset:
EASY_DB_LAB_PROFILE=staging easy-db-lab profile show
If the profile has no settings.yaml, the command says so and points at easy-db-lab profile setup. If the file is present but unreadable, it reports that instead, naming the file.
profile setup
Set up user profile interactively.
easy-db-lab profile setup
Guides you through:
- Email and AWS credentials collection
- AWS credential validation
- SSH transport selection (
directorssm; see SSH over SSM Session Manager) - Key pair generation
- IAM role creation
- Packer VPC infrastructure setup
- AMI validation/building
show-iam-policies
Display IAM policies with your account ID populated.
easy-db-lab show-iam-policies [policy-name]
Aliases: sip
| Argument | Description |
|---|---|
policy-name | Optional filter: ec2, iam, emr, opensearch or compactor |
build-image
Build both base and Cassandra AMI images.
easy-db-lab build-image [options]
| Option | Description | Default |
|---|---|---|
--arch | CPU architecture (AMD64, ARM64) | AMD64 |
--region | AWS region | (from profile) |
Cluster Lifecycle Commands
init
Initialize a directory for easy-db-lab.
easy-db-lab init [cluster-name] [options]
Use a new, empty directory as the workspace, for example under clusters/. init fails, and
writes nothing, in a directory that already has a bin/ file or directory: easy-db-lab writes its
tool wrappers into the workspace's bin/. This also keeps a source checkout from being used as a
workspace. With --clean, the check runs after the cleanup, which removes a bin/ that holds only
easy-db-lab's wrappers.
The database and application node groups are configured through a namespaced
--db.* / --app.* scheme. Every pre-existing flag continues to work as an
alias carrying its established default. When both a namespaced option and its
legacy alias are supplied for the same setting, the namespaced option always
wins, regardless of the order they appear on the command line.
Architecture is no longer a flag. Each node group's CPU architecture is derived
automatically from that group's resolved instance type at init time (via the
EC2 DescribeInstanceTypes SupportedArchitectures field) and persisted per
group in cluster state. A cluster whose database and application groups have
different architectures is provisioned correctly, each group booting from the
AMI for its own architecture. An instance type whose architecture cannot be
determined fails at init, before any instance is created.
| Option | Description | Default |
|---|---|---|
--db.count (alias --db, --cassandra, -c) | Number of database instances | 3 |
--app.count (alias --app, --stress, -s) | Number of application instances | 0 |
--db.instance-type (alias --instance, -i) | Database instance type | i4i.xlarge |
--app.instance-type (alias --stress-instance, -si) | Application instance type | c6id.2xlarge |
--azs, -z | Availability zones (e.g., a,b,c) | all |
--ebs.type | EBS volume type (NONE, gp2, gp3, io1, io2) | NONE |
--ebs.size | EBS volume size in GB | 256 |
--ebs.iops | EBS IOPS (gp3 only) | 0 |
--ebs.throughput | EBS throughput (gp3 only) | 0 |
--ebs.optimized | Enable EBS optimization | false |
--until | When instances can be deleted | tomorrow |
--ami | Override AMI ID | (auto-detected) |
--open | Unrestricted SSH access | false |
--tag | Custom tags (key=value, repeatable) | - |
--vpc | Use existing VPC ID | - |
--cidr | VPC CIDR block, for example 10.14.0.0/16. It must be /20 or larger, name its prefix length, and be in network form: 10.0.0.0 (no prefix) and 10.0.0.5/16 (host bits set) are rejected before any AWS call. See Network Connectivity | (a random unused 10.X.0.0/16) |
--cni | Pod-network CNI: flannel (K3s built-in overlay) or cilium (ENI native routing). See Pod Networking (CNI) | cilium |
--tenant | Observability tenant the cluster's traces, profiles, metrics, logs and annotations belong to. Must match ^[a-z][a-z0-9_-]{0,62}$; checked before anything is created. Fixed for the life of the cluster. See Where observability data is stored | default |
--up | Auto-provision after init | false |
--clean | Remove existing config first | false |
up
Provision AWS infrastructure.
easy-db-lab up [options]
| Option | Description |
|---|---|
--no-setup, -n | Skip K3s setup and AxonOps configuration |
Creates: VPC, EC2 instances, K3s cluster. Configures the account S3 bucket for this cluster and re-applies its bucket policy. Starts the account compactor when it is not running, and leaves it as it is when it runs; up does not wait for its task.
up fails fast. If any provisioning step fails — EC2 setup, K3s, node labeling, the
local-storage/local-storage-wfc StorageClasses, the observability stack, Tailscale, and so
on — the command exits non-zero and stops rather than continuing with a partially-provisioned
cluster. EC2 instances that were already launched are left running; there is no automatic
rollback. Reclaim them with easy-db-lab down, fix the underlying issue, and re-run up.
down
Shut down AWS infrastructure.
easy-db-lab down [vpc-id] [options]
| Argument | Description |
|---|---|
vpc-id | Optional: specific VPC to tear down |
| Option | Description |
|---|---|
--all | Tear down all VPCs tagged with easy_cass_lab; the account compactor's VPC is kept |
--packer | Tear down the packer infrastructure VPC |
--force | Skip the pre-teardown save and tear down anyway; the signals not yet in S3 are listed before the confirmation prompt |
Save before teardown. When you tear down the current cluster, down first previews the resources and asks for confirmation. The save stops Loki, Mimir and the OTel collector, so it runs only once the teardown is going ahead: a declined prompt, --dry-run, or a VPC with nothing left in it stops nothing. Then down saves everything the cluster holds that is not yet in S3, before it removes any infrastructure. The save has two phases.
Phase A runs in order:
- Every Grafana annotation is mirrored to Loki. Loki accepts only entries from the last 8760 hours to 24 hours ahead; an annotation outside that window is skipped with a warning naming its id, and it stays in the annotations backup.
- The OTel collector is stopped: its DaemonSet is deleted and
downwaits until its pods are gone. The collector is the only sender to Tempo, and its shutdown sends its last batches to Loki, Mimir and Tempo while they still accept writes. If the collector is already gone, this step succeeds.
Phase B runs every step at the same time, so the save takes about as long as its slowest step:
- Logs. Loki's ingester is stopped with a synchronous flush, which answers only once every open chunk is in S3. Then Loki is scaled to 0 and
downwaits for its pod to go, which builds and uploads its index. If the mirror failed, the Loki flush does not run and Loki keeps running. - Metrics. Mimir's ingester is stopped, which cuts and ships every block it holds before it answers, and Mimir is scaled to 0.
- Traces.
downreports that the Tempo drain has started, with its timeout. It waits until Tempo holds no live trace and its count of created traces stops changing, then until every block on the control node's disk carries itsflushedmarker, which Tempo writes only once the block is in S3. Tempo is never stopped or restarted. This step times out after 5 minutes. - Profiles. Nothing to do: Pyroscope writes each batch to S3 before it accepts it.
downreports this. - Annotations. The Grafana annotations are backed up to
grafana/annotations/<tenant>/in the account bucket.
Every step has a timeout. down only flushes, waits for the flush to finish, and refuses to tear down if it did not finish; it runs no other check. A redirect cluster has no local backends, so it skips both phases.
Stop on failure. Every Phase B step runs to completion, and a failed step never stops another. If any step failed or timed out, down then stops. It removes no infrastructure, because tearing down would destroy the data that is not yet in S3. It does not start Loki, Mimir or the collector again, retry a step, or undo anything: each backend stays as its step left it, and its write-ahead log and local blocks stay on the control node's disk. The report names every failed signal with its step and cause, the state of each workload (running, ingester stopped, scaled to 0, or deleted), and down --force. down exits with a non-zero status.
Logs and metrics are recorded as they finish. The Loki and Mimir flushes stop their backends and cannot run twice, so each is recorded in the cluster state the moment it succeeds, with the time it finished. A down that fails or is interrupted keeps these records. Run down again and it reports them as already saved, skips them, and runs the collector stop, the Tempo drain, the profiles report and the annotations backup again. up clears the record, so the next down saves the new data.
A teardown that fails after the save restores nothing. If removing the infrastructure fails, down reports the failure: Loki and Mimir stay at 0 and the collector stays deleted. A save of every signal is recorded in the cluster state before any infrastructure is removed. Run down again to finish: it reports every signal as already saved, skips the whole save without connecting to the control node, and goes straight to the teardown.
Nothing is deleted. down sets no S3 lifecycle, expiry or retention rule on any bucket and deletes no object that holds your data. With --all, a per-cluster data bucket is deleted only when it is already empty. A data bucket that still holds objects is left as it is, and down reports it as kept, with S3's reason.
--force skips the save. Pass --force to skip both phases and tear down anyway. With the teardown preview, before the confirmation prompt, down --force lists the signals it will not save: logs and metrics unless an earlier down saved them, then traces and annotations. After a save of every signal, it lists none. Profiles are never listed. Use it only when the backends are already stopped or gone, or when you do not need the data.
The account compactor. After the infrastructure teardown succeeds, down counts the VPCs tagged easy_cass_lab=1, in every enabled region, whose bucket tag names the account bucket, leaving out the ones it just removed. When none is left, it stops the account compactor; otherwise the compactor keeps running.
Exit status. down exits 0 only when the teardown succeeds. It exits non-zero when the save stops it, when the teardown completes with errors, and when you decline the confirmation prompt.
clean
Clean up generated files from the current directory.
easy-db-lab clean
In bin/ it deletes only the tool wrappers and their marker, and removes bin/ only when nothing
else is left in it.
socks start
Start the SOCKS5 tunnel to the cluster, or reuse the one that runs, record its port for the tool
wrappers and env.sh, and print the port for browser setup.
easy-db-lab socks start
On a Tailscale cluster it starts nothing and says that no tunnel is needed. See SOCKS Proxy.
socks stop
Stop the SOCKS5 tunnel and remove its port from the record, without tearing the cluster down.
Wrapped tools then fail with a pointer to socks start until the tunnel starts again.
easy-db-lab socks stop
If the tunnel process does not end after socks stop asks and then forces it to, socks stop prints its PID and exits non-zero. The PID and port stay recorded, so stop the process with kill <pid> and run socks stop again. If no tunnel runs, socks stop says so and succeeds.
ingress allow
Allow SSH (tcp, port 22) to the cluster from an IPv4 address or CIDR. Run it from the cluster's workspace. It adds one rule per value to the cluster's security group.
easy-db-lab ingress allow # this machine's public IP
easy-db-lab ingress allow --cidr 203.0.113.7 # one address, as 203.0.113.7/32
easy-db-lab ingress allow --cidr 203.0.113.7 --cidr 198.51.100.0/24
| Option | Description |
|---|---|
--cidr | An IPv4 address or CIDR. A bare address means /32. Repeat it for more than one value. With no --cidr, the command uses this machine's detected public IP. |
Use it when your public IP changed and SSH and the SOCKS tunnel stopped working, or to let another person in. It makes only AWS API calls, so it works when SSH does not, and it does not start the SOCKS tunnel.
- The command checks every value before it calls AWS. If one value is not valid, it names each bad value and its reason, changes nothing, and exits non-zero. A value with host bits set, such as
198.51.100.7/24, is not valid; the error names the network form,198.51.100.0/24. - Values that are the same CIDR, such as
203.0.113.7and203.0.113.7/32, count as one. - A rule that already exists is reported and left as it is.
- If AWS refuses one rule, for example because the security group has too many rules, the command still adds the others, reports AWS's reason, and exits non-zero.
- If the public IP cannot be detected, the command tells you to give it with
--cidr. Detection waits up to 5 seconds to connect toapi.ipify.organd up to 10 seconds for its answer.
A security group allows a connection that is already open. After a change, an SSH session or tunnel that is still connected can keep working until it reconnects.
ingress revoke
Remove the SSH rule (tcp, port 22) for an IPv4 address or CIDR from the cluster's security group. It takes the same --cidr values as ingress allow, and with none it uses this machine's detected public IP.
easy-db-lab ingress revoke --cidr 203.0.113.7
easy-db-lab ingress revoke --cidr 0.0.0.0/0 # the open rule of an init --open cluster
It removes only the exact SSH rule for each CIDR. Every other rule stays: SSH rules for other CIDRs, the rules for traffic within the VPC, and egress. It asks for no confirmation, even when the CIDR is your own public IP.
If a CIDR has no SSH rule, the command names it and exits non-zero, but it still removes the rules that do exist.
hosts
List all hosts in the cluster.
easy-db-lab hosts
status
Display full environment status.
easy-db-lab status
The === KITS === section lists every installed kit, marking the running ones with ✓. Under each running kit it prints the endpoints that the kit's kit.yaml declares: the NodePorts on each host of the endpoint's node type. A stopped kit shows no endpoints. If a running kit's kit.yaml cannot be read, the kit shows (endpoints unavailable: cannot read kit.yaml) instead of its endpoints.
status is the one command that degrades instead of failing outright when the SOCKS proxy
tunnel can't be established. It still reports EC2, VPC, security groups, Spark/EMR, OpenSearch,
S3, kits, observability URLs, and database versions — the last read directly over SSH, which
never uses the tunnel. Only the section that requires the private Kubernetes API (stress jobs)
is marked unavailable, stating the proxy failure as the reason. status still
exits non-zero when degraded, so a partial report is never mistaken for a healthy cluster by a
script. See Network Connectivity for how to diagnose a
tunnel that won't come up.
Cassandra Commands
All Cassandra commands are available under the cassandra subcommand group.
cassandra use
Switch the active Cassandra version.
easy-db-lab cassandra use <version> [options]
| Option | Description |
|---|---|
--java | Java version to use: 8, 11, 17, 21 or 25 |
--hosts | Filter to specific hosts |
The version can be any installed version, including a build published by cassandra build.
Run cassandra list to see them.
Fails if the version is not installed on a targeted node — install it first with
cassandra install.
cassandra build
Build a Cassandra branch checkout on your own machine and publish it to the profile's S3 bucket,
where cassandra install can find it by name.
easy-db-lab cassandra build [<dir>] --java <N> [options]
| Option | Description | Default |
|---|---|---|
<dir> | Cassandra source checkout to build | current directory |
--java, -j | JDK major version to build with. Required — it forms part of the build's name | — |
--jira | Ticket this build is for, e.g. CASSANDRA-19000 | none |
--name | Label folded into the name, to tell your builds apart at a glance | none |
--ant-flags | Extra flags passed to ant | none |
The build runs ant realclean then ant artifacts in the checkout, under the JDK you name.
JAVA_HOME is set for ant only — your shell's default JDK is untouched.
The version comes from the base.version property in the checkout's build.xml. Nothing is ever
inferred from the branch name — not the version, not the ticket. A branch naming convention is a
habit, not a contract, and this name is permanent once published, so --jira and --name are the
only ways anything gets into it. The result is named for what identifies it:
5.1-CASSANDRA-19000-flushfix-20260905-a1b2c3d-jdk17
<version>-<JIRA>------<--name>--<date>--<sha>--<jdk>
The ticket and --name segments are dropped entirely when absent, giving
5.1-20260905-a1b2c3d-jdk17. --name is for the case the rest of the name cannot help with: two
builds of the same commit that differ in something only you know. It sits after the ticket so the
version stays the leading segment and a bucket listing still sorts by release. Letters, digits,
., _ and - only, 40 characters or fewer — anything else is refused rather than quietly
rewritten, because this name is the build's identity in S3 and on every node.
That name is the build's S3 directory, the directory it installs into on a node, and the name
cassandra install takes.
A checkout with uncommitted changes builds normally — that is the case this command exists for — but you are warned, because the sha then does not fully describe what was built, and the manifest records the tree as dirty.
No cluster is needed. Builds belong to the profile, so one made on your desktop installs from your laptop.
Published layout, under the profile's account bucket:
cassandra-builds/5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17/
apache-cassandra-5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17-bin.tar.gz
manifest.json
manifest.json records the base version, the full and short sha, the branch and remote, whether
the tree was dirty, the ticket, the ant flags, when it was built and by which profile, and the
tarball's size and SHA-256.
Then install it on a cluster:
easy-db-lab cassandra install 5.1-CASSANDRA-19000-20260905-a1b2c3d-jdk17
Nodes fetch the tarball straight from the bucket with their instance profile.
cassandra install
Install an additional Cassandra version onto a running cluster, without rebuilding the AMI.
easy-db-lab cassandra install <version> [options]
| Option | Description | Default |
|---|---|---|
--url | Tarball URL (.tar.gz) or git repository URL (with --branch) | from the declared entry |
--branch | Git branch to clone and build, requires --url | from the declared entry |
--java, -j | Java version to build and run this version with | from the declared entry |
--python | Python version cqlsh runs under | 3.11.9 |
--ant-flags | Extra flags passed to ant when building from a branch | from the declared entry |
--hosts | Filter to specific hosts | all Cassandra nodes |
Each option falls back to the version's cassandra_versions.yaml entry when not supplied, so a
declared version needs no options at all. A name that is not declared locally is looked up among
the builds published by cassandra build, so a build installs by name with no
options either. Every targeted node is attempted regardless of what
happens on the others, and each node's outcome is reported individually.
A version already installed on a node is never rebuilt:
- Asking for the parameters it was built with is a no-op.
- Asking for different
--java,--python, or--ant-flagschanges nothing on that node, reports which fields disagree, and exits non-zero. To run the existing build under a different JDK, usecassandra use <version> --java <version>instead.
A failure on any node also exits non-zero, with the reason reported against that node. A token
embedded in --url is used for the clone but never persisted to the node and never appears in logs,
errors, or events.
See Configuring Cassandra for the full workflow.
cassandra write-config
Generate a new configuration patch file.
easy-db-lab cassandra write-config [filename] [options]
Aliases: wc
| Option | Description | Default |
|---|---|---|
-t, --tokens | Number of tokens | 4 |
cassandra update-config
Apply configuration patch to all nodes.
easy-db-lab cassandra update-config [options]
Aliases: uc
| Option | Description |
|---|---|
--restart, -r | Restart Cassandra after applying |
--hosts | Filter to specific hosts |
cassandra download-config
Download configuration files from nodes.
easy-db-lab cassandra download-config [options]
Aliases: dc
| Option | Description |
|---|---|
--version | Version to download config for |
The files land in a directory named for the version (for example 5.0/) in the workspace. An
existing directory is never overwritten: the command reports that it already exists and skips the
download. Delete the directory to download it again.
cassandra start
Start Cassandra on all nodes.
easy-db-lab cassandra start [options]
| Option | Description | Default |
|---|---|---|
--sleep | Time between starts in seconds | 120 |
--hosts | Filter to specific hosts | - |
--sidecar-image | Container image for the sidecar DaemonSet | ghcr.io/apache/cassandra-sidecar:latest |
Use --sidecar-image to test a fork or specific version:
easy-db-lab cassandra start --sidecar-image ghcr.io/myfork/cassandra-sidecar:my-branch
cassandra stop
Stop Cassandra on all nodes.
easy-db-lab cassandra stop [options]
| Option | Description |
|---|---|
--hosts | Filter to specific hosts |
cassandra restart
Restart Cassandra on all nodes.
easy-db-lab cassandra restart [options]
| Option | Description |
|---|---|
--hosts | Filter to specific hosts |
cassandra list
List available Cassandra versions.
easy-db-lab cassandra list
Aliases: ls
Versions installed on the node are listed first. A version declared with lazy: true that is not
installed on that node is listed too, marked (declared, not installed), as is any build published
by cassandra build and not yet installed there, marked
(build, not installed).
Cassandra Profiling Commands
Runtime async-profiler control under cassandra profile. Every command applies to all Cassandra
nodes by default; --hosts narrows it to a subset. A cluster profiles CPU automatically from
cluster-up, so these are for changing what is profiled and for pulling profiles out.
This is not the top-level profile group, which manages your easy-db-lab user profile. See
Profile Commands for that.
See Profiling for the full guide.
cassandra profile start
Enable profiling with a given set of async-profiler arguments. Arguments after -- are passed to
asprof untouched.
easy-db-lab cassandra profile start -- -e cpu
easy-db-lab cassandra profile start --loop 30s -- -e wall -i 10ms
easy-db-lab cassandra profile start --hosts db0,db1 -- -e cpu --alloc 512k
| Option | Default | Description |
|---|---|---|
--loop | 15s | JFR rotation interval |
--retention | 60 | Minutes of profile data to keep on each node |
--max-bytes | 2147483648 | Byte ceiling for each node's profile directory |
--hosts | all | Comma-separated host aliases |
easy-db-lab supplies async-profiler's output file, output format, rotation, and session duration, so
-f/--file, -o/--output, bare format words, --loop, -d/--duration and --timeout are
rejected at the CLI before any node is contacted. Use --loop above to set rotation.
Never combine a CPU event with wall-clock sampling in one recording. Switch modes with stop/start
instead — see the cpu+wall hazard.
cassandra profile stop
Disable profiling. Records an explicit disabled state; the running session is stopped cleanly so its in-flight chunk is finalized and still ships.
easy-db-lab cassandra profile stop
easy-db-lab cassandra profile stop --hosts db0
cassandra profile status
Report per node: enabled state, the process being profiled, session age, your arguments verbatim, the full command line as actually invoked, chunks pending/shipped/rejected, bytes on disk, and the last shipping error.
easy-db-lab cassandra profile status
cassandra profile fetch
Download completed JFR chunks into ./profiles/<host>/. The chunk currently being written is never
offered — it has no constant pool yet and no tool can read it.
easy-db-lab cassandra profile fetch
easy-db-lab cassandra profile fetch --last 20 --hosts db0
| Option | Default | Description |
|---|---|---|
--last | 5 | How many of the most recent completed chunks to download |
--hosts | all | Comma-separated host aliases |
cassandra profile flamegraph
Convert recent chunks into a flame graph on the node and download the result. jfrconv arguments
after -- pass through untouched; its input and output are reserved.
easy-db-lab cassandra profile flamegraph --last 10
easy-db-lab cassandra profile flamegraph --last 20 -- --threads
| Option | Default | Description |
|---|---|---|
--last | 5 | How many of the most recent completed chunks to convert |
--format | html | Output format handed to jfrconv |
--hosts | all | Comma-separated host aliases |
--threads is the only way to get Cassandra thread-pool attribution: Pyroscope's ingest discards
thread identity.
Cassandra Stress Commands
Stress testing commands under cassandra stress.
cassandra stress start
Start a stress job on Kubernetes.
easy-db-lab cassandra stress start [options]
Aliases: run
cassandra stress stop
Stop and delete stress jobs.
easy-db-lab cassandra stress stop [options]
cassandra stress status
Check status of stress jobs.
easy-db-lab cassandra stress status
cassandra stress logs
View logs from stress jobs.
easy-db-lab cassandra stress logs [options]
cassandra stress list
List available workloads.
easy-db-lab cassandra stress list
cassandra stress fields
List available field generators.
easy-db-lab cassandra stress fields
cassandra stress info
Show information about a workload.
easy-db-lab cassandra stress info <workload>
Utility Commands
exec
Execute commands on remote hosts via systemd-run. Tool output is captured by the systemd journal and shipped to Loki through Fluent Bit and the OTel collector, with accurate timestamps for cross-service log correlation.
exec run
Run a command on remote hosts (foreground by default). The command runs through bash -c: pass a whole command line as one quoted argument, or pass words after --, which are quoted one by one. If the command fails on any host, exec run prints that host's output, reports the host, and exits non-zero.
# Foreground (blocks until complete, shows output)
easy-db-lab exec run -t cassandra -- ls /mnt/db1
# A shell command line, quoted as one argument
easy-db-lab exec run --hosts db0 "df -h /mnt/db1 | tail -1"
# Background (returns immediately, tool keeps running)
easy-db-lab exec run --bg -t cassandra -- inotifywait -m /mnt/db1/data
# Background with custom name
easy-db-lab exec run --bg --name watch-imports -t cassandra -- inotifywait -m /mnt/db1/data
| Option | Description |
|---|---|
-t, --type | Server type: cassandra, stress, control (default: cassandra) |
--bg | Run in background (returns immediately) |
--name | Name for the systemd unit, edl-exec-<name> (default: the command's first word and a timestamp). Characters systemd does not allow become -. |
--hosts | Filter to specific hosts |
-p | Execute in parallel across hosts |
exec list
List running background tools on remote hosts.
easy-db-lab exec list
easy-db-lab exec list -t cassandra
exec stop
Stop a named background tool.
easy-db-lab exec stop watch-imports
easy-db-lab exec stop watch-imports -t cassandra
help
Show task-oriented help topics packaged with the tool. These are short guides to common operations, not a listing of command-line flags.
Run with no argument to list every topic and its description:
easy-db-lab help
Run with a topic name to print that topic's guide. Matching is case-insensitive:
easy-db-lab help provisioning
The seed topics are provisioning, kits, stress-testing, profiles, connecting, querying,
observability, spark, cassandra, and cassandra-builds. The cassandra topic covers database
lifecycle, version selection, and configuration on a running cluster. The cassandra-builds topic
shows how to build a local Cassandra source tree and run it on a cluster. Topics are packaged
markdown files, so help works from a Homebrew install with no source checkout. An unknown topic
prints an error that names the bad topic, lists the valid ones, and exits non-zero.
The standard -h/--help output points here too: the root usage carries a footer directing you to
help, and each subcommand that maps to a topic names the related help <topic>.
ip
Get IP address for a host by alias.
easy-db-lab ip <alias>
version
Display the easy-db-lab version.
easy-db-lab version
repl
Start interactive REPL.
easy-db-lab repl
server
Start the server for Claude Code integration, REST status endpoints, and live metrics.
easy-db-lab server
See Server for details.
Logs Commands
logs query
Read the cluster's logs from Loki.
easy-db-lab logs query [options]
| Option | Description | Default |
|---|---|---|
--source, -s | Log source: cassandra (application logs, selects service_name="cassandra"), cassandra-gc (JVM GC log), journald, system, tool-runner, emr | All sources |
--host, -H | Hostname (db0, app0, control0) | All hosts |
--unit | systemd unit | All units |
--since | Time range (1h, 30m, 1d) | 1h |
--limit, -n | Most lines to return | 100 |
--grep, -g | Only lines containing this text | None |
--query, -q | Raw LogQL query, sent unchanged | None |
Every option but --query is scoped to this cluster; a raw query reads every cluster in the tenant unless it names a cluster. A redirect cluster has no local Loki, so the command refuses there. See Logs (Loki).
Metrics and logs are no longer snapshotted: Mimir and Loki write to S3 themselves, and down flushes them. The metrics backup, metrics import, metrics ls, logs backup, logs import and logs ls commands are gone.
Kubernetes Commands
k8 apply
Apply observability stack to K8s cluster.
easy-db-lab k8 apply
platform cni
Show the pod-network datapath. Read-only.
easy-db-lab platform cni
On a Cilium cluster, prints the routing mode, IPAM mode, kube-proxy replacement, masquerade interfaces, native routing CIDR, and the Hubble UI URL, then one block per node with its ENI count, subnet CIDRs, and IPs allocated, used, and available. The values are read from the cilium-config ConfigMap and the CiliumNode objects on the control node. On a Flannel cluster, prints one line that names Flannel and exits 0. See Pod Networking (CNI).
Observability Commands
These commands manage the account compactor. They work outside a cluster workspace: the account bucket comes from the profile.
observability compactor start
Start the account compactor as up does: create it when it is missing, and start it at 1 task when it is stopped. A running compactor is left as it is.
easy-db-lab observability compactor start
observability compactor stop
Stop the account compactor by setting its desired count to 0. When the compactor was never created, it says so and changes nothing.
easy-db-lab observability compactor stop
observability compactor status
Print the compactor's state, its desired, running and pending task counts, and its current or last task with its state. It changes nothing.
The state is one of not created, stopped, stopping, starting, running or failing. A compactor that asks for a task but runs none is starting while a task is pending or before any task has stopped, and failing when none is pending and its latest task stopped, so a task that crashes in a loop never reads as running.
For that task it prints why the task stopped (ECS's stop code and reason) and each container's status, exit code and reason. It also prints the service's 5 newest events and the last 20 log lines of the task's containers. It leaves out the Tempo worker's no jobs found line, which an idle worker logs on every poll.
If your IAM user lacks the EasyDBLabCompactor policy, observability compactor start, stop and status fail and name the policy. Run easy-db-lab show-iam-policies compactor to print it, then attach it.
easy-db-lab observability compactor status
Grafana Commands
grafana update-config
Build and apply the full observability stack to the K8s cluster, including Grafana and its core dashboards. up runs this automatically.
The core dashboards are copied as a file tree onto the control node's Grafana data directory (/mnt/db1/grafana/dashboards), one subdirectory per Grafana folder. Grafana's file provider re-reads that directory every 10 seconds and files each dashboard into a folder named after its subdirectory. The command reads the tree from the installed build, so run ./gradlew installDist after editing a dashboard before running it from a source checkout.
easy-db-lab grafana update-config
grafana install
Upload a single dashboard JSON file to the running Grafana instance through its HTTP API. This is the one-off path for a dashboard that is not part of the core tree; it does not touch the copied tree, and the dashboard is updated in place only if the JSON carries a top-level uid. Before the upload, the command applies the same defaults up applies, from the workspace's cluster state: each tenant picker selects the cluster's own tenant, and cluster, baseline_cluster and candidate_cluster select the current cluster.
easy-db-lab grafana install my-dashboard.json --folder=experiments
| Option | Description | Default |
|---|---|---|
--folder | Grafana folder to install into; created if it does not exist | General |
grafana annotate
Create a Grafana annotation on the running cluster. Use it to drop an A/B config-change marker on the dashboards' timeline; for example, before and after a Cassandra setting change.
A plain global marker (no --dashboard and no --panel scope) is automatically tagged easydblab, in addition to any --tags you pass. Every annotation is also mirrored to Loki as it is created, and the core dashboards read their markers from Loki, so a global marker renders on every core dashboard and is still readable from S3 after the cluster is gone. A scoped marker (--dashboard or --panel) is not auto-tagged; it renders on its target dashboard. If Grafana creates the annotation but the mirror to Loki fails, the command reports the created annotation and its id, then exits non-zero; the next grafana backup or down mirrors it. The command reaches Grafana over the SOCKS proxy. If the Grafana API is unreachable, the command exits non-zero and names the endpoint.
easy-db-lab grafana annotate --text "raised concurrent_writes to 128" --tags config
| Option | Description | Default |
|---|---|---|
--text | The annotation body text (required) | - |
--tags | Tags to attach; repeat the flag or comma-separate | none |
--time | Start time: now, a relative offset like -30m/-2h/-1d, an ISO-8601 instant, or epoch millis | now |
--time-end | Optional end time; produces a region annotation (same formats as --time) | none |
--dashboard | Optional dashboard UID to scope the annotation to one dashboard | all dashboards |
--panel | Optional panel id to scope the annotation to one panel | all panels |
grafana backup
Back up the cluster's Grafana annotations to the observability store in the account bucket.
The annotations are the A/B config-change markers worth keeping after the ephemeral cluster is torn down. The artifact lands at grafana/annotations/<tenant>/<yyyyMMdd-HHmmss>_<name>-<clusterId>.json, so backups from clusters that share a tenant never overwrite each other. The command reports the resulting S3 URI on success. If no S3 bucket is configured, it fails fast with the standard "run up first" message.
Before it writes the file, the command mirrors every annotation to Loki (source="annotation"), so the annotations can also be read from Loki's store after the cluster is gone; see Logs (Loki). An annotation older than 8760 hours or more than 24 hours ahead is outside the window Loki accepts: it is skipped with a warning naming its id, and it is still in the JSON backup.
The backup captures up to 5000 annotations in one call. If the cluster has 5000 or more, the command fails and backs up nothing, rather than persisting the first 5000 as a complete backup. This makes a truncated backup impossible to mistake for a full one.
easy-db-lab grafana backup
This backup also runs automatically before teardown; see down.
Report Commands
report upload
Attach markdown documents to the current test. Each file is stored in the test's folder in the account bucket, reports/<tenant>/<name>-<id>/, with an HTML copy, and the test's index.html is rebuilt to hold every document, each under its own heading, in name order. The Tests and comparison dashboards show that index. A file with the name of a stored document replaces it.
The command accepts only .md files whose names hold letters, digits, ., _ and -, and refuses index.md and a name given twice. It checks every file first; if it refuses any, it names each one and uploads nothing. It uses your own AWS credentials and needs only the workspace's state.json, so it works before and after down.
If an upload fails, the command names the failed file and each document it already stored. Those documents are in the bucket but not yet in the index. Run the command again.
easy-db-lab report upload results.md notes.md
| Argument | Description |
|---|---|
FILE... | One or more markdown files (required) |
Two uploads for one test at the same moment can each rebuild the index without the other's document; the next upload restores it.
ClickHouse Commands
clickhouse start
Deploy ClickHouse cluster to K8s.
easy-db-lab clickhouse start [options]
clickhouse stop
Stop and remove ClickHouse cluster.
easy-db-lab clickhouse stop
clickhouse status
Check ClickHouse cluster status.
easy-db-lab clickhouse status
Spark Commands
spark submit
Submit Spark job to EMR cluster.
easy-db-lab spark submit [options]
spark status
Check status of a Spark job (the most recent job, or the one --step-id names).
easy-db-lab spark status [options]
| Option | Description |
|---|---|
--step-id | EMR step ID (defaults to the most recent job) |
--verbose, -v | Show detailed step information (equivalent to aws emr describe-step) |
--logs | Download the step's stderr.gz from S3 and print it; if EMR has not uploaded it yet, print the S3 path where it will appear |
spark jobs
List recent Spark jobs on the cluster.
easy-db-lab spark jobs
spark logs
Query a Spark job's logs from Loki (the most recent job, or the one --step-id names).
easy-db-lab spark logs [options]
OpenSearch Commands
opensearch start
Create an AWS OpenSearch domain.
easy-db-lab opensearch start [options]
opensearch stop
Delete the OpenSearch domain.
easy-db-lab opensearch stop
opensearch status
Check OpenSearch domain status.
easy-db-lab opensearch status
AWS Commands
aws vpcs
List all easy-db-lab VPCs.
easy-db-lab aws vpcs
Port Reference
This page documents the ports used by easy-db-lab and the services it provisions.
Cassandra Ports
| Port | Purpose |
|---|---|
| 9042 | Cassandra Native Protocol (CQL) |
| 7000 | Inter-node communication |
| 7001 | Inter-node communication (SSL) |
| 7199 | JMX monitoring |
Observability Ports (Control Node)
| Port | Service |
|---|---|
| 3000 | Grafana |
| 3080 | Test documents web server (read-only, in the Grafana pod) |
| 3081 | Test documents signing proxy (loopback only, in the Grafana pod) |
| 8081 | Grafana image renderer (in the Grafana pod) |
| 4040 | Pyroscope (continuous profiling) |
| 9009 | Mimir HTTP (metrics storage; Prometheus API under /prometheus) |
| 9097 | Mimir gRPC |
| 7947 | Mimir memberlist (loopback only) |
| 11211 | Mimir's memcached caches (loopback only, in the Mimir pod) |
| 3100 | Loki HTTP (log storage) |
| 9098 | Loki gRPC |
| 3200 | Tempo (trace storage) |
| 5001 | YACE CloudWatch exporter (Prometheus) |
| 8080 | kube-state-metrics (Prometheus; ClusterIP Service, pod network) |
Observability Ports (All Nodes — DaemonSets)
| Port | Service |
|---|---|
| 4317 | OTel Collector gRPC |
| 4318 | OTel Collector HTTP — the Cassandra JVM agent exports here |
| 9400 | Beyla eBPF metrics (Prometheus) |
| 9435 | ebpf_exporter metrics (Prometheus) |
Cilium Ports (Cilium clusters only; the default CNI)
| Port | Service |
|---|---|
| 9962 | Cilium agent metrics (Prometheus, every node) |
| 9963 | Cilium operator metrics (Prometheus, the node that runs the operator) |
| 9965 | Hubble metrics (Prometheus, every node) |
| 31234 | Hubble UI (NodePort, any node's private IP) |
Kit Ports
Kits publish client ports through NodePort Services, reachable on any node's private IP.
| Port | Kit | Service |
|---|---|---|
| 31211 | memcached | memcached client port (NodePort → 11211) |
| 9150 | memcached | memcached-exporter metrics (pod port, scraped by pod discovery) |
| 30687 | neo4j | Bolt (NodePort → 7687) |
| 30474 | neo4j | HTTP (NodePort → 7474) |
| 30432 | postgres | PostgreSQL (NodePort → 5432) |
| 30987 | postgres | metrics (NodePort → 9187, for manual inspection; scraped by pod discovery) |
| 30435 | postgres-duckdb | PostgreSQL (NodePort → 5432) |
| 30990 | postgres-duckdb | metrics (NodePort → 9187) |
| 30433 | postgres-postgis | PostgreSQL (NodePort → 5432) |
| 30988 | postgres-postgis | metrics (NodePort → 9187) |
| 30434 | postgres-timescaledb | PostgreSQL (NodePort → 5432) |
| 30989 | postgres-timescaledb | metrics (NodePort → 9187) |
Every built-in kit's NodePorts, each postgres instance's, and the Hubble UI's 31234 are distinct, so any combination of kits can run at once.
Server
| Port | Purpose |
|---|---|
| 8080 | Default server port (configurable via --port) |
SSH
SSH access is configured automatically through the sshConfig file generated by source env.sh.
OpenTelemetry Instrumentation
easy-db-lab includes optional OpenTelemetry (OTel) instrumentation for distributed tracing and metrics. When enabled, traces and metrics are exported to an OTLP-compatible collector.
CLI Tool Instrumentation
The easy-db-lab CLI tool runs with the OpenTelemetry Java Agent, which automatically instruments:
- AWS SDK calls - EC2, S3, IAM, EMR, STS, OpenSearch operations
- HTTP clients - OkHttp and other HTTP libraries
- JDBC/Cassandra driver - Database operations
- JVM metrics - Memory, threads, garbage collection
Enabling Instrumentation
Set the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to your OTLP collector endpoint:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
easy-db-lab up
When this environment variable is:
- Set: Traces, metrics and logs are exported to the specified endpoint. The agent's default protocol is
http/protobuf, which collectors serve on port 4318. To use a gRPC endpoint (port 4317), also setOTEL_EXPORTER_OTLP_PROTOCOL=grpc. - Not set: The agent is still loaded, but it exports nothing and prints nothing.
The easy-db-lab launcher decides this before the JVM starts. The agent's own default is to export to localhost:4318, so without a collector there every export fails and prints a stack trace to stderr. When none of the following variables is set, the launcher passes -Dotel.traces.exporter=none, -Dotel.metrics.exporter=none and -Dotel.logs.exporter=none, plus -Dotel.javaagent.logging=none unless OTEL_JAVAAGENT_LOGGING is set:
OTEL_EXPORTER_OTLP_ENDPOINTOTEL_EXPORTER_OTLP_TRACES_ENDPOINT,OTEL_EXPORTER_OTLP_METRICS_ENDPOINT,OTEL_EXPORTER_OTLP_LOGS_ENDPOINTOTEL_TRACES_EXPORTER,OTEL_METRICS_EXPORTER,OTEL_LOGS_EXPORTER
Setting any of them leaves every export setting to the agent, including its error reporting, so a misconfigured collector still shows up as export errors. The check has to happen in the launcher because the agent gives system properties priority over environment variables: an unconditional -D would override the user's OTEL_* settings.
The agent uses automatic instrumentation only - there is no custom manual instrumentation in the CLI tool code.
Cluster Node Instrumentation
The following instrumentation applies to cluster nodes (Cassandra, stress, Spark) and is separate from the CLI tool:
Node Role Labeling
The OTel Collector (0.161.0) on cluster nodes sets the node_role resource attribute of host metrics and host log files from the node's host name: db<N> is db, app<N> is app, and control<N> is control (transform/node_role_from_host). A node_role that is already set is kept. This label is used by Grafana dashboards (e.g., System Overview) for hostname and service filtering.
| Node Type | K8s Label | node_role Value | Source |
|---|---|---|---|
| Cassandra host metrics | N/A | db | Host name db<N> |
| Cassandra JVM | N/A | db | otel.resource.attributes in cassandra.in.sh |
| Stress host metrics | N/A | app | Host name app<N> |
| Control host metrics | N/A | control | Host name control<N> |
| Spark/EMR | N/A | spark | EMR OTel Collector resource/role processor |
The host-name rule runs in the metrics/local and logs/local pipelines only. Host metrics and host log files carry no pod, so the k8s_attributes processor cannot find the node's type label for them. It still runs in metrics/local, before the host-name rule, for the kit metrics scraped by pod discovery: the Prometheus receiver sets each target's pod and namespace, and k8s_attributes adds the pod's labels (k8s_app_instance, which the kit dashboards select on) and the node's type as node_role. It also runs on container logs (logs/containers). Metrics arriving over OTLP take the metrics/otlp pipeline, which runs neither, so each OTLP source sets node_role itself: the Cassandra JVM agent and the stress sidecar declare it as a resource attribute, and Spark nodes set it in their own collector.
The processor requires RBAC access to the K8s API. The OTel Collector DaemonSet runs with a dedicated ServiceAccount (otel-collector) that has read-only access to pods and nodes.
Stress Job Metrics
When running cassandra-easy-stress as K8s Jobs, metrics are automatically collected via an OTel collector sidecar container. The sidecar scrapes the stress process's Prometheus endpoint (localhost:9500) and forwards metrics via OTLP to the node's OTel DaemonSet, which then exports them to Mimir.
The Prometheus scrape job is named cassandra-easy-stress. The following labels are available in Grafana:
| Label | Source | Description |
|---|---|---|
host_name | DaemonSet resource_detection processor | K8s node name where the pod runs |
instance | Sidecar relabel_configs | Node name with port (e.g., ip-10-0-1-50:9500) |
cluster | Sidecar relabel_configs | Cluster name from cluster-config ConfigMap |
Short-lived stress commands (list, info, fields) do not include the sidecar since they complete quickly and don't produce meaningful metrics.
Cassandra JVM Instrumentation
The Cassandra JVM runs the OpenTelemetry Java Agent (v2.31.1). Its JMX Metric Insight module reads Cassandra's own MBeans in process and exports them over OTLP. The agent replaces the k8ssandra management-api (MCAC/MAAC) agent, which is removed.
packer/cassandra/cassandra.in.sh adds the agent flags at JVM start. Cassandra's bin/cassandra sources that file under /bin/sh (dash), so it must stay POSIX sh. A bashism there does not degrade to "no metrics": dash fails to parse the file, and Cassandra does not start.
Key configuration:
- OTel Agent JAR: installed by Packer to
/usr/local/otel/opentelemetry-javaagent.jar - Service name:
cassandra, which the collector turns into the Prometheus labeljob="cassandra" - Export endpoint:
http://localhost:4318(local OTel Collector DaemonSet) - Export interval: 5 seconds
- JMX discovery delay: 5 seconds. The agent default is 60 seconds, which holds the first metrics back for about a minute and delays picking up a new table by as much again.
- Rule file:
/etc/easy-db-lab/cassandra-jmx-rules.yaml, written bysetup-instances
Use port 4318, not 4317. The agent's default OTLP protocol is http/protobuf, which the collector serves on 4318. Port 4317 is the collector's gRPC port, and every export fails there.
The agent goes on JVM_EXTRA_OPTS, never on JVM_OPTS. bin/nodetool sources cassandra.in.sh and puts $JVM_OPTS on its own command line, so an agent on JVM_OPTS starts again for every nodetool, sstableloader and cassandra-stress run. nodetool discards JVM_EXTRA_OPTS, which is what an agent wants.
How a node labels its own metrics
cassandra.in.sh derives one value at JVM start and interpolates it into an OTel resource attribute:
EDL_CASSANDRA_BUILD=$(basename "$(readlink -f /usr/local/cassandra/current 2>/dev/null)" 2>/dev/null)
edl_add_jvm_extra_opt \
"-javaagent:${EDL_OTEL_AGENT_JAR}" \
"-Dotel.service.name=cassandra" \
"-Dotel.resource.attributes=service.instance.id=$(hostname),node_role=db,cassandra_build=${EDL_CASSANDRA_BUILD}" \
"-Dotel.exporter.otlp.endpoint=http://localhost:4318" \
"-Dotel.metric.export.interval=5s" \
"-Dotel.jmx.discovery.delay=5000"
otel.resource.attributes declares the SDK's Resource: comma-separated key=value pairs that the agent attaches once, at startup. The Resource is stamped on every metric, span and log that JVM exports. It is identity attached at the source, not bookkeeping added per metric.
The collector turns Resource attributes into Prometheus labels. The prometheus_remote_write exporter sets resource_to_telemetry_conversion: enabled: true in otel-collector-config.yaml. That step is what makes sum by (cassandra_build) work.
The value is read at JVM start from the symlink that cassandra use moves, so a node describes itself. Run cassandra use --hosts=db2 <version> and restart that node, and it reports its new build. Nothing is pushed, and no other node is touched. This is what makes a mixed-version comparison work when the split changes between runs.
An info metric carrying the version was rejected. Such a metric sits on its own series, so sum by (cassandra_build) (<any other metric>) returns nothing and every panel needs a group_left join. A resource attribute lands on every series instead.
ReleaseVersion from the StorageService MBean was also rejected. It reports 5.0.9 against a 5.0.9-SNAPSHOT build, which separates a stock release from a branch but collapses every branch build into one value. Two branches under test would be indistinguishable. The symlink target carries the commit sha.
The same hook is open for anything else the node knows about itself: instance type, availability zone, disk layout, or a test-run identifier. Add a shell variable, interpolate it into the same list, and it becomes a dimension you can group and filter by across every metric that JVM emits.
Two rules protect the value:
- Keep the whole attribute value as one shell word. It contains
=and,, so it must stay quoted. - Keep the file POSIX sh, for the dash reason above.
If the symlink cannot be read, the value falls back to cassandra_build=unknown and the script warns on stderr. The attribute is never dropped. Dropping it would silently merge that node into another variant's series.
The JMX rule file
The agent reads every rule from /etc/easy-db-lab/cassandra-jmx-rules.yaml. otel.jmx.target.system is deliberately not set, so the built-in experimental-cassandra target supplies nothing. That target is experimental upstream: its metric names can move between agent releases and empty every panel that reads them. Owning the rules pins each name to this repo.
The file ships from the CLI, not from the AMI. It is a Kotlin classpath resource at src/main/resources/com/rustyrazorblade/easydblab/configuration/cassandra/cassandra-jmx-rules.yaml. init extracts it into the cluster workspace, and setup-instances uploads it to each Cassandra node. Changing a rule therefore costs ./gradlew installDist and a Cassandra restart, never an AMI rebake.
The rules define these families:
| Metric | Attributes | Source MBean |
|---|---|---|
cassandra.client.request.latency.{p50,p99,p999,max} | cassandra.operation | type=ClientRequest,scope={Read,Write,RangeSlice,CASRead,CASWrite},name=Latency |
cassandra.client.request.count | cassandra.operation | the same five beans, Count |
cassandra.client.request.error | cassandra.operation, cassandra.status | name={Unavailables,Timeouts,Failures} |
cassandra.compaction.tasks.{completed,pending} | — | type=Compaction |
cassandra.storage.{load,hints.count,hints.in_progress} | — | type=Storage |
cassandra.table.disk.space.{live,total} | keyspace, table | type=Table |
cassandra.table.sstable.count.live | keyspace, table | type=Table,name=LiveSSTableCount |
cassandra.table.compaction.pending | keyspace, table | type=Table,name=PendingCompactions |
cassandra.table.{read,write}.latency.{p50,p99,p999,max} | keyspace, table | type=Table,name={Read,Write}Latency |
cassandra.table.sstables.per.read.{p50,p99,max} | keyspace, table | type=Table,name=SSTablesPerReadHistogram |
cassandra.thread_pool.tasks.{active,pending,blocked} | pool, pool_path | type=ThreadPools |
cassandra.messages.dropped | message_type | type=DroppedMessage |
cassandra.table.range.latency.{p50,p99,p999,max} | keyspace, table | type=Table,name=RangeLatency |
cassandra.table.live_rows.scanned.{p50,p99,p999,max} | keyspace, table | type=Table,name=LiveScannedHistogram |
cassandra.table.read_repair.requests | keyspace, table | type=Table,name=ReadRepairRequests |
cassandra.table.short_read_protection.requests | keyspace, table | type=Table,name=ShortReadProtectionRequests |
cassandra.table.replica_filtering_protection.requests | keyspace, table | type=Table,name=ReplicaFilteringProtectionRequests |
cassandra.table.replica_filtering_protection.rows_cached.{p50,p99,max} | keyspace, table | type=Table,name=ReplicaFilteringProtectionRowsCachedPerQuery |
cassandra.table.repaired_data.inconsistencies.{confirmed,unconfirmed} | keyspace, table | type=Table,name=RepairedDataInconsistencies{Confirmed,Unconfirmed} |
cassandra.table.repaired_data.overread.rows.{p50,p99,max} | keyspace, table | type=Table,name=RepairedDataTrackingOverreadRows |
cassandra.table.repaired_data.overread.time.{p50,p99,p999,max} | keyspace, table | type=Table,name=RepairedDataTrackingOverreadTime |
cassandra.read_repair.{blocking,reconcile_reads} | — | type=ReadRepair,name={RepairedBlocking,ReconcileRead} |
cassandra.buffer_pool.{hits,misses} | pool | type=BufferPool,name={Hits,Misses} |
cassandra.buffer_pool.{capacity,size,used,overflow} | pool | type=BufferPool,name={Capacity,Size,UsedSize,OverflowSize} |
cassandra.client.request.cas.contention.{p50,p99,p999,max} | cassandra.operation | type=ClientRequest,scope={CASRead,CASWrite},name=ContentionHistogram |
cassandra.client.request.cas.contended | cassandra.operation | the same beans, Count |
cassandra.client.request.cas.unfinished_commits | cassandra.operation | type=ClientRequest,scope={CASRead,CASWrite},name=UnfinishedCommit |
cassandra.client.request.cas.condition_not_met | cassandra.operation | type=ClientRequest,scope=CASWrite,name=ConditionNotMet |
cassandra.paxos.linearizability_violations | — | type=Paxos,name=LinearizabilityViolations |
cassandra.client.request.consistency.latency.{p50,p99,p999,max} | cassandra.operation, cassandra.consistency_level | type=ClientRequest,scope={Read-QUORUM,Write-QUORUM},name=Latency |
cassandra.table.cas.{prepare,propose,commit}.latency.{p50,p99,p999,max} | keyspace, table | type=Table,name=Cas{Prepare,Propose,Commit}Latency |
Latency is reported in microseconds. The rules set unit: us and no sourceUnit, so the raw MBean value passes through unconverted. That is the unit nodetool proxyhistograms and nodetool tablehistograms print, so a dashboard and the oracle compare directly: 86 against 86, not 86 against 0.000086. The Prometheus suffix follows the unit, so these series end _microseconds, for example cassandra_client_request_latency_p99_microseconds.
Lightweight transactions use the client request families as cassandra_operation="casread" and "caswrite". Cassandra also records a serial read in scope=Read, so a request total summed over every operation counts a serial read twice; filter cassandra_operation!="casread" for a total that counts each request once. A CAS write is not recorded in scope=Write. To see regular read latency without serial reads, read cassandra_client_request_consistency_latency_* with cassandra_operation="read", cassandra_consistency_level="quorum".
Latency is a percentile gauge, not a histogram
The MAAC agent emitted Prometheus histogram buckets. Every bucket carried the same cumulative count, so the histogram held no distribution and histogram_quantile() returned a constant.
The rules read Cassandra's own EstimatedHistogram instead, the same reservoir nodetool proxyhistograms prints, and report each percentile as a gauge. Two consequences follow:
histogram_quantile()does not apply to these series. There are no_bucketseries to give it.- A pre-aggregated percentile cannot be re-aggregated, so there is no true cluster-wide p99. Show the spread across nodes with
max by (cluster)andmin by (cluster)instead.
Migrating from MAAC
Metric names and the job label both change, and nothing translates between them.
| Before | After | |
|---|---|---|
| Metric prefix | org_apache_cassandra_metrics_* | cassandra_* |
| Job label | job="cassandra-maac" | job="cassandra" |
| Transport | Prometheus scrape of localhost:9000 | OTLP to localhost:4318 |
| Latency shape | histogram buckets | percentile gauges |
A query that spans the change returns two disjoint sets of series. Metrics stored before the change are not comparable with metrics stored after it.
An agent version bump is a dashboard-affecting change. Re-verify every Cassandra panel under load after one.
Spark JVM Instrumentation
EMR Spark jobs are auto-instrumented with the OpenTelemetry Java Agent (v2.31.1) and Pyroscope Java Agent (v2.3.0), both installed via an EMR bootstrap action. The OTel agent is activated through spark.driver.extraJavaOptions and spark.executor.extraJavaOptions.
Each EMR node also runs an OTel Collector as a systemd service, collecting host metrics (CPU, memory, disk, network) and receiving OTLP from the Java agents. The collector forwards all telemetry to the control node's OTel Collector via OTLP gRPC.
Key configuration:
- OTel Agent JAR: Downloaded by bootstrap action to
/opt/otel/opentelemetry-javaagent.jar - Pyroscope Agent JAR: Downloaded by bootstrap action to
/opt/pyroscope/pyroscope.jar - OTel Collector: Installed at
/opt/otel/otelcol-contrib, runs asotel-collector.service - Export protocol: OTLP/gRPC to
localhost:4317(local collector), which forwards to control node - Logs exporter: OTLP (captures JVM log output)
- Service name:
spark-<job-name>(set per job) - Profiling: CPU, allocation (512k threshold), lock (10ms threshold) profiles in JFR format sent to Pyroscope server
Cassandra Sidecar Instrumentation
The Cassandra Sidecar process is instrumented with the OpenTelemetry Java Agent and Pyroscope Java Agent, matching the pattern used for Cassandra itself. Both agents are loaded via -javaagent flags set in /etc/default/cassandra-sidecar, which is written by the setup-instances command.
Key configuration:
- OTel Agent JAR: Installed by Packer to
/usr/local/otel/opentelemetry-javaagent.jar - Pyroscope Agent JAR: Installed by Packer to
/usr/local/pyroscope/pyroscope.jar - Service name:
cassandra-sidecar(both OTel and Pyroscope) - Export endpoint:
localhost:4317(local OTel Collector DaemonSet) - Profiling: CPU, allocation (512k threshold), lock (10ms threshold) profiles sent to Pyroscope server
- Activation: Gated on
/etc/default/cassandra-sidecar— the systemdEnvironmentFile=-directive makes it optional, so the sidecar starts normally without instrumentation if the file doesn't exist
Tool Runner Log Collection
Commands run via exec run are executed through systemd-run, which captures stdout and stderr to log files under /var/log/easydblab/tools/. The OTel Collector's file_log/tools receiver watches this directory and ships log entries to Loki with the label source="tool-runner".
This provides automatic log capture for ad-hoc debugging tools (e.g., inotifywait, tcpdump, strace) run during investigations. Logs are queryable in Loki, which writes them to S3, so they outlive the cluster.
Key details:
- Log directory:
/var/log/easydblab/tools/ - Source attribute:
tool-runner(for filtering Loki queries:{source="tool-runner"}) - Foreground commands: Output displayed after completion, also logged to file
- Background commands (
--bg): Output logged to file only, tool runs as a systemd transient unit
YACE CloudWatch Scrape
YACE (Yet Another CloudWatch Exporter) runs on the control node and scrapes AWS CloudWatch metrics for services used by the cluster. It uses tag-based auto-discovery with the easy_cass_lab=1 tag. For EC2 instances, EBS volumes and OpenSearch domains it also requires the cluster's own ClusterId tag, so each cluster reports only its own resources. The S3 job finds every bucket tagged easy_cass_lab=1, because the account bucket is shared by every cluster.
YACE scrapes metrics for:
- S3 — bucket request/byte counts
- EBS — volume read/write ops and latency
- EC2 — instance CPU, network, disk
- OpenSearch — domain health, indexing, search metrics
EMR metrics are collected directly via OTel Collectors on Spark nodes (see Spark JVM Instrumentation above).
YACE exposes scraped metrics as Prometheus-compatible metrics on port 5001, which are then scraped by the OTel Collector and forwarded to Mimir. This replaces the previous CloudWatch datasource in Grafana with a Prometheus-based approach, giving dashboards access to CloudWatch metrics through PromQL queries against Mimir.
Resource Attributes
A resource attribute is declared once, when the SDK starts, and is stamped on every metric, span and log that process exports. The prometheus_remote_write exporter sets resource_to_telemetry_conversion: enabled: true, so each one also becomes a Prometheus label.
Telemetry from the CLI tool and cluster nodes carries these resource attributes:
service.name: service identifier (e.g.easy-db-lab,cassandra,cassandra-sidecar,spark-<job-name>). The collector maps it to the Prometheus labeljob.service.instance.id: instance identifier, mapped to the Prometheus labelinstance. Cassandra pins it to the hostname, because the agent default is a fresh UUID per JVM start and would mint a new series on every restart.service.version: application version (CLI tool only)host.name: hostnamenode_role: node type —db,app,control,spark-masterorspark-workercassandra_build: the build a Cassandra node is running, read from its owncurrentsymlink (Cassandra JVM only)
Configuration
The following environment variables are supported:
| Variable | Description | Default |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | OTLP endpoint | None (no export) |
OTEL_EXPORTER_OTLP_PROTOCOL | http/protobuf or grpc | http/protobuf |
OTEL_SERVICE_NAME | Override service name | easy-db-lab |
OTEL_RESOURCE_ATTRIBUTES | Additional resource attributes | None |
Additional standard OTel environment variables are supported by the agent. See the OpenTelemetry Java Agent documentation for details.
Example: Using with Jaeger
Start Jaeger with OTLP support:
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
Export traces to Jaeger:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
easy-db-lab up
View traces at http://localhost:16686
Example: Using with Grafana Tempo
If you have Grafana Tempo running with OTLP gRPC ingestion:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://tempo:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
easy-db-lab up
Troubleshooting
No Traces Appearing
- Verify
OTEL_EXPORTER_OTLP_ENDPOINTis set. Without it the launcher turns every exporter off. - Verify the endpoint is correct and reachable, and that the protocol matches the port:
http/protobuf(the default) on 4318,grpcon 4317 - Look for OpenTelemetry agent logs on startup (set
JAVA_OPTS=-Dotel.javaagent.debug=trueto enable debug logging)
No Traces in a Cluster's Tempo
The cluster's collector forwards spans to Tempo with the cluster's tenant in X-Scope-OrgID, and Tempo runs native multi-tenancy, so a query that sends no tenant reads nothing. Query through the cluster's Grafana (its Tempo datasource sends the tenant) or add -H 'X-Scope-OrgID: <tenant>' to a direct API call.
The collector scrapes its own telemetry into Mimir (job otel-collector, :8888). A Tempo exporter that is failing shows up there:
sum by (cluster, exporter) (rate(otelcol_exporter_send_failed_spans_total[5m]))
Tempo cuts a block at most every minute, so a trace is in S3 under tempo/<tenant>/ within about two minutes of arriving; before that Tempo serves it from its live store. At down, the collector is stopped first, and down then waits until Tempo holds no live trace and has uploaded every block.
High Latency
Traces are batched before export (default 1 second delay). This is normal and reduces overhead.
Spark Observability Debugging
Diagnostic commands for troubleshooting Spark observability on EMR nodes. These require SSH access to the EMR master node (ssh hadoop@<master-public-dns>).
OTel Collector
# Check collector is running
sudo systemctl status otel-collector
# View collector config (verify control node IP)
cat /opt/otel/config.yaml
# Test connectivity to control node collector
curl -s -o /dev/null -w '%{http_code}' http://<control-ip>:4318
Spark Configuration
# Verify -javaagent flags and OTel env vars are present
cat /etc/spark/conf/spark-defaults.conf
# Verify agent JARs exist
ls -la /opt/otel/opentelemetry-javaagent.jar
ls -la /opt/pyroscope/pyroscope.jar
Runtime Verification (while a job is running)
# Confirm agents are attached to Spark JVMs
ps aux | grep javaagent
Pyroscope API (from any node that can reach control0)
# List all label names
curl \
-H "Content-Type: application/json" \
-d '{
"end": '$(date +%s)000',
"start": '$(expr $(date +%s) - 3600)000'
}' \
http://localhost:4040/querier.v1.QuerierService/LabelNames
# List values for a specific label
curl \
-H "Content-Type: application/json" \
-d '{
"end": '$(date +%s)000',
"name": "hostname",
"start": '$(expr $(date +%s) - 3600)000'
}' \
http://localhost:4040/querier.v1.QuerierService/LabelValues
# Diff two profiles (compare workloads)
# POST to /querier.v1.QuerierService/Diff with left/right profile selectors
# See: left.labelSelector, right.labelSelector, profileTypeID, start/end
Grafana Explore Queries
# All metrics from Spark nodes
{node_role=~"spark-.*"}
# JVM metrics only
{node_role=~"spark-.*", __name__=~"jvm_.*"}
# List distinct JVM metric names
group({node_role=~"spark-.*", __name__=~"jvm_.*"}) by (__name__)
# Filesystem usage (raw)
system_filesystem_usage_bytes{state="used", node_role=~"spark-.*", mountpoint="/"}
JFR Format Reference
The Java Flight Recorder format is used by JVM-based profilers and supported by the Pyroscope Java integration.
When JFR format is used, query parameters behave differently:
formatshould be set tojfrnamecontains the prefix of the application name. Since a single request may contain multiple profile types, the final application name is created by concatenating this prefix and the profile type. For example, if you send cpu profiling data and set name tomy-app{}, it will appear in Pyroscope asmy-app.cpu{}unitsis ignored — actual units depend on the profile types in the dataaggregationTypeis ignored — actual aggregation type depends on the profile types in the data
Supported JFR Profile Types
cpu— samples from runnable threads onlyitimer— similar to cpu profilingwall— samples from any thread regardless of statealloc_in_new_tlab_objects— number of new TLAB objects createdalloc_in_new_tlab_bytes— size in bytes of new TLAB objects createdalloc_outside_tlab_objects— number of new allocated objects outside any TLABalloc_outside_tlab_bytes— size in bytes of new allocated objects outside any TLAB
JFR with Dynamic Labels
To ingest JFR data with dynamic labels:
- Use
multipart/form-dataContent-Type - Send JFR data in a form file field called
jfr - Send
LabelsSnapshotprotobuf message in a form file field calledlabels
message Context {
// string_id -> string_id
map<int64, int64> labels = 1;
}
message LabelsSnapshot {
// context_id -> Context
map<int64, Context> contexts = 1;
// string_id -> string
map<int64, string> strings = 2;
}
Where context_id is a parameter set in async-profiler.
Ingestion Examples
Simple profile upload:
printf "foo;bar 100\n foo;baz 200" | curl \
-X POST \
--data-binary @- \
'http://localhost:4040/ingest?name=curl-test-app&from=1615709120&until=1615709130'
JFR profile with labels:
curl -X POST \
-F jfr=@profile.jfr \
-F labels=@labels.pb \
"http://localhost:4040/ingest?name=curl-test-app&units=samples&aggregationType=sum&sampleRate=100&from=1655834200&until=1655834210&spyName=javaspy&format=jfr"
Future: Ad-hoc Profiling with async-profiler
async-profiler can capture JFR profiles on demand and upload them to Pyroscope with labels. This enables targeted profiling of specific Spark jobs or Cassandra operations to inspect exactly what is happening at the JVM level.
Common Issues
- No JVM metrics: Check
ps aux | grep javaagent— if-javaagentflags are missing,spark.driver.extraJavaOptionsmay be overridden at job submission time (replaces spark-defaults.conf entirely). - Collector retry errors at startup: Normal if the control node collector isn't ready yet. Should stabilize within a minute.
- Spark profiles missing hostname label:
PYROSCOPE_LABELSenv var must be set viaspark-envclassification withhostname=$(hostname -s).
Development Overview
Hello there. If you're reading this, you've probably decided to contribute to easy-db-lab or use the tools for your own work. Very cool.
Prerequisites
Install these locally before building:
- Java 21 or newer (Temurin) via SDKMAN — Java 21 is the project default, but the
build also works on newer JDKs (e.g. 25). The build compiles with whatever JDK you have
installed and always emits Java 21 bytecode, so artifacts still run on older JVMs.
One caveat: static analysis (
detekt, and therefore the fullchecktask) must run on JDK 21 — detekt 1.23.8 cannot run under a JDK 25 runtime. Building, running, and testing the application all work on JDK 25; CI runscheckon JDK 21. - Kotlin and Gradle (the Gradle wrapper
./gradlewis committed) - Docker — for TestContainers-backed integration tests
- mdbook + mdbook-admonish — for previewing documentation
The example Spark jobs and their Cassandra Analytics build live in the separate
spark-examples repository, so this
repo needs only a single JDK (21 or newer).
Local Configuration (.env)
bin/easy-db-lab automatically loads a .env file from the project root if one exists. This is the recommended way to set per-developer configuration without modifying committed scripts.
Setup
cp .env.example .env
# Edit .env with your values
.env is listed in .gitignore and will never be committed.
Supported Variables
| Variable | Required | Default | Description |
|---|---|---|---|
AWS_PROFILE | Yes (for e2e tests) | — | AWS credentials profile from ~/.aws/config |
EASY_DB_LAB_INSTANCE_TYPE | No | c5d.2xlarge | EC2 instance type for database nodes |
SIDECAR_IMAGE | No | ghcr.io/apache/cassandra-sidecar:latest | Custom Cassandra sidecar container image |
Example .env:
AWS_PROFILE=sandbox-admin
# SIDECAR_IMAGE=102382809497.dkr.ecr.us-west-2.amazonaws.com/rustyrazorblade/cassandra-sidecar
# EASY_DB_LAB_INSTANCE_TYPE=c5d.4xlarge
Variables already exported in your shell always take precedence over .env.
Building the Project
With the required tools installed:
./gradlew assemble
./gradlew test
Documentation Preview
Preview documentation locally with live reload:
cd docs
mdbook serve
Then open http://localhost:3000 in your browser.
Project Structure
easy-db-lab is broken into several subprojects:
- Docker containers (prefixed with
docker-) - Documentation (the manual you're reading now)
- Utility code for downloading artifacts
Architecture
The project follows a layered architecture:
Commands (PicoCLI) → Services → External Systems (K8s, AWS, Filesystem)
Layer Responsibilities
- Commands (
commands/): Lightweight PicoCLI execution units - Services (
services/,providers/): Business logic layer - Kernel (
kernel/): Types both layers share, such as thePicoCommandinterface. It keeps commands and services from depending on each other.
For more details, see the project's CLAUDE.md file.
Docker Development
Building Docker Containers
Each container is versioned and can be built locally using the following:
./gradlew :PROJECT-NAME:buildDocker
Where PROJECT-NAME is one of the subproject directories you see in the top level.
Setup
We recommend updating your local Docker service to use 8GB of memory. This is necessary when running dashboard previews locally. The preview is configured to run multiple Cassandra containers at once.
Available Docker Projects
Check the root project directory for subprojects prefixed with docker- to see available containerized components.
Local Testing
To test containers locally:
-
Build the container:
./gradlew :docker-cassandra:buildDocker -
Run the container:
docker run -it <image-name>
Memory Requirements
| Use Case | Recommended Memory |
|---|---|
| Single container development | 4GB |
| Dashboard preview (multiple containers) | 8GB |
| Full integration testing | 16GB |
Publishing
Pre-Release Checklist
- First check CI to ensure the build is clean and green
- Ensure the following environment variables are set:
DOCKER_USERNAMEDOCKER_PASSWORDDOCKER_EMAIL
Publishing Steps
Build and Upload
./gradlew buildAll uploadAll
Post-Release
After publishing, bump the version in build.gradle.kts.
Container Publishing
Containers are automatically published to GitHub Container Registry (ghcr.io) when:
- A version tag (v*) is pushed
- PR Checks pass on main branch
See .github/workflows/publish-container.yml for details.
Documentation
Documentation is automatically built and deployed via GitHub Actions when changes are pushed to the docs/ directory on the main branch.
Building a Cassandra Ref On Demand
The Build Cassandra Ref workflow turns an arbitrary Apache Cassandra git ref (branch, tag, or commit SHA) into two reusable artifacts in a single CI run:
- A GHCR Docker image that is a drop-in replacement for the Docker Official
cassandraimage — samedocker-entrypoint.shcontract,CASSANDRA_*environment variables, exposed ports (7000/7001/7199/9042/9160), and/var/lib/cassandradata volume. - A binary tarball attached to a per-build GitHub release, consumable by the
lab install path (
cassandra_versions.yaml+install_cassandra.sh).
This is the self-service alternative to a manual local build or waiting on the nightly tarball matrix. It does not change cluster provisioning or the AMI/Packer pipeline — the produced artifacts are inert until a consumer references them.
Triggering the workflow
Run it from the Actions tab (Build Cassandra Ref → Run workflow) or via the
CLI:
gh workflow run build-cassandra-ref.yml \
-f ref=cassandra-5.0
Inputs
| Input | Required | Default | Purpose |
|---|---|---|---|
ref | yes | — | Branch, tag, or commit SHA to build |
repo | no | apache/cassandra | Source repo (owner/name); set this to build a fork |
jdk | no | auto | Override the build JDK (e.g. 11, 17, 21) |
base_image | no | auto | Override the runtime JRE base image the container is built FROM |
When jdk / base_image are left blank they are auto-mapped from the resolved
Cassandra version: 4.x → JDK 11 / eclipse-temurin:11-jre,
5.0 → JDK 17 / eclipse-temurin:17-jre, 5.1+/trunk →
JDK 21 / eclipse-temurin:21-jre. Supply an override when a branch needs a
different JRE than its version maps to (for example when trunk's floor moves).
For 4.0/4.1 refs the build also passes -Duse.jdk11=true automatically: those
branches default their ant build to JDK 8 and need that flag to compile under
the auto-mapped JDK 11 (the same flag the Packer install path uses for 4.x). This
version → JDK / ant-flags / base-image / tag mapping lives in
.github/cassandra-image/resolve-build-plan.sh and is unit-tested by
resolve-build-plan.test.sh (run via ./gradlew testCassandraBuildPlan).
How it works
Three sequential jobs make "build fails → nothing published" structural:
- resolve — validates
refwithgit ls-remote(fails fast, naming the bad ref, if it does not exist), pins it to a full + short commit SHA, readsbase.versionfrombuild.xml, and computes the build JDK, runtime base image, image tags, tarball name, and release tag. The ref-resolution logic (ls-remote / raw-SHA fallback / fail-fast naming the bad ref) lives in.github/cassandra-image/resolve-ref.shand is unit-tested byresolve-ref.test.sh(run via./gradlew testCassandraResolveRef) with thegit ls-remotecall stubbed, so the fail-fast path is covered without a live run. - build — checks out the exact resolved SHA, sets up the build JDK, runs
ant artifactson the runner, and uploads the tarball as an intra-workflow artifact. - publish (
needs: [resolve, build]) — creates the per-build release with the tarball attached, builds the image from.github/cassandra-image/with the tarball + resolved base image, pushes the tags to GHCR, runs a CQL smoke test against the pushed image, and writes the run summary.
Authentication to GHCR uses the repository's GITHUB_TOKEN
(packages: write + contents: write) — no secrets are entered at trigger time.
Where the artifacts land
- Image:
ghcr.io/<owner>/<repo>/cassandra, tagged with both an immutablesha-<short>tag and a moving sanitized-ref tag. When the build is driven by the nightly matrix (build-cassandra-set.yml), each image also gets a stable version-label tag (5.0-HEAD,6.0-HEAD,trunk) that matches the tarball naming, giving a stable pull URL that always points at the latest build for that line. Thelatesttag is reserved for the main CLI image and is never produced here. - Tarball:
apache-cassandra-<version>-<short-sha>-bin.tar.gz, attached to a GitHub release taggedcassandra-<version>-<short-sha>.
The short SHA in every tag and asset name guarantees that distinct refs never overwrite each other's artifacts. Accumulated per-build releases are pruned manually.
The run summary surfaces the pullable image reference, the tarball download URL, and the resolved version + commit SHA.
Consuming the artifacts
Docker image
Drop it into a compose file in place of cassandra:<n> using the same
CASSANDRA_* environment variables:
docker pull ghcr.io/<owner>/<repo>/cassandra:sha-<short>
Tarball (lab install path)
Pin the release's tarball URL in packer/cassandra/cassandra_versions.yaml:
- version: "my-branch"
url: https://github.com/<owner>/<repo>/releases/download/cassandra-<version>-<short-sha>/apache-cassandra-<version>-<short-sha>-bin.tar.gz
java: "17"
python: "3.11.9"
install-cassandra-version downloads the URL and expects it to unpack into a
single top-level *cassandra* directory, which the ant artifacts tarball
satisfies. The same script runs at AMI bake time and at runtime, so you can skip
the rebuild entirely and install the tarball onto a running cluster:
easy-db-lab cassandra install my-branch \
--url https://github.com/<owner>/<repo>/releases/download/.../apache-cassandra-<version>-<short-sha>-bin.tar.gz \
--java 17
Image assembly
The image is assembled from repo-owned files in .github/cassandra-image/:
Dockerfile— reproduces the Docker Officialcassandralayout but injects the branch-built tarball (passed as theTARBALLbuild arg) onto theBASE_IMAGEJRE base instead of downloading and GPG-verifying a released tarball.docker-entrypoint.sh— a vendored, byte-identical copy of the official entrypoint. If upstream changes it, re-vendor in a deliberate PR; the CQL smoke test guards against contract drift.
Testing Guidelines
This document outlines the testing standards and practices for the easy-db-lab project.
Core Testing Principles
1. Use BaseKoinTest for Dependency Injection
All tests should extend BaseKoinTest to take advantage of automatic dependency injection setup and teardown.
class MyCommandTest : BaseKoinTest() {
// Your test code here
}
BaseKoinTest provides:
- Automatic Koin lifecycle management
- Core modules that are always mocked (AWS, SSH, OutputHandler)
- Ability to add test-specific modules via
additionalTestModules()
2. Use AssertJ for Assertions
Tests should use AssertJ assertions, not JUnit assertions. AssertJ provides more readable and powerful assertion methods.
// Good - AssertJ style
import org.assertj.core.api.Assertions.assertThat
assertThat(result).isNotNull()
assertThat(result.value).isEqualTo("expected")
assertThat(list).hasSize(3).contains("item1", "item2")
// Avoid - JUnit style
import org.junit.jupiter.api.Assertions.assertEquals
assertEquals("expected", result.value)
3. Create Custom Assertions for Non-Trivial Classes
When testing non-trivial classes, create custom AssertJ assertions to implement Domain-Driven Design in tests. This decouples business logic from implementation details and makes tests more maintainable during refactoring.
Custom Assertions Pattern
Custom assertions provide a fluent, domain-specific language for testing that improves readability and maintainability.
Example: Custom Assertion for a Domain Class
Here's a complete example showing how to create and use custom assertions:
// Domain class to be tested
data class CassandraNode(
val nodeId: String,
val datacenter: String,
val rack: String,
val status: NodeStatus,
val tokens: Int
)
enum class NodeStatus {
UP, DOWN, JOINING, LEAVING
}
// Custom assertion class
import org.assertj.core.api.AbstractAssert
class CassandraNodeAssert(actual: CassandraNode?) :
AbstractAssert<CassandraNodeAssert, CassandraNode>(actual, CassandraNodeAssert::class.java) {
companion object {
fun assertThat(actual: CassandraNode?): CassandraNodeAssert {
return CassandraNodeAssert(actual)
}
}
fun hasNodeId(nodeId: String): CassandraNodeAssert {
isNotNull
if (actual.nodeId != nodeId) {
failWithMessage("Expected node ID to be <%s> but was <%s>", nodeId, actual.nodeId)
}
return this
}
fun isInDatacenter(datacenter: String): CassandraNodeAssert {
isNotNull
if (actual.datacenter != datacenter) {
failWithMessage("Expected datacenter to be <%s> but was <%s>", datacenter, actual.datacenter)
}
return this
}
fun hasStatus(status: NodeStatus): CassandraNodeAssert {
isNotNull
if (actual.status != status) {
failWithMessage("Expected status to be <%s> but was <%s>", status, actual.status)
}
return this
}
fun isUp(): CassandraNodeAssert {
return hasStatus(NodeStatus.UP)
}
fun isDown(): CassandraNodeAssert {
return hasStatus(NodeStatus.DOWN)
}
fun hasTokenCount(tokens: Int): CassandraNodeAssert {
isNotNull
if (actual.tokens != tokens) {
failWithMessage("Expected token count to be <%s> but was <%s>", tokens, actual.tokens)
}
return this
}
}
// Usage in tests
import CassandraNodeAssert.Companion.assertThat
@Test
fun `test cassandra node configuration`() {
val node = CassandraNode(
nodeId = "node1",
datacenter = "dc1",
rack = "rack1",
status = NodeStatus.UP,
tokens = 256
)
// Fluent assertions with domain language
assertThat(node)
.hasNodeId("node1")
.isInDatacenter("dc1")
.isUp()
.hasTokenCount(256)
}
Project-Wide Assertions Helper
Create a central assertions class to provide access to all custom assertions:
// MyProjectAssertions.kt
object MyProjectAssertions {
// Cassandra domain assertions
fun assertThat(actual: CassandraNode?): CassandraNodeAssert {
return CassandraNodeAssert(actual)
}
fun assertThat(actual: Host?): HostAssert {
return HostAssert(actual)
}
fun assertThat(actual: TFState?): TFStateAssert {
return TFStateAssert(actual)
}
// Add more domain assertions as needed
}
Then import statically in tests:
import com.rustyrazorblade.easydblab.assertions.MyProjectAssertions.assertThat
@Test
fun `test complex scenario`() {
val node = createTestNode()
val host = createTestHost()
// All domain assertions available through single import
assertThat(node).isUp()
assertThat(host).hasPrivateIp("10.0.0.1")
}
Benefits of Custom Assertions
- Domain-Driven Design: Tests use business language, not implementation details
- Refactoring Safety: Changes to class internals don't break test logic
- Readability: Tests read like specifications
- Reusability: Common assertions are centralized
- Maintainability: Single place to update assertion logic
- Type Safety: Compile-time checking of assertion methods
When to Create Custom Assertions
Create custom assertions for:
- Domain entities (e.g.,
Host,TFState,CassandraNode) - Complex value objects with multiple properties
- Classes that appear in multiple test scenarios
- Any class where you find yourself writing repetitive assertion code
Testing Best Practices
-
Test Names: Use descriptive names with backticks
@Test fun `should start cassandra node when status is DOWN`() { } -
Test Structure: Follow Arrange-Act-Assert pattern
@Test fun `test node startup`() { // Arrange val node = createTestNode(status = NodeStatus.DOWN) // Act val result = nodeManager.startNode(node) // Assert assertThat(result).isUp() } -
Mock External Dependencies: Always mock AWS, SSH, and other external services
class MyTest : BaseKoinTest() { override fun additionalTestModules() = listOf( module { single { mockRemoteOperationsService() } } ) } -
Test Edge Cases: Include tests for error conditions and boundary cases
-
Keep Tests Focused: Each test should verify one specific behavior
Testing Interactive Commands with TestPrompter
Commands that require user input (like profile setup) can be tested deterministically using TestPrompter. This test utility replaces the real Prompter interface and returns predefined responses.
Basic Usage
class MyCommandTest : BaseKoinTest() {
private lateinit var testPrompter: TestPrompter
override fun additionalTestModules() = listOf(
module {
single<Prompter> { testPrompter }
}
)
@BeforeEach
fun setup() {
// Configure responses - keys can be exact matches or partial matches
testPrompter = TestPrompter(
mapOf(
"email" to "test@example.com",
"region" to "us-west-2",
"AWS Access Key" to "AKIAIOSFODNN7EXAMPLE",
)
)
}
@Test
fun `should collect user credentials`() {
// Run command that prompts for input
val command = SetupProfile()
command.call()
// Verify prompts were called
assertThat(testPrompter.wasPromptedFor("email")).isTrue()
assertThat(testPrompter.wasPromptedFor("region")).isTrue()
}
}
Response Matching
TestPrompter supports two matching strategies:
- Exact match: The question text matches a key exactly
- Partial match: The question text contains the key (case-insensitive)
val prompter = TestPrompter(
mapOf(
// Exact match - only matches "email" exactly
"email" to "test@example.com",
// Partial match - matches any question containing "AWS Profile"
"AWS Profile" to "my-profile",
)
)
Sequential Responses for Retry Testing
For testing retry logic (e.g., credential validation failures), use addSequentialResponses():
@Test
fun `should retry on invalid credentials`() {
testPrompter = TestPrompter()
// First call returns invalid credentials, second returns valid ones
testPrompter.addSequentialResponses(
"AWS Access Key",
"invalid-key", // First attempt
"AKIAVALIDKEY123" // Second attempt (after retry)
)
testPrompter.addSequentialResponses(
"AWS Secret",
"invalid-secret",
"valid-secret-key"
)
val command = SetupProfile()
command.call()
// Verify the command handled retry correctly
val callLog = testPrompter.getCallLog()
val accessKeyCalls = callLog.filter { it.question.contains("Access Key") }
assertThat(accessKeyCalls).hasSize(2)
}
Verifying Prompt Behavior
TestPrompter records all prompt calls for verification:
@Test
fun `should not prompt for credentials when using AWS profile`() {
testPrompter = TestPrompter(
mapOf(
"AWS Profile" to "my-profile", // Non-empty = use profile auth
)
)
val command = SetupProfile()
command.call()
// Verify credential prompts were skipped
assertThat(testPrompter.wasPromptedFor("Access Key")).isFalse()
assertThat(testPrompter.wasPromptedFor("Secret")).isFalse()
// Check detailed call log
val callLog = testPrompter.getCallLog()
assertThat(callLog).anyMatch { it.question.contains("email") }
}
TestPrompter API Reference
| Method | Description |
|---|---|
prompt(question, default, secret) | Returns configured response or default |
addSequentialResponses(key, vararg responses) | Configure different responses for retry scenarios |
getCallLog() | Returns list of all prompt calls with details |
wasPromptedFor(questionContains) | Check if any prompt contained the given text |
clear() | Reset call log and sequential state |
PromptCall Data Class
Each recorded call contains:
question: The prompt question textdefault: The default value offeredsecret: Whether input was masked (for passwords)returnedValue: The value that was returned
Additional Resources
Spark Development
The example Spark jobs — the bulk writers and the Spark Cassandra Connector
read/write examples — have moved to their own repository:
spark-examples (locally ../spark-examples).
That repo is where you:
- build and test the job modules (
common,bulk-writer-sidecar,bulk-writer-s3-iam,connector-writer,connector-read-write), - build Apache Cassandra Analytics (the bulk-writer modules depend on it), and
- publish the shadow (fat) job jars.
This repository keeps the spark CLI commands that provision EMR and run jobs:
easy-db-lab spark init/submit/status/logs/jobs/down. They submit a pre-built job
jar to EMR — download the published jar from the
spark-examples releases,
then point spark submit --jar <path> at it. See the
Spark user guide for provisioning EMR, submitting jobs, and
debugging failed steps.
Log Infrastructure
This page documents the centralized logging infrastructure in easy-db-lab, including OTel for log collection and Loki for storage and querying. See Logs (Loki) for the user guide.
Architecture Overview
┌──────────────────────────────────────────────────────────────────┐
│ All Nodes │
├──────────────────────────────────────────────────────────────────┤
│ /var/log/system logs │ /mnt/db1/container-logs/ (NVMe) │
│ cassandra gc.log* │ K8s pod stdout/stderr │
│ journald │ (symlinked from /var/log/pods) │
│ │ │
└──────────────┬────────────────────────────┬──────────────────────┘
│ │
▼ ▼
┌────────────────────────────────────────┐
│ OTel Collector (DaemonSet) │ ┌──────────────────┐
│ file_log/system file_log/containers │◀─────│ EMR Spark JVMs │
│ file_log/cassandra (GC log) │ OTLP │ (OTel Java Agent│
│ + OTLP receiver │ │ v2.25.0) │
└───────────────────┬─────────────────────┘ └──────────────────┘
│
┌─────────────────────────────────┼────────────────────────────┐
│ Control Node │ │
├─────────────────────────────────┼────────────────────────────┤
│ ▼ │
│ ┌──────────────────┐ │
│ │ Loki │ │
│ │ (:3100) │ │
│ └────────┬─────────┘ │
└─────────────────────────────┼──────────────────────────────────┘
│
▼
┌──────────────────┐
│ easy-db-lab │
│ logs query │
└──────────────────┘
Components
OTel Collector DaemonSet
The OpenTelemetry Collector runs on all nodes as a DaemonSet, collecting logs from four separate pipelines:
logs/local— host file-based logs:- System logs:
/var/log/**/*.log,/var/log/messages,/var/log/syslog(excludes container log paths) - Tool runner logs:
/var/log/easydblab/tools/*.log - Cassandra JVM GC log:
/mnt/db1/cassandra/logs/gc.log*(source="cassandra-gc"). The JVM writes it directly, so the Java agent never sees it.
- System logs:
logs/containers— K8s pod stdout/stderr from all running pods, enriched with Kubernetes metadata (pod name, namespace, container name, kit label). Automatically covers any K8s-native kit without per-kit configuration. Logs are stored on NVMe at/mnt/db1/container-logs/(symlinked from/var/log/pods) to keep the boot volume free.logs/otlp— logs pushed via OTLP: Cassandra's application logs from the OpenTelemetry Java agent in the Cassandra JVM (service_name="cassandra"), Fluent Bit's journald lines, and remote applications (e.g. EMR Spark JVMs). This is the only path for Cassandra'ssystem.loganddebug.log; the collector does not tail those files, so each line is stored once.- systemd journal — collected via a separate Fluent Bit DaemonSet (
fluent-bit-journald)
All pipelines forward to Loki's OTLP endpoint on the control node, with the cluster's tenant in the X-Scope-OrgID header. Loki writes chunks and index to the account bucket under loki/ (chunks under the tenant, the index under loki/index/). A chunk is flushed once it spans an hour, which also sets a stream's out-of-order window to 30 minutes.
Spark OTel Java Agent (EMR)
When EMR Spark jobs are running, the Spark driver and executor JVMs are instrumented with the OpenTelemetry Java Agent (v2.25.0) via an EMR bootstrap action. The agent auto-instruments the JVMs and exports logs via OTLP to the control node's OTel Collector.
Logs appear in Loki with a service_name label like spark-<job-name>, making it easy to filter logs for specific Spark jobs: {service_name=~"spark-.+"}. The OTel Collector on each EMR node labels every line source="emr" and node_role (spark-master or spark-worker). spark logs selects one job's lines by service_name; no log line carries the EMR step id.
The data flow is: Spark JVM → OTel Java Agent → OTLP → OTel Collector (EMR node) → OTel Collector (control node) → Loki.
Loki
Loki runs on the control node as a single process and provides:
- Log storage in S3 that outlives the cluster; nothing is compacted away or deleted
- The LogQL query language
- An HTTP API for querying (port 3100); every request needs the
X-Scope-OrgIDtenant header
Querying Logs
Using the CLI
# Query all logs from last hour
easy-db-lab logs query
# Filter by source (`cassandra` is the application log over OTLP, `cassandra-gc` the JVM GC log)
easy-db-lab logs query --source cassandra
easy-db-lab logs query --source cassandra-gc
easy-db-lab logs query --source journald
# Filter by host
easy-db-lab logs query --source cassandra --host db0
# Filter by systemd unit
easy-db-lab logs query --source journald --unit docker.service
# Search for text
easy-db-lab logs query --grep "OutOfMemory"
# Time range and limit
easy-db-lab logs query --since 30m --limit 500
# Raw LogQL query, sent unchanged
easy-db-lab logs query -q '{service_name="cassandra", host_name="db0"}'
Labels and Structured Metadata
Stream labels (indexed; use them in the {...} selector):
| Label | Description |
|---|---|
cluster | <name>-<clusterId>; every stream carries it |
source | Log source: cassandra-gc, system, tool-runner, journald, annotation |
host_name | Hostname (db0, app0, control0) |
node_role | db, app or control |
service_name | OTel service name; unknown_service when the sender sets none |
k8s_pod_name | Name of the pod that emitted the log (container logs) |
k8s_namespace_name | Kubernetes namespace (container logs) |
k8s_container_name | Container name within the pod (container logs) |
Structured metadata (every other attribute; match it with a label filter after the selector, such as | systemd_unit="docker.service"):
| Field | Description |
|---|---|
k8s_app_instance | Value of the app.kubernetes.io/instance pod label — identifies the kit (e.g. presto, tidb) |
systemd_unit | systemd unit name (journald) |
trace_id, span_id | Trace context, for OTLP logs from instrumented JVMs |
Troubleshooting
No logs appearing
-
Check Loki is running:
kubectl get pods -l app.kubernetes.io/name=loki -
Check OTel Collector is running:
kubectl get pods | grep otel -
Verify the cluster-config ConfigMap exists:
kubectl get configmap cluster-config -o yaml
Connection errors
The logs query command uses the internal SOCKS5 proxy to connect to Loki. If you see connection errors:
- Ensure the cluster is running:
easy-db-lab status - The proxy is started automatically when needed
- Check that control node is accessible:
ssh control0 hostname
Ports
| Port | Service | Location |
|---|---|---|
| 3100 | Loki HTTP API | Control node |
| 9098 | Loki gRPC | Control node |
Pyroscope Configuration Parameters
Reference for Pyroscope server configuration. Source: Grafana Pyroscope docs.
How Configuration Works
Pyroscope is configured via a YAML file (-config.file flag) or CLI flags. CLI flags take precedence over YAML values. Environment variables can be used with -config.expand-env=true using ${VAR} or ${VAR:-default} syntax.
View the effective config at the /api/v1/status/config HTTP API endpoint (Pyroscope 2.3.1; /config returns 404).
Key Configuration Sections
Top-Level
# Modules to load. 'all' enables single-binary mode.
[target: <string> | default = "all"]
api:
[base-url: <string> | default = ""]
Server
HTTP on port 4040 (default), gRPC on port 9095 (default).
server:
[http_listen_address: <string> | default = ""]
[http_listen_port: <int> | default = 4040]
[grpc_listen_port: <int> | default = 9095]
[graceful_shutdown_timeout: <duration> | default = 30s]
[http_server_read_timeout: <duration> | default = 30s]
[http_server_write_timeout: <duration> | default = 30s]
[http_server_idle_timeout: <duration> | default = 2m]
[log_format: <string> | default = "logfmt"] # logfmt or json
[log_level: <string> | default = "info"] # debug, info, warn, error
[grpc_server_max_recv_msg_size: <int> | default = 4194304]
[grpc_server_max_send_msg_size: <int> | default = 4194304]
[grpc_server_max_concurrent_streams: <int> | default = 100]
PyroscopeDB (Local Storage)
pyroscopedb:
# Directory for local storage
[data_path: <string> | default = "./data"]
# Max block duration
[max_block_duration: <duration> | default = 1h]
# Row group target size (uncompressed)
[row_group_target_size: <int> | default = 1342177280]
# Partition label for symbols
[symbols_partition_label: <string> | default = ""]
# Disk retention: minimum free disk (GiB)
[min_free_disk_gb: <int> | default = 10]
# Disk retention: minimum free percentage
[min_disk_available_percentage: <float> | default = 0.05]
# How often to enforce retention
[enforcement_interval: <duration> | default = 5m]
# Disable retention enforcement
[disable_enforcement: <boolean> | default = false]
Storage (Object Storage Backend)
Supported backends: s3, gcs, azure, swift, filesystem, cos.
storage:
[backend: <string> | default = ""]
[prefix: <string> | default = ""]
s3:
[endpoint: <string> | default = ""]
[region: <string> | default = ""]
[bucket_name: <string> | default = ""]
[secret_access_key: <string> | default = ""]
[access_key_id: <string> | default = ""]
[insecure: <boolean> | default = false]
[signature_version: <string> | default = "v4"]
[bucket_lookup_type: <string> | default = "auto"]
# Our deployment leaves access_key_id/secret_access_key empty to use
# the default AWS SDK credential chain (env vars, IMDS).
sse:
[type: <string> | default = ""] # SSE-KMS or SSE-S3
[kms_key_id: <string> | default = ""]
[kms_encryption_context: <string> | default = ""]
gcs:
[bucket_name: <string> | default = ""]
[service_account: <string> | default = ""]
azure:
[account_name: <string> | default = ""]
[account_key: <string> | default = ""]
[container_name: <string> | default = ""]
filesystem:
[dir: <string> | default = "./data-shared"]
Distributor
distributor:
[pushtimeout: <duration> | default = 5s]
ring:
kvstore:
[store: <string> | default = "memberlist"] # consul, etcd, inmemory, memberlist, multi
Ingester
ingester:
lifecycler:
ring:
kvstore:
[store: <string> | default = "consul"]
[heartbeat_timeout: <duration> | default = 1m]
[replication_factor: <int> | default = 1]
[num_tokens: <int> | default = 128]
[heartbeat_period: <duration> | default = 5s]
Querier
querier:
# Time after which queries go to storage instead of ingesters
[query_store_after: <duration> | default = 4h]
Compactor
compactor:
[block_ranges: <list of durations> | default = 1h0m0s,2h0m0s,8h0m0s]
[data_dir: <string> | default = "./data-compactor"]
[compaction_interval: <duration> | default = 30m]
[compaction_concurrency: <int> | default = 1]
[deletion_delay: <duration> | default = 12h]
[downsampler_enabled: <boolean> | default = false]
Limits (Per-Tenant)
limits:
# Ingestion rate limit (MB/s)
[ingestion_rate_mb: <float> | default = 4]
[ingestion_burst_size_mb: <float> | default = 2]
# Label constraints
[max_label_name_length: <int> | default = 1024]
[max_label_value_length: <int> | default = 2048]
[max_label_names_per_series: <int> | default = 30]
# Profile constraints
[max_profile_size_bytes: <int> | default = 4194304]
[max_profile_stacktrace_samples: <int> | default = 16000]
[max_profile_stacktrace_depth: <int> | default = 1000]
# Series limits
[max_global_series_per_tenant: <int> | default = 5000]
# Query limits
[max_query_lookback: <duration> | default = 1w]
[max_query_length: <duration> | default = 1d]
[max_flamegraph_nodes_default: <int> | default = 8192]
[max_flamegraph_nodes_max: <int> | default = 1048576]
# Retention
[compactor_blocks_retention_period: <duration> | default = 0s]
# Ingestion time bounds
[reject_older_than: <duration> | default = 1h]
[reject_newer_than: <duration> | default = 10m]
# Relabeling
[ingestion_relabeling_rules: <list of Configs> | default = []]
[sample_type_relabeling_rules: <list of Configs> | default = []]
Self-Profiling
self_profiling:
# Disable push profiling in single-binary mode
[disable_push: <boolean> | default = false]
[mutex_profile_fraction: <int> | default = 5]
[block_profile_rate: <int> | default = 5]
Memberlist (Gossip)
memberlist:
[bind_port: <int> | default = 7946]
[join_members: <list of strings> | default = []]
[gossip_interval: <duration> | default = 200ms]
[gossip_nodes: <int> | default = 3]
[leave_timeout: <duration> | default = 20s]
Tracing
tracing:
[enabled: <boolean> | default = true]
Multi-Tenancy
# Require X-Scope-OrgId header; false = use "anonymous" tenant
[multitenancy_enabled: <boolean> | default = false]
Embedded Grafana
embedded_grafana:
[data_path: <string> | default = "./data/__embedded_grafana/"]
[listen_port: <int> | default = 4041]
[pyroscope_url: <string> | default = "http://localhost:4040"]
Port Summary
| Service | Port | Protocol |
|---|---|---|
| HTTP API | 4040 | HTTP |
| gRPC | 9095 | gRPC |
| Memberlist gossip | 7946 | TCP/UDP |
| Embedded Grafana | 4041 | HTTP |
Relevant to Our Deployment
Our Pyroscope deployment (configuration/pyroscope/PyroscopeManifestBuilder.kt, image grafana/pyroscope:2.3.1) uses:
- Pure v2 storage —
architecture_storage: v2. The default,v1-v2-dual, writes every profile twice. - Native multi-tenancy —
multitenancy_enabled: true. Every writer sends the cluster's tenant inX-Scope-OrgID; the Grafana datasource sends it on every query. - Account bucket,
pyroscope/root — v2 lays out its ownsegments/and blocks under it. It is the one observability root the cluster may delete under: v2 compaction removes the segments it merged into a block. Nothing is written to the per-cluster data bucket. S3 auth uses IAM role credentials via IMDS (no explicit keys). - No deletion by age —
metastore.index.cleanup_interval: 0sturns off the index cleanup that drives v2 retention, andlimits.retention_period: 0smeans data is never deleted (the v2.2+ default is 31 days). v2 compaction stays on; it merges this cluster's segments into blocks and keeps their data. - Metastore on the node —
metastore.data_dir,metastore.raft.dirandmetastore.raft.snapshots_dirlive under/data, a hostPath at/mnt/db1/pyroscope. v2 finds blocks only through this index and cannot rebuild it from S3, so it must survive pod restarts. - Single-binary mode (
target: all), port 4040 for the HTTP API and the UI. With multi-tenancy on, the UI asks for a tenant on first visit (Enter a Tenant ID); enter the cluster's tenant (init --tenant, defaultdefault). It is kept per browser in localStorage (pyroscope:tenantID) and sent asX-Scope-OrgID; a link cannot preselect it. See Profiling. - Config values substituted at build time via TemplateService (
__ACCOUNT_BUCKET__,__AWS_REGION__,__PROFILES_S3_PREFIX__). - Profiles received from: the Cassandra JFR shipper (
/ingest?format=jfr), the Java agent (stress jobs, sidecar, Spark, Trino, Presto), and the Alloy eBPF agent (all nodes).
Kit Development Guide
This guide covers how to build a kit for easy-db-lab — from the kit.yaml structure
through lifecycle phases, metrics collection, hooks, and Grafana dashboard provisioning.
What is a Kit?
A kit is a self-contained package of configuration and scripts that installs, starts, stops, and optionally backs up a piece of software on the cluster. Examples: ClickHouse, Presto, OpenSearch.
Each kit lives under src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/.
After easy-db-lab kit install <name> runs, the kit directory is copied to the cluster's working
directory. The <name> start, <name> stop, etc. subcommands then drive it.
Directory Layout
Everything for a kit lives in src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/:
kit.yaml— required: the kit definitionbin/— optional: shell scripts (start.sh.template,stop.sh.template, ...) for phases that have no typed steps. Everybin/*.shbecomes a command, so put only phase scripts here.lib/,pod/and other directories — optional: shared script code, scripts that run inside a pod, query files. They are copied into the kit directory and never become commands (clickhouse-benchmarkkeeps its sourced functions inlib/and its pod-side scripts inpod/).dashboards/— optional: Grafana dashboard JSON files<name>.yaml.template— optional: K8s manifest templates applied bymanifeststepsMETRICS.mdandmetrics-catalog.json— recommended for any kit that exposes metrics
kit.yaml Reference
name: myworkload
type: db # db | app: the node pool the kit needs; install fails if it has no nodes
description: Short description shown in help text
version: "1.0.0"
collision-check: false # true = refuse a second install and a start while running; or a map per phase (see Collision check)
metrics: # a list; see Metrics section
- type: scrape # scrape | java-agent | helm-native
port: 9090
runtime:
type: helm # see Runtime section
release: myworkload
namespace: default
endpoints:
- name: "HTTP UI"
node-type: app # "app" or "db"
port: 8080
type: http # http | https | jdbc | native | cql | postgresql | mysql | kafka
scheme: "" # optional: used for JDBC URLs (jdbc type only)
path: "" # optional: appended to URL (http/https/jdbc only)
database: "" # optional: logical database name (postgresql and mysql types)
args:
- flag: --workers
variable: WORKERS
description: "Number of workers"
type: int # string | int | float | boolean | kit-ref | extension
capability: sql # optional: for kit-ref, a capability the target must declare
endpoints: [native] # optional: for kit-ref, endpoint types the target must declare
required: false
default: "${APP_NODE_COUNT}"
hooks:
post-workload-start:
script: bin/update-catalogs.sh
workloads: [] # optional: only fire when these kits start
post-workload-stop:
script: bin/update-catalogs.sh
install: [] # steps to run on `easy-db-lab kit install <name>`
start: [] # steps to run on `easy-db-lab <name> start`
stop: [] # steps to run on `easy-db-lab <name> stop`
uninstall: [] # steps to run on `easy-db-lab <name> uninstall`
backup: [] # steps to run on `easy-db-lab <name> backup <backup-name>`
restore: [] # steps to run on `easy-db-lab <name> restore <backup-name>`
Lifecycle Phases
| Phase | Trigger | What happens after success |
|---|---|---|
install | easy-db-lab kit install <name> | Kit directory written to working dir |
start | easy-db-lab <name> start | Metrics registered, dashboards installed, hooks fired |
stop | easy-db-lab <name> stop | Waits for the runtime's pods to go, metrics deregistered, kit dropped from runningKits, hooks fired |
uninstall | easy-db-lab <name> uninstall or kit uninstall <name> | Kit directory deleted from working dir. A kit still running has its stop phase run first, stop wait included; if that fails, the uninstall fails without running its steps. After the uninstall steps the kit is released as stop releases it (metrics deregistered, dropped from runningKits, hooks fired) |
backup | easy-db-lab <name> backup <name> | BACKUP_NAME env var set to first argument |
restore | easy-db-lab <name> restore <name> | BACKUP_NAME env var set to first argument |
If no typed steps are defined for a phase and a matching script exists in bin/ (e.g.
bin/start.sh), the script is executed instead.
Step Types
All phases use the same set of typed steps.
helm-repo
Adds a Helm chart repository.
- type: helm-repo
name: altinity
url: https://docs.altinity.com/clickhouse-operator/
helm
Installs or upgrades a Helm chart.
- type: helm
chart: altinity/altinity-clickhouse-operator
release: clickhouse-operator
namespace: kube-system
version: "1.2.3" # optional: pin chart version
values: # optional: inline values
replicaCount: "3"
values-file: values.yaml # optional: path relative to kit dir
helm-uninstall
Uninstalls a Helm release.
- type: helm-uninstall
release: clickhouse-operator
namespace: kube-system
When one operator serves several kit instances, keep-while-any names a resource type and keeps
the release while any object of that type is left in any namespace, reporting what still uses it
(Kit.HelmReleaseKept). Plain postgres and every postgres-<extension> share the CNPG operator:
- type: helm-uninstall
release: cnpg-operator
namespace: cnpg-system
keep-while-any: clusters.postgresql.cnpg.io
manifest
Applies a K8s manifest template. The template file must exist in the kit's resource directory. Template variables (see below) are substituted before applying.
- type: manifest
template: clickhouseinstallation.yaml
manifest-url
Fetches and applies a manifest from a URL.
- type: manifest-url
url: https://example.com/operator.yaml
kustomize
Applies a kustomize configuration from a URL.
- type: kustomize
url: https://github.com/example/repo/config/default
namespace
Creates a Kubernetes namespace (no-op if it already exists).
- type: namespace
name: monitoring
wait
Waits for a K8s resource to reach a condition.
- type: wait
kind: Deployment
name: my-operator
namespace: kube-system
condition: Available # default: Available
timeout: 300s # default: 300s
delete
Deletes K8s resources, either one object by name or every object a label selector matches.
Both forms run kubectl on the control node. A step that mixes the two forms, or has neither, is
rejected when kit.yaml loads.
By name — kind and name are required:
- type: delete
kind: ClickHouseInstallation
name: clickhouse
namespace: default # default: default
ignore-not-found: true # default: true
By label — selector and kinds are required:
- type: delete
kinds: [deployment, replicaset, pod, service, configmap]
selector: easydblab/kit=memcached
namespace: default # default: default
Every object of the listed kinds in namespace that selector matches is deleted. When nothing
matches, the step succeeds and prints nothing. A failed cluster query fails the step. selector,
kinds and namespace accept ${VAR} interpolation. Use this form in stop and uninstall for
kits that label every object they create, instead of a shell step.
platform-pvs
Creates persistent volumes on cluster nodes using the platform substrate.
- type: platform-pvs
node-type: db # default: db
count: 3 # optional: defaults to node count
storage-size: 10Ti # optional: PV capacity; defaults to the STORAGE_SIZE variable
if-set: EXTSTORE_SIZE # optional: skip the step unless this kit variable is non-empty
if-set makes the volume optional: the memcached kit creates its extstore PV only when
--extstore-size is set. Pair it with platform-pvs-delete in uninstall, which is a no-op when
no PV was created.
Every kit's PVs share one storage class, and each PV is labelled app.kubernetes.io/name=<kit>.
Every claim the kit creates — its own, a StatefulSet's, or one an operator generates — must select
that label (selector.matchLabels, or Strimzi's selector) or name the PV in volumeName.
Otherwise it can bind another kit's free PV.
configmap
Creates or updates a K8s ConfigMap.
- type: configmap
name: my-config
namespace: default
data:
key: value
label
Applies labels to cluster nodes.
- type: label
node-type: db
labels:
kit: presto
exec
Runs a command inside a running pod.
- type: exec
pod: my-pod-name
namespace: default
command: ["clickhouse-client", "--query", "SELECT 1"]
shell
Runs an inline shell script. The script runs locally (not on the remote node) with cluster variables injected as environment variables.
- type: shell
script: |
kubectl wait --for=condition=Ready pods \
-l app=myworkload \
--timeout=300s
How local scripts reach the cluster
Shell steps in install: and in phases, phase scripts in bin/, and hook scripts all run on your
machine with the same two settings:
KUBECONFIGis the absolute path of the workspacekubeconfig. The process does not start if that file does not exist.<workspace>/binis first onPATH. easy-db-lab writes a wrapper there for each ofkubectl,helm,cilium,curl,skopeo,k9s,ssh,scp,sftpandrsyncbefore the process starts. Thessh,scp,sftpandrsyncwrappers use the workspacesshConfig, so a step can runssh db0 ...by the node's alias; they never use the tunnel.
On a SOCKS cluster (no Tailscale), each of the other wrappers sends its call through the SOCKS5 tunnel that easy-db-lab keeps open, and the tool reaches the private cluster addresses. On a Tailscale cluster the wrappers run the tool unchanged, and it connects directly. The CLI starts or restarts the tunnel before the first step runs, so a kit never has to.
The kubectl, helm, cilium and k9s wrappers always set KUBECONFIG to the workspace kubeconfig for their call, in place of any inherited value, on SOCKS and Tailscale clusters. A bare <workspace>/bin/kubectl therefore works with no setup, from a step or from your own shell. If the workspace has no kubeconfig file, the wrapper names the missing file and exits 1. The curl and skopeo wrappers leave KUBECONFIG unchanged.
Indirect calls go through the wrappers too, because they look the tool up on PATH: timeout 60 kubectl ..., echo ns | xargs kubectl get, sh -c 'helm list -A', and a script in the kit's own
bin/ that calls kubectl.
Some things are not wrapped:
- Tools other than those six,
awsincluded, get no proxy settings and connect directly. - A call that bypasses
PATHis not wrapped: an absolute path such as/usr/local/bin/kubectl,env -i kubectl, or a script that sets its ownPATH=/usr/bin:/bin. Call the tool by its name and keep the inheritedPATH. - A wrapped tool sends all of its traffic through the tunnel, public URLs included.
helm repo addof a public chart repository works, but it is slower than a direct connection.
If a wrapper says that no SOCKS tunnel is recorded, run easy-db-lab socks start in the workspace.
Environment Variables
All scripts and shell steps receive the following environment variables:
| Variable | Description |
|---|---|
CLUSTER_NAME | Name of the cluster |
KUBECONFIG | Absolute path to the workspace kubeconfig file |
PATH | <workspace>/bin (the tool wrappers) followed by the inherited PATH |
CONTROL_HOST | Public IP of the control node |
CONTROL_HOST_PUBLIC | Public IP of the control node |
CONTROL_HOST_PRIVATE | Private IP of the control node |
DB_NODE_COUNT | Number of database nodes |
APP_NODE_COUNT | Number of app/stress nodes |
DB_NODE_IPS | Comma-separated private IPs of database nodes |
APP_NODE_IPS | Comma-separated private IPs of app nodes |
BUCKET_NAME | S3 data bucket name |
ACCOUNT_BUCKET | S3 account-level bucket name |
REGION | AWS region |
KIT_NAME | Name of this kit |
STORAGE_SIZE | Storage size (from --size arg, if used) |
STORAGE_CLASS_WFC | K8s storage class name with WaitForFirstConsumer binding |
RUNNING_KITS | Comma-separated names of currently running kits |
EASY_DB_LAB_EXEC | Path to the easy-db-lab binary |
BACKUP_NAME | First positional argument (backup and restore phases only) |
EDL_KIT_LIB | Absolute path of the shared kit shell library, <workspace>/bin/edl-kit-lib.sh (see Shared shell library) |
Args declared in kit.yaml under args: are also injected using their variable name. For
example, --workers with variable: WORKERS becomes $WORKERS. An arg the user leaves out that
has no default is set to an empty string, never the string null, and it replaces a variable of
the same name that the operator's shell exports; test it with [ -n "${WORKERS}" ]. A boolean
arg takes an optional value: a bare --flag means true, and --flag false or --flag=false
turns off a flag whose default is true (--help shows --flag[=<value>]).
Addressing per-node services (e.g. the Cassandra Sidecar)
Some cluster services run as a hostNetwork DaemonSet — one instance per db node, addressable
at <db-node-private-ip>:<port> with no cluster-wide load-balanced Service (the Cassandra Sidecar
on port 9043 is the canonical example). Each instance is node-local: a request only affects the
node it fronts. A kit that needs to reach every such instance (for example, creating a per-node
snapshot before a distributed read) must fan the call out across all nodes rather than wiring a
single node's URI.
Use DB_NODE_IPS — the comma-separated list of all db-node private IPs — as the enumeration source:
# Create a Sidecar snapshot on every db node, not just one
IFS=',' read -ra DB_IPS <<< "$DB_NODE_IPS"
for ip in "${DB_IPS[@]}"; do
curl -sf -XPUT "http://${ip}:9043/api/v1/keyspaces/${KS}/tables/${TBL}/snapshots/${SNAP}"
done
Wiring only one node's address (e.g. db0) leaves the other nodes without the resource, so any work
that lands on db1/db2 fails. DB_NODE_IPS is part of the stable variable contract above, so this
pattern does not depend on topology discovery from within the kit.
Default values in kit.yaml can reference any of the variables above using ${VAR} syntax:
default: "${APP_NODE_COUNT}"
A single arg cannot produce two derived forms — there is no transform syntax. If a kit needs
the same value in different shapes (e.g. Flink uses the image tag 1.20 and the flinkVersion
enum v1_20), hardcode the derived form in the manifest template and document the lockstep
coupling with the arg.
Metrics
The metrics field is a list of entries, each with a type, telling easy-db-lab how the kit's
metrics reach the collector. When start succeeds, scrape entries are registered. When stop
succeeds, they are deregistered.
scrape — Prometheus endpoint
The kit exposes a Prometheus endpoint and the OTel collector DaemonSet scrapes it.
metrics:
- type: scrape
port: 9090 # required
path: /metrics # optional, default: /metrics
job: myworkload # optional, default: the kit name; unique within the kit
pod-selector: "app=myworkload" # optional: scrape the matching pods directly (see below)
username: "" # optional: basic-auth user
Without a pod-selector, every collector scrapes localhost:<port>, so the port must be reachable
on the node: a NodePort, or a hostPort on the pod. With a pod-selector, the collector finds the
matching pods through Kubernetes pod discovery and each node's collector scrapes only the pods on
its own node, on the pod IP and port (the container port). Every pod gets its own instance
label, and no metrics NodePort is needed. See kits/memcached/kit.yaml.
Use a pod-selector. A job without one runs on every collector in the DaemonSet, whatever
node the workload is on: a NodePort answers on every node, so a single-instance kit reports one
duplicate series per node, each with a different collector's hostname as instance, and a hostPort
answers only on its own node, so every other node reports the job down. Every built-in kit scrapes
by pod discovery, and NodePortKitScrapeTest fails if one declares a static scrape. A kit that
runs several instances side by side (postgres and postgres-<extension>) names its own with
${KIT_NAME} in the selector, which is filled in with the instance name at start.
Before adding any reporter plumbing, check whether the workload's image already ships the
metrics reporter — a pre-staged plugin directory, a built-in endpoint, or a bundled jar
already on the classpath. Many JVM images do (e.g. the official Flink image pre-stages the
Prometheus reporter at /opt/flink/plugins/metrics-prometheus/; you only set the reporter
config — no plumbing). Only add an initContainer or volume to stage a reporter jar if it is
genuinely absent.
Never mount an emptyDir over a directory the image already populates — it hides what the
image staged there. (We hit a crash copying the Flink reporter jar from an assumed path that
did not exist, while the emptyDir overlay masked the real pre-staged plugin dir.)
Registration creates one K8s ConfigMap per scrape entry, named easydblab-metrics-<kit>-<job> and
labelled easydblab.com/workload-metrics=true. OtelSyncService reads these ConfigMaps and
regenerates the OTel collector config with a scrape job for each. Scraped metrics receive the
entry's job and cluster=<cluster-name> labels automatically.
A hostPort scrape assumes one metrics-exposing pod per node per kit. A kit that exposes
metrics from multiple pods (e.g. Flink serves :9249 on the JobManager and every TaskManager)
will collide on the hostPort if two land on the same node. Prefer a pod-selector; otherwise
spread them with podAntiAffinity and keep replicas below the node count.
java-agent — OpenTelemetry Java Agent
For JVM kits. The OTel Java agent JAR at /usr/local/otel/opentelemetry-javaagent.jar
is attached to the JVM process.
metrics:
- type: java-agent
service-name: myworkload
The agent pushes OTLP to the collector on the pod's own node; nothing in the collector config
changes. Mount /usr/local/otel from the host read-only, load the jar through the workload's
JVM options, and set OTEL_SERVICE_NAME (it becomes job), HOST_IP from status.hostIP, and
OTEL_EXPORTER_OTLP_ENDPOINT=http://$(HOST_IP):4318. See kits/neo4j/statefulset.yaml.template.
Never write a double underscore in a .template file. __NAME__ is the install-time
placeholder syntax, so a literal __ (e.g. Neo4j's NEO4J_server_bolt_advertised__address)
swallows the text up to the next __. Put such a name in kit.yaml instead — the Neo4j kit's
start step writes it as a ConfigMap key and the pod loads it with envFrom.
helm-native — Built-in telemetry
The kit ships its own metrics pipeline via Helm values. No OTel config change is needed.
metrics:
- type: helm-native
Documenting Metrics
Every kit that exposes metrics should include a METRICS.md file listing the available
metrics, their labels, and usage notes. This is the reference for anyone building dashboards.
See kits/presto/METRICS.md for an example.
Exporting the Metrics Catalog
metrics-catalog.json lists every metric a running kit sends to Mimir under
job="<kit>". It is exported from a live cluster by bin/export-workload-metrics, and the
committed copy is what METRICS.md and the kit's dashboards are built from.
The catalog is compact: series holds one entry per distinct metric name, and each entry's
labels maps a label key to the sorted distinct values seen for it, at most 20 per key
(MAX_LABEL_VALUES in the script). Per-pod identity labels (k8s_pod_uid, k8s_pod_name,
instance, service_instance_id) are dropped, so the file does not grow with the number of
pods.
Label keys present on every series of the export (the collector's infrastructure labels such as
cluster, host_name, job and k8s_*) are listed once, in the top-level common_labels map,
in the same key → values form and with the same cap. They are left out of every entry, so an
entry's labels holds only the labels specific to that metric, and is {} when it has none:
{
"workload": "clickhouse",
"exported_at": "2026-09-23T12:00:00Z",
"common_labels": {
"host_name": ["db0", "db1", "db2"],
"job": ["clickhouse"]
},
"series": [
{
"name": "ClickHouseMetrics_Query",
"labels": { "service_name": ["clickhouse"] }
},
{
"name": "target_info",
"labels": {}
}
]
}
A series' full label set is its entry's labels plus common_labels.
The transform lives in bin/metrics-catalog.jq, so a full per-series export (the older format,
one entry per series with a single value per label) can be converted offline with the same code
the script uses:
jq -L bin --argjson cap 20 \
'include "metrics-catalog"; del(.series) + (.series | compact_catalog($cap))' \
old-catalog.json > metrics-catalog.json
Only series with a sample in the last five minutes are exported, so series left behind by pods that have since been replaced do not end up in the catalog. Export while the kit is running and being scraped.
The script sources env.sh, so run it from the cluster workspace (the directory holding
env.sh, state.json and kubeconfig), calling it by its path in your checkout:
cd <cluster-workspace>
<checkout>/bin/export-workload-metrics <kit>
It writes <cluster-workspace>/<kit>/metrics-catalog.json, not the kit's resource directory.
That <kit>/ directory is the kit's working copy, and <kit> uninstall deletes it, so copy the
file into the source tree before uninstalling:
cp <cluster-workspace>/<kit>/metrics-catalog.json \
<checkout>/src/main/resources/com/rustyrazorblade/easydblab/kits/<kit>/metrics-catalog.json
Hooks
Hooks let one kit react when another kit starts or stops. The hook script runs in the context of the declaring kit, not the triggering one.
hooks:
post-workload-start:
script: bin/update-catalogs.sh
workloads: [cassandra] # optional: only fire when cassandra starts
post-workload-stop:
script: bin/update-catalogs.sh
When easy-db-lab cassandra start completes, easy-db-lab scans every installed kit
directory, finds those with a matching post-workload-start hook, and fires them.
If workloads is empty or omitted, the hook fires for any kit start/stop. Hooks retry up
to 3 times with exponential backoff (1s, 2s, 4s) on failure.
Use case: Presto registers its Cassandra catalog after Cassandra starts. Its
post-workload-start hook runs bin/update-catalogs.sh which re-registers catalogs for all
currently running data sources.
Grafana Dashboards
After a successful start, easy-db-lab installs dashboards into Grafana via the HTTP API.
Auto-discovery (default): any .json files in dashboards/ are installed automatically.
Files are installed in alphabetical order into a Grafana folder named after the kit.
Explicit list (optional): declare dashboard paths in kit.yaml to control selection or
order:
dashboards:
- path: dashboards/overview.json
- path: dashboards/queries.json
name: Query Details
A kit with an extension arg (postgres) runs as several instances side by side (postgres,
postgres-duckdb). A dashboard with extension: is installed only by the instance created with
that extension; one without is installed by every instance. Grafana uids are global, so an
instance other than the kit's own installs each dashboard under the uid suffixed with its
extension (postgres-overview-duckdb), and links between the dashboards it installs point at
its own copies; it never moves another instance's dashboard into its folder. A link to another
extension's dashboard points at the uid that extension's instance installs it under
(/d/postgres-duckdb becomes /d/postgres-duckdb-duckdb), in every instance.
dashboards:
- path: dashboards/postgres.json
- path: dashboards/duckdb.json
extension: duckdb
Dashboard JSON files should:
- Use
"uid": "<kit>-kit"to make re-installs idempotent - Declare the
clustervariable:label_values(up, cluster)on${metrics_datasource}, multi-select, with "All" andallValue.+. Leave itscurrentempty. - Filter every metrics and logs selector by
cluster=~"$cluster", including the queries of other variables such as an instance list:label_values(cnpg_collector_up{cluster=~"$cluster"}, instance). Clusters of one tenant share the store, so a selector without the filter mixes every cluster that ran at the same time. In a binary expression, each side needs its own filter. - Keep
clusterin every aggregation (sum by (cluster, instance),sum by (cluster) (...)), and show it in every legend by its short name: wrap the query inlabel_replace(<expr>, "cluster_name", "$1", "cluster", "(.+-[0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")and write the legend as{{cluster_name}} {{instance}}. The short name keeps the first 8 characters of the id (test-1a2b3c4d), so two clusters that share a name still differ. Two selected clusters then draw as separate series, each named. A field override matches the prefixed legend (byRegexp^(.* )?reads$, notbyNamereads). A table keeps its legends and shows the cluster as a column: itsorganizehidesclusterand renamescluster_nameto "Cluster". - Declare a datasource picker for each signal they use:
metrics_datasource("type": "datasource", "query": "prometheus", label "Metrics"), andlogs_datasource(loki) ortraces_datasource(tempo) when they use logs or traces. Leave each picker'scurrentempty. - Name every datasource through its picker, keeping the
type:{ "type": "prometheus", "uid": "${metrics_datasource}" }. Never namemimir,lokiortempodirectly, not even inside an Explore link;pyroscopehas no picker and stays as it is. - Pass each picker and
clusterthe dashboard declares on every/d/link, as${metrics_datasource:queryparam}(or an explicitvar-<name>=), and no${name:queryparam}for a variable it does not declare. - Include
"tags": ["<kit>", "kit"]
DashboardDatasourceVariablesTest checks the picker and link rules on every kit dashboard, ClusterFilterTest checks the cluster variable and filter, and SeriesClusterTest checks the aggregations and legends. dashboards/CLAUDE.md in the repository has the details.
On start, the dashboards go through the same install-time pass as the core dashboards (DashboardDefaults): each picker selects the cluster's own tenant and cluster defaults to the current cluster, so a dashboard opens on the cluster with no selection by hand. Dashboards are installed with overwrite: true, so re-running start never duplicates them.
Collision check
collision-check is a boolean or a map of phase to boolean. true guards both install and
start; false (the default) guards neither. The map guards only the phases set to true:
collision-check:
start: true # refuse start while running
install: false # a second install overwrites the scaffold without --force
install and start are the only phases with a collision check; any other key is rejected when
the kit is loaded. Each guarded phase fails with an error event and exits non-zero:
kit installrefuses when the kit's scaffold directory already exists and is not empty. The event isInstall.CollisionDetected. Pass--forceto overwrite the scaffold.<kit> startrefuses when the workload the kit'sruntimeblock declares is already in the cluster: pods matching the runtimeselectorin its namespace, or, for ahelmruntime, the helm release and then any pod labelledapp.kubernetes.io/instance=<release>in the runtime's namespace. Pods that are still terminating count too, so a pod left cleaning up by a manualkubectl deleteblocksstartuntil it is gone. The event isKit.CollisionDetected, naming the objects it found, and no start step runs. Run<kit> stopfirst.
<kit> stop is not a collision check, and it applies to every kit that declares a runtime,
guarded or not. Once its steps succeed, it waits until none of the runtime's pods is left,
terminating pods included. For a helm runtime that means the pods labelled
app.kubernetes.io/instance=<release>; the release itself does not count, because a kit may stop
by scaling its release to zero (Presto, Trino) and keep it. Deleting a StatefulSet, Deployment or
operator resource, or scaling a Deployment to zero, returns before its pods have terminated, so
this wait is what makes stop return only once the workload is gone, and stop followed by
start work. It gives up after 5 minutes with a Kit.StopIncomplete error event naming what is
left. A failed cluster query during the wait is retried; if the last look still fails, stop
exits non-zero with a Kit.StopUnverified error event carrying the cause. A kit with no runtime
block does not wait.
<kit> uninstall waits the same way after its steps succeed — a helm uninstall (Presto, Trino)
also returns while the release's pods are still terminating — and reports a pod that outlives the
wait with the same events, naming the uninstall phase; the kit directory is then kept.
The runtime must therefore name what start creates and stop removes. A runtime pointing at
something the install phase creates, such as an operator's helm release, would refuse every
start after a successful install. A kit with no runtime block is looked up by the pod label
app.kubernetes.io/name=<kit>.
Runtime
The runtime field tells easy-db-lab how to find running pods for status checks, log tailing, and
the start collision check.
runtime:
type: helm # helm | deployment | statefulset | pods
release: presto # for helm: the Helm release name
namespace: default
selector: "app=presto" # for pods: label selector
name: presto # for deployment/statefulset: resource name
Profiling
Every kit running on the cluster is automatically profiled at the system level by the Grafana Alloy eBPF DaemonSet — no per-kit setup required. This covers all processes including non-JVM ones like ClickHouse.
JVM kits get deeper profiling via the Pyroscope Java agent
(/usr/local/pyroscope/pyroscope.jar, pre-installed on every node by packer). This enables
method-level CPU, allocation, and lock contention profiles — much richer than eBPF.
Wiring up the Java agent for a K8s kit
The agent JAR lives on the host at /usr/local/pyroscope. Mount it into each JVM container via a
hostPath volume, then inject JAVA_TOOL_OPTIONS via a kubectl patch in the start phase.
CONTROL_HOST_PRIVATE and CLUSTER_NAME are available as environment variables at runtime.
PYROSCOPE_OPTS="-javaagent:/usr/local/pyroscope/pyroscope.jar \
-Dpyroscope.application.name=<kit> \
-Dpyroscope.server.address=http://${CONTROL_HOST_PRIVATE}:4040 \
-Dpyroscope.format=jfr \
-Dpyroscope.profiler.event=cpu \
-Dpyroscope.profiler.alloc=512k \
-Dpyroscope.profiler.lock=10ms"
PATCH=$(jq -n \
--arg opts "${PYROSCOPE_OPTS} -Dpyroscope.labels=cluster=${CLUSTER_NAME},component=<component>" \
'{spec:{template:{spec:{
volumes:[{name:"pyroscope-agent",hostPath:{path:"/usr/local/pyroscope"}}],
containers:[{name:"<container-name>",env:[{name:"JAVA_TOOL_OPTIONS",value:$opts}],
volumeMounts:[{name:"pyroscope-agent",mountPath:"/usr/local/pyroscope",readOnly:true}]}]
}}}}')
kubectl patch deployment <deployment-name> --namespace default --type=strategic -p="$PATCH"
Use component=coordinator, component=worker, etc. in the pyroscope.labels to distinguish
multiple JVM processes belonging to the same kit. Profiles appear in Grafana's Pyroscope
datasource under service_name=<kit>.
Operator-managed (CRD) kits
For kits whose pods are managed by an operator (the Flink operator, CNPG, etc.), a kubectl patch does not work — the operator reconciles its pods and reverts the patch. Put the JVM
options in the operator's own resource instead (e.g. a FlinkDeployment's flinkConfiguration
or podTemplate, or the CR's pod template). For these kits the per-kit Java agent is optional
anyway — system-level eBPF profiling already covers them, so it can be deferred.
See docs/user-guide/profiling.md for how to access profiles in Grafana, profile types, and
the full observability data flow.
Shared shell library
A kit script that runs pods sources the shared kit shell library instead of writing its own pod
placement, waits and gauge push. The CLI packages it as configuration/edl-kit-lib.sh, writes it to
<workspace>/bin/edl-kit-lib.sh, and sets EDL_KIT_LIB to its absolute path for every kit process
(phase scripts, shell steps and hooks). Source it at the top of the script:
#!/usr/bin/env bash
set -euo pipefail
. "${EDL_KIT_LIB:?run this through easy-db-lab, which sets EDL_KIT_LIB}"
Sourcing it changes no set option, trap or working directory, and defines only edl_ functions
and EDL_ variables. The functions read KIT_NAME, KUBECONFIG and CONTROL_HOST_PRIVATE from
the environment. A function that fails prints why to stderr and exits the script.
| Function | What it does |
|---|---|
edl_fail <message> | Prints the message to stderr and exits 1 |
edl_labels <role> | Prints easydblab/kit=${KIT_NAME},easydblab/role=<role> |
edl_resource_name <role> | Prints ${KIT_NAME}-<role>-$(date +%s), a unique pod or ConfigMap name |
edl_require_app_node <kit> | Fails with <kit> needs an app node (type=app); this cluster has none when no node is labelled type=app |
edl_pod_overrides [<configmap> <mount-path> <container>] | Prints the JSON for kubectl run --overrides=<json> --override-type=strategic: nodeSelector: {type: app}, and a mount of the ConfigMap when one is given. kubectl run names the container after the pod |
edl_create_configmap <name> <role> <key>=<path>... | Creates a labelled ConfigMap from files in one call; fails on a missing or empty file, or more than 1,000,000 bytes in all |
edl_active_pods <role> | Prints this instance's pods of that role that have not ended |
edl_wait_for_pod_running <pod> | Returns once the pod leaves Pending (Running, Succeeded or Failed); fails at once on an image pull error with the reason and the pod's events, and after 10 minutes otherwise, and deletes the pod first so it does not block the next run |
edl_follow_pod_logs <pod> <handler> | Streams the pod's log, calling the handler function with each line, and resumes a cut stream while the pod runs |
edl_wait_for_pod_exit <pod> | Waits, with no limit, for the pod to end and prints its exit code |
edl_push_gauges <service> <name> <value>... | Pushes OTLP gauges labelled kit=${KIT_NAME} to the collector; a non-numeric value is sent as 0; a failed push never fails the script and prints one warning per script |
Every wait fails with pod <name> was deleted when the pod is gone, for example after a stop in
another terminal. A typical run:
edl_require_app_node my-bench
POD="$(edl_resource_name bench-run)"
kubectl run "${POD}" --image="${IMAGE}" --restart=Never \
--labels="$(edl_labels bench-run)" \
--overrides="$(edl_pod_overrides)" --override-type=strategic \
--command -- my-bench --threads "${THREADS}"
edl_wait_for_pod_running "${POD}"
edl_follow_pod_logs "${POD}" handle_line
EXIT_CODE="$(edl_wait_for_pod_exit "${POD}")"
[ "${EXIT_CODE}" = 0 ] || edl_fail "The run failed with exit code ${EXIT_CODE}"
Kit scripts run on the operator's machine, where #!/usr/bin/env bash can be the macOS bash 3.2.
The library, and every script that sources it, follows these rules:
- No associative arrays,
mapfile,${v,,}orwait -n. - Never expand an array that can be empty as
"${arr[@]}"underset -u: bash before 4.4 fails withunbound variable. Add to an array only when there is at least one element to pass, or build the list another way. - Assign a command substitution on its own line (
X="$(f)"), never withlocal X="$(f)"or inside aniftest, so a failure inside it still ends the script underset -e.
The library tests (./gradlew testKitShellLib) and the kit script tests also run under /bin/bash
when it is bash 3.x.
The library is written once into each workspace and never rewritten, so a workspace keeps the
library it was first given. When you edit the library, use a fresh workspace, or delete
<workspace>/bin/edl-kit-lib.sh, before you try the change; the next kit process writes the new
copy. Report parsers, argument checks and SQL belong in the kit's own lib/, not in the library.
Bench Kits — cross-kit targeting
A bench kit benchmarks a running database kit. It declares a kit-ref arg that the user
populates with --target <kit-name> at install time. The framework then reads the target
kit's declared endpoints and injects them as TARGET_* environment variables into every phase
script.
Declaring a kit-ref arg
args:
- flag: --target
variable: TARGET
type: kit-ref
capability: sql # optional: a capability the target must declare
endpoints: [native, http] # optional: endpoint types the target must declare
description: "Name of the running database kit to benchmark"
required: true
type: kit-ref tells easy-db-lab three things:
- The installed kit directory is named
<kit>-<target>instead of<kit>, allowing multiple simultaneous instances (e.g.sysbench-clickhouseandsysbench-tidb). - At install, the target must already be installed and must provide what the arg asks for.
kit installrefuses, before it writes anything, a target that lacks thecapabilityor any type inendpoints, and names every missing type in one message (for exampleTarget kit 'tidb' does not have the 'native' endpoint required by clickhouse-benchmark). An unknown type inendpointsfails whenkit.yamlis parsed. With neither field, the target is not checked. - In every phase,
KitEndpointResolverreads the target kit'skit.yamlendpoints and injects them asTARGET_*environment variables.
TARGET_* injection rules
The variables injected depend on what endpoints the target kit declares:
| Endpoint type | Variables injected |
|---|---|
jdbc | TARGET_JDBC_URL, TARGET_JDBC_USER, TARGET_JDBC_DRIVER |
postgresql | TARGET_PG_HOST, TARGET_PG_PORT, TARGET_PG_USER, TARGET_PG_DATABASE |
mysql | TARGET_MYSQL_HOST, TARGET_MYSQL_PORT, TARGET_MYSQL_USER, TARGET_MYSQL_DATABASE |
native | TARGET_NATIVE_HOST, TARGET_NATIVE_PORT, TARGET_NATIVE_USER, TARGET_NATIVE_DATABASE |
http | TARGET_HTTP_URL |
kafka | TARGET_KAFKA_BOOTSTRAP |
https and cql endpoints inject nothing. Every *_USER variable comes from the target's sql
capability user, and is empty when the target declares no sql capability.
If the target kit directory does not exist or its kit.yaml is unreadable, no TARGET_*
variables are injected and no error is raised (fail-safe).
Wiring a TARGET_* endpoint into an application pod
TARGET_* variables are available in the kit's shell scripts — not inside running pods.
To make the address available to an application, write a ConfigMap from the start script
and reference it in your deployment.
In bin/start.sh.template:
# Write the target endpoint into a ConfigMap the application pod reads
kubectl create configmap my-app-config \
--from-literal=KAFKA_BOOTSTRAP_SERVERS="$TARGET_KAFKA_BOOTSTRAP" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f my-app-deployment.yaml
In my-app-deployment.yaml:
spec:
containers:
- name: my-app
envFrom:
- configMapRef:
name: my-app-config
The application sees KAFKA_BOOTSTRAP_SERVERS (or any other variable) as a normal
environment variable. The same pattern works for any TARGET_* variable — just change
the key name to match what your application expects.
Delete the ConfigMap in bin/stop.sh.template to keep the cluster clean:
kubectl delete configmap my-app-config --ignore-not-found
Wire protocol endpoint types
To expose a PostgreSQL or MySQL wire protocol port, use the corresponding endpoint type:
endpoints:
- name: "PostgreSQL wire"
node-type: db
port: 5432
type: postgresql
database: "mydb" # logical database name
- name: "MySQL wire"
node-type: db
port: 4000
type: mysql
database: "test" # logical database name
The database field is also available on jdbc endpoints to store the logical database name
separately from the JDBC URL path.
SQL capability
Database kits that expose a SQL interface should declare the sql capability:
capabilities:
- type: sql
user: default # default username
driver-class: com.clickhouse.jdbc.ClickHouseDriver # JDBC driver (optional)
The user and driver-class fields are used when constructing TARGET_JDBC_USER and
TARGET_JDBC_DRIVER for bench kits targeting this database.
Making an external kit targetable by bench kits
If you are writing an external kit that exposes a SQL interface and want bench kits like
sysbench to be able to target it, add three things to your kit.yaml:
1. A sql capability — declares the default username and (for JDBC) the driver class:
capabilities:
- type: sql
user: root
driver-class: com.mysql.cj.jdbc.Driver # omit if you don't expose JDBC
2. One or more wire protocol endpoints — the endpoint type determines which TARGET_*
variables the bench kit receives. Declare one per protocol your database supports:
endpoints:
- name: "MySQL wire"
node-type: app # or "db" — must match the node pool your kit runs on
port: 3306
type: mysql
database: "mydb" # the logical database name bench kits should connect to
- name: "PostgreSQL wire"
node-type: app
port: 5432
type: postgresql
database: "mydb"
- name: "JDBC"
node-type: app
port: 3306
type: jdbc
scheme: mysql
path: "/mydb?useSSL=false"
You only need to declare the protocols your database actually supports. A MySQL-compatible
database only needs the mysql endpoint; it does not need to also declare jdbc unless
you want JDBC bench tools to target it.
3. NodePort service on the declared port — the bench kit pod runs inside the same
Kubernetes cluster and connects via the app or db node's private IP. Make sure your kit's
start phase creates a NodePort service exposing the port you declared in the endpoint.
Once these three pieces are in place, a user can install sysbench (or any other bench kit
that declares capability: sql) against your kit:
easy-db-lab kit install sysbench --target <your-kit-name>
easy-db-lab sysbench-<your-kit-name> prepare
easy-db-lab sysbench-<your-kit-name> start
The capability check at install time will verify your kit exposes sql before writing
any files, so misconfigured targets fail immediately with a clear error.
Exposing Client Ports
A kit exposes its client ports through a NodePort Service, in the range 30000-32767, on a
fixed port that no other kit uses. Never use hostPort or hostNetwork for a client port:
hostPortonly works when the CNI chains theportmapplugin, which is a property of the datapath rather than of the kit.hostNetworkpins the pod to one host, and a port clash with another process shows up only as a CrashLoop.
A NodePort is reachable on every node's private IP, so declare the endpoint in kit.yaml with
the NodePort as its port and the node type that kit info should resolve. The existing kits
follow this: postgres serves on 30432 and clickhouse on 30123.
apiVersion: v1
kind: Service
metadata:
name: mykit-nodeport
labels:
easydblab/kit: mykit
spec:
type: NodePort
selector:
app: mykit
ports:
- name: client
port: 1234
targetPort: 1234
nodePort: 30999 # a port no other kit uses; Hubble UI owns 31234
Adding a New Kit
- Create
src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/kit.yaml - Add lifecycle steps — start with
startandstopat minimum - Add a
metricsblock if the kit exposes Prometheus metrics - Add a
METRICS.mddocumenting the available metrics - Create
dashboards/<name>.jsonwith panels for the key metrics - Run
easy-db-lab kit install <name>to scaffold the working directory - Test
<name> startand<name> stopagainst a real cluster
No Kotlin code is required. The install and kit runner commands register dynamically from
the kit.yaml files at startup.