Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Install easy-db-lab
  2. Set up your profile - Run easy-db-lab profile setup
  3. 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

RequirementDetails
Operating SystemmacOS or Linux
JavaJDK 21 or later
DockerRequired 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

Tip

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:

  1. Collect your email and AWS credentials
  2. Validate your AWS access
  3. Create necessary AWS resources (key pair, IAM roles, Packer VPC)
  4. 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:

PromptDescriptionDefault
EmailUsed to tag AWS resources for ownership(required)
AWS RegionRegion for your clustersus-west-2
AWS Profile nameNamed AWS profile to authenticate with — press Enter to enter credentials manually instead(manual)
AWS Access KeyYour AWS access key ID (only asked if no profile name was given)(required)
AWS Secret KeyYour AWS secret access key (only asked if no profile name was given)(required)
AxonOps OrgOptional: AxonOps organization name(skip)
AxonOps KeyOptional: AxonOps API key(skip)
SSH transportHow 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

Note

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

Tip

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.

Warning

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

Important

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.

Note

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:

PolicyPurpose
EC2Create/manage EC2 instances, VPCs, security groups; open SSM Session Manager sessions to cluster instances
IAMCreate instance roles and profiles
EMRCreate Spark clusters (optional)
OpenSearchCreate OpenSearch domains (optional)
CompactorRun 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

For teams with multiple users, we recommend creating managed policies attached to an IAM group:

  1. Create an IAM group (e.g., "EasyDBLabUsers")
  2. Create the managed policies from the JSON output
  3. Attach all policies to the group
  4. Add users to the group

Warning

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

OptionDescriptionDefault
--archCPU architecture (AMD64 or ARM64)AMD64
--regionAWS 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

Note

Building an AMI takes approximately 10-15 minutes. Docker must be installed and running.

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

VariableDescriptionDefault
EASY_DB_LAB_USER_DIROverride configuration directory~/.easy-db-lab
EASY_DB_LAB_PROFILEUse a named profiledefault
EASY_DB_LAB_INSTANCE_TYPEDefault database instance type for initi4i.xlarge
EASY_DB_LAB_STRESS_INSTANCE_TYPEDefault application (stress) instance typec6id.2xlarge
EASY_DB_LAB_AMIOverride 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.

Prerequisites

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.

OptionDescriptionDefault
--db.count (alias --db, --cassandra, -c)Number of database instances3
--app.count (alias --app, --stress, -s)Number of application instances0
--db.instance-type (alias --instance, -i)Database instance typei4i.xlarge
--app.instance-type (alias --stress-instance, -si)Application instance typec6id.2xlarge
--azs, -zAvailability zones (e.g., a,b,c)all available
--ebs.typeEBS volume type (NONE, gp2, gp3, io1, io2)NONE
--ebs.sizeEBS volume size in GB256
--ebs.iopsEBS IOPS (gp3 only)0
--ebs.throughputEBS throughput (gp3 only)0
--untilWhen instances can be deletedtomorrow
--tagCustom tags (key=value, repeatable)-
--vpcUse existing VPC ID-
--upAuto-provision after initfalse
--cleanRemove existing config firstfalse

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 d suffix (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.type for 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

  1. Configures account S3 bucket with cluster prefix
  2. Creates VPC with public subnets in your availability zones
  3. Provisions EC2 instances in parallel
  4. Waits for SSH availability
  5. Configures K3s cluster on all nodes
  6. Writes SSH config and environment files

up fails fast

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

OptionDescription
--no-setup, -nSkip 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.

Choosing a database

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:

SettingDescriptionDefault
num_tokensVirtual nodes per instance4
concurrent_readsMax concurrent read operations64
concurrent_writesMax concurrent write operations64
endpoint_snitchNetwork topology snitchEc2Snitch

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

Warning

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

TaskCommand
Initialize clustereasy-db-lab init <name> [options]
Provision infrastructureeasy-db-lab up
Initialize and provisioneasy-db-lab init <name> --up
Select Cassandra versioneasy-db-lab cassandra use <version>
Apply configurationeasy-db-lab cassandra update-config
Start Cassandraeasy-db-lab cassandra start
Stop Cassandraeasy-db-lab cassandra stop
Restart Cassandraeasy-db-lab cassandra restart
Check cluster statusssh db0 nodetool status
Download configeasy-db-lab cassandra download-config
Destroy clustereasy-db-lab down
Display hostseasy-db-lab hosts
Clean local fileseasy-db-lab clean

Next Steps

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:

VersionJavaNotes
3.08Legacy support
3.118Stable release
4.011First 4.x release
4.111Current LTS
5.011Latest stable (recommended)
5.0-HEAD17Nightly build from 5.0 branch
6.0-HEAD21Nightly build from 6.0 branch
trunk21Development 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:

  1. Sets the active Cassandra version on all nodes
  2. Downloads current configuration files locally
  3. 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

Auto-Managed Settings — Do Not Include

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 IP
  • seed_provider / seeds — configured automatically based on cluster topology
  • hints_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 11
    

    Or 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. --python defaults to 3.11.9.

Useful details:

  • --hosts db0,db1 installs 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 ant build 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.

Credentials in --url

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

OpenSearch

AWS OpenSearch can be provisioned as a managed domain for full-text search and log analytics.

Commands

CommandDescription
opensearch startCreate an OpenSearch domain
opensearch statusCheck domain status
opensearch stopDelete 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

OptionDescriptionDefault
--spark.enableEnable Spark EMR clusterfalse
--spark.master.instance.typeMaster node instance typem5.xlarge
--spark.worker.instance.typeWorker node instance typem5.xlarge
--spark.worker.instance.countNumber of worker nodes3

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

OptionDescriptionDefault
--master.instance.typeMaster node instance typem5.xlarge
--worker.instance.typeWorker node instance typem5.xlarge
--worker.instance.countNumber of worker nodes3

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

OptionDescriptionRequired
--jarPath to JAR file (local path or s3:// URI)Yes
--main-classMain class to executeYes
--confSpark configuration (key=value), can be repeatedNo
--envEnvironment variable (KEY=value), can be repeatedNo
--argsArguments for the Spark applicationNo
--waitWait for job completionNo
--nameJob 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 to aws emr describe-step)
  • --logs - Download the step's stderr.gz from the EMR log path in S3, save it under logs/<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:

FilesNode
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

ImplementationTransportUse CasePrerequisites
Direct (Sidecar)DIRECTLow latency, direct network path, single DCSidecar running, network connectivity
S3 StagingS3_COMPATLarge datasets, multi-dcS3 bucket, IAM permissions
ConnectorCQLStandard writes, compatibilityCassandra 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:

  1. Spark generates SSTables from source data
  2. SSTables are streamed directly to Sidecar endpoints
  3. 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:

  1. Spark generates SSTables and bundles them into ZIPs with manifests
  2. Bundles are uploaded to S3 bucket (provided by easy-db-lab)
  3. Spark pushes import notification to Sidecar REST API
  4. Sidecar downloads bundles from S3, validates checksums, filters by token ranges
  5. 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: NONE in cassandra.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:

  1. Spark generates rows as DataFrames
  2. Connector batches writes and sends via Cassandra native protocol (port 9042)
  3. 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

ModuleGradle PathMain ClassTransportDescription
common:common——Shared config, data generation, CQL setup
bulk-writer-sidecar:bulk-writer-sidecarDirectBulkWriterDIRECTStreams SSTables directly to Sidecar
bulk-writer-s3-iam:bulk-writer-s3-iamIamBulkWriterS3_COMPATStages SSTables in S3 via IAM credentials, imports via Sidecar
connector-writer:connector-writerStandardConnectorWriterCQLStandard writes via Cassandra native protocol
connector-read-write:connector-read-writeKeyValuePrefixCountCQLRead→transform→write example

The Gradle paths above are within the spark-examples build, 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.*:

PropertyDescriptionDefault
spark.easydblab.contactPointsComma-separated database hostsRequired
spark.easydblab.keyspaceTarget keyspaceRequired
spark.easydblab.tableTarget tabledata_<timestamp>
spark.easydblab.localDcLocal datacenter nameRequired
spark.easydblab.rowCountNumber of rows to write1000000
spark.easydblab.parallelismSpark partitions for generation10
spark.easydblab.partitionCountCassandra partitions to distribute across10000
spark.easydblab.replicationFactorKeyspace replication factor3
spark.easydblab.skipDdlSkip keyspace/table creation (validates they exist)false
spark.easydblab.compactionCompaction strategy(default)
spark.easydblab.s3.bucketS3 bucket (S3 mode only)Required for S3
spark.easydblab.s3.endpointS3 endpoint overrideAWS 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:

  1. Verify EMR cluster has EasyDBLabEMREC2Role instance profile attached
  2. Check IAM role has s3:* permissions on the data bucket
  3. 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:

  1. Verify storage_compatibility_mode: NONE in cassandra.yaml
  2. Cassandra 5.x defaults to UPGRADING mode which uses legacy SSTable format
  3. The bulk writer requires NONE to 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

  1. Check Sidecar is running: curl http://<cassandra-host>:9043/api/v1/health
  2. Verify Sidecar has S3 read permissions (IAM role)
  3. Check Sidecar logs for download or validation errors
  4. Verify token ranges match between SSTables and Cassandra ring

Direct Bulk Writer Issues

"Connection refused" to port 9043

  1. Verify Cassandra Sidecar is running on all nodes
  2. Check security groups allow EMR → Cassandra on port 9043
  3. 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:

  1. Reduce spark.easydblab.parallelism to lower write rate
  2. Use S3 staging transport for large datasets
  3. Check Cassandra disk I/O and compaction status

Connector Writer Issues

"No route to host" or timeout

  1. Check security groups allow EMR → Cassandra on port 9042
  2. Verify contact points are reachable from EMR
  3. 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:

VariableDescription
TARGET_JDBC_URLFull JDBC connection URL (e.g. jdbc:clickhouse://10.0.1.5:8123/default)
TARGET_JDBC_USERDatabase username for JDBC connections
TARGET_JDBC_DRIVERFully-qualified JDBC driver class name
TARGET_PG_HOSTHost for PostgreSQL wire protocol connections
TARGET_PG_PORTPort for PostgreSQL wire protocol connections
TARGET_PG_USERUsername for PostgreSQL wire protocol connections
TARGET_PG_DATABASEDatabase name for PostgreSQL wire protocol connections
TARGET_MYSQL_HOSTHost for MySQL wire protocol connections
TARGET_MYSQL_PORTPort for MySQL wire protocol connections
TARGET_MYSQL_USERUsername for MySQL wire protocol connections
TARGET_MYSQL_DATABASEDatabase name for MySQL wire protocol connections
TARGET_HTTP_URLFull 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.

Optionmemcached optionMeaning
--extstore-page-sizeext_page_sizePage size in MB
--extstore-wbuf-sizeext_wbuf_sizeWrite buffer size in MB
--extstore-threadsext_threadsIO threads
--extstore-item-sizeext_item_sizeSmallest 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:

  1. Profile kits directory (~/.easy-db-lab/profiles/<profile>/kits/)
  2. Registered additional sources (in registration order)
  3. 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
OptionTypeDefaultDescription
--versionstringlatestClickHouse server image tag (clickhouse/clickhouse-server:<version>), e.g. 25.4, 24.8
--sizestring10TiStorage size per node (e.g. 100Gi)
--replicasintnumber of db nodesNumber of ClickHouse replicas; also the number of Keeper replicas
--s3-cachestring10GiLocal cache size for the S3-backed storage policies
--s3-cache-on-writebooleantruePopulate the S3 disk cache on write
--s3-tier-move-factorfloat0.2move_factor of the s3_tier policy: data moves to S3 when local free space falls below this fraction
--forcebooleanfalseOverwrite 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:

PodShardReplica
chi-clickhouse-clickhouse-0-0-000
chi-clickhouse-clickhouse-0-1-001
chi-clickhouse-clickhouse-0-2-002

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.

InterfaceURL/PortDescription
Play UIhttp://<db-node-ip>:30123/playInteractive web query interface
HTTP APIhttp://<db-node-ip>:30123REST API for queries
Native Protocol<db-node-ip>:30900High-performance binary protocol
MySQL wire<db-node-ip>:30904MySQL-compatible protocol (mysql -h <ip> -P 30904 -u default)
PostgreSQL wire<db-node-ip>:30905PostgreSQL-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:

  • clickhouse is 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

EngineUse CaseReplicationSharding
MergeTreeSingle-node, no replicationNoNo
ReplicatedMergeTreeReplicated within shardYesNo
DistributedQuery/insert across shardsVia underlying tableYes

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

Aspectdefaults3_mains3_tier
Storage LocationLocal disk (local_disk)Cluster's S3 data bucket, through a local cacheHybrid: starts local, moves to S3 when disk fills
PerformanceBest latency, highest throughputHigher latency, cache-dependentGood initially, degrades as data moves to S3
CapacityLimited by disk sizeVirtually unlimitedVirtually unlimited
CostIncluded in instance costS3 storage + request costsS3 storage + request costs
Data PersistenceLost when cluster is destroyedKept in the data bucket after downKept in the data bucket after down
Best ForBenchmarks, low-latency queriesLarge datasets, cost-sensitive workloadsMixed 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:

JobPodsPortPath
clickhouseserver pods (clickhouse.altinity.com/chi=clickhouse)9363/metrics
clickhouse-keeperKeeper 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-operator in kube-system, installed by kit install clickhouse
  • ClickHouse Server: ClickHouseInstallation clickhouse, one cluster named clickhouse with 1 shard and --replicas replicas, on db nodes only
  • ClickHouse Keeper: ClickHouseKeeperInstallation clickhouse-keeper with --replicas replicas on db nodes, used for replication and ON CLUSTER DDL (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:

  1. During cluster creation, each db node is labeled with its ordinal (easydblab.com/node-ordinal=0, etc.)
  2. Local PVs are created with node affinity matching these ordinals and the type=db label
  3. Each PV is labelled app.kubernetes.io/name=clickhouse, and the claims select that label, so they never bind another kit's PV
  4. The installation's volumeClaimTemplate requests --size of 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 portNodePortPurpose
812330123HTTP interface (also Play UI and JDBC)
900030900Native protocol
900430904MySQL wire protocol
900530905PostgreSQL wire protocol
936330936Server 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

FlagDefaultDescription
--version4.2.0Kafka version
--brokers1Number of broker/controller nodes
--storage-size10TiPersistent volume size per broker

Managing Kafka

start

Deploys the Kafka cluster and waits for it to become Ready:

  1. Applies the JMX metrics ConfigMap
  2. Applies the Kafka and KafkaNodePool custom resources
  3. Waits for the Kafka cluster to report Ready (up to 300s)
  4. 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
FlagDefaultDescription
--num-records1000000Number of records to produce
--record-size1024Record size in bytes
--throughput1000Target msg/sec (-1 for unlimited)
--topicperf-testTopic name

Consumer performance test

easy-db-lab kafka consumer-perf
FlagDefaultDescription
--num-records1000000Number of records to consume
--topicperf-testTopic name
--groupbench-consumerConsumer group ID

Topic management

easy-db-lab kafka create-topic --topic my-topic --partitions 3 --replication-factor 3
FlagDefaultDescription
--topicperf-testTopic name
--partitions3Number of partitions
--replication-factor1Replication factor

Metrics

Two Prometheus scrape jobs are registered automatically by kafka start:

JobContainer portNodePortDescription
kafka-exporter940432309Consumer lag, topic offsets, partition health
kafka-jmx940432404Per-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

OptionDescriptionDefault
--replicasNumber of Ignite server nodesdb node count
--storageStorage profile (see below)aipersist
--versionApache Ignite 3 Docker image version3.0.0

Storage Profiles

Ignite 3 supports three storage engines, selectable at start time:

ProfileDescriptionData survives restart?
aimemPure in-memory, volatileNo
aipersistIn-memory with disk persistence (default)Yes
rocksdbDisk-based LSM, suited for large datasetsYes
# 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

NamePortProtocol
REST / Management30300HTTP
Thin Client / JDBC30800TCP

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

OptionDescriptionDefault
--versionNeo4j 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>-community2026.09.0
--storage-sizeSize of the data volume10Ti
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 stock server.jvm.additional lines in neo4j.conf with 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:

NamePortProtocol
Bolt30687Bolt (bolt://, neo4j://)
HTTP30474HTTP (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.Stress node)
  • kubectl and helm are 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

FlagDefaultDescription
--workersapp node countNumber 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.Stress node)
  • kubectl and helm are 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

FlagDefaultDescription
--version474Trino release version to deploy
--workersapp node countNumber 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

FlagDefaultDescription
--versionv8.5.2TiDB version to deploy
--replicasdb node countNumber of TiKV and TiFlash replicas (one per db node)

Cluster Layout

ComponentNode typeReplicasRole
PDapp1Placement driver / metadata
TiDBappone per app nodeMySQL-compatible SQL layer
TiKVdb--replicasRow store (Raft)
TiFlashdb--replicasColumnar 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 labelComponentWhat it covers
tidb-sqlTiDBConnections, query throughput, plan cache, errors
pdPDCluster health, TSO, region scheduling
tikvTiKVRaft, RocksDB storage, coprocessor
tiflashTiFlashMPP 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 1 or 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, start and stop fail before they create a pod, with sysbench needs an app node (type=app); this cluster has none. A stop that fails this way leaves the kit marked as running.
  • A database kit with the sql capability 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

FlagDefaultDescription
--target(required)Name of the running database kit to benchmark
--threads4Number of concurrent threads
--duration60Benchmark duration in seconds
--workloadoltp_read_writesysbench built-in workload (oltp_read_write, oltp_read_only, oltp_write_only)
--scale10Number of rows per table, in thousands
--tables10Number of tables
--rate0Target transactions/sec (0 = unlimited, thread-bound). See Rate limiting and overload testing
--skip-trxoffRun statements in autocommit instead of BEGIN/COMMIT transactions (on/off)
--rand-typespecialKey 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):

MetricDescription
sysbench_tpsTransactions per second
sysbench_qpsQueries per second
sysbench_lat_p99_ms99th percentile latency (ms) — pushed per interval, plus a whole-run value at completion
sysbench_lat_p95_ms95th percentile latency (ms), whole-run
sysbench_lat_p50_ms50th percentile (median) latency (ms), whole-run
sysbench_errors_per_secondErrors 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 1 or more). Every pod of this kit runs on an app node (type=app). With no app node, prepare and start fail before they create a pod, with clickhouse-benchmark needs an app node (type=app); this cluster has none.
  • The clickhouse kit 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

FlagCommandDefaultDescription
--targetinstall(required)Name of the running ClickHouse kit to benchmark
--versioninstalllatestclickhouse/clickhouse-server image version for the benchmark and prepare pods
--workloadprepareclickbenchDataset to load; clickbench is the only one
--sourcepreparethe public ClickBench filesS3 URL of the parquet files to load; see Loading from your own S3 files
--storage-policypreparedefaultStorage policy of the hits table (default, s3_main, s3_tier)
--recreatepreparefalseDrop an existing hits table and load it again
--workloadstartclickbenchPackaged query set to run; give this or --queries, not both
--queriesstart(none)Local file of queries to run instead of a packaged set
--concurrencystart4Number of queries run at the same time
--durationstart60Stop starting queries after this many seconds (0 = no time limit)
--iterationsstart0Stop after this many queries (0 = no limit)
--randomizestarttrueRun the queries in random order; --randomize=false runs them in file order
--continue-on-errorsstartfalseKeep running when a query fails, instead of stopping the run
--settingsstart(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 hits already exists, prepare fails, names the table and its storage policy, and tells you to use --recreate. It does not change the table. With --recreate it drops hits on every replica first.
  • While a benchmark run of this instance is in progress, prepare fails and names the run pod. Run stop, 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_second and clickhouse_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_ms and _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

FlagDefaultDescription
--version17PostgreSQL major version (e.g. 17, 16)
--instances1Number of PostgreSQL instances; values > 1 deploy a primary and read replicas
--size10TiStorage 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

  1. Creates PersistentVolumes on db nodes via platform-pvs
  2. Applies the CNPG Cluster custom resource
  3. Waits for all pods to reach Ready
  4. 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

AliasDescriptionPostgreSQL NodePortMetrics NodePort
duckdbDuckDB analytical query engine via pg_duckdb3043530990
postgisGeospatial types and functions3043330988
timescaledbTime-series storage and query optimization3043430989

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:

NameBinding ModeReclaim PolicyUse
local-storageImmediateRetainLegacy / direct-bound PVs
local-storage-wfcWaitForFirstConsumerDeletePer-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:

LabelValuesApplied to
typedb, app, controlAll nodes
easydblab.com/node-ordinal0, 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> and easydblab.com/node-ordinal=N for 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:

VariableDescription
__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 hostPort mappings 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

KitProtocolNative portNode portExposure
ClickHouseHTTP812330123NodePort
ClickHouseNative TCP900030900NodePort
ClickHouseMySQL wire900430904NodePort
ClickHousePostgreSQL wire900530905NodePort
ClickHousePrometheus936330936pod SD (per-replica); NodePort also exposed
TiDBMySQL (SQL layer)400030400NodePort
TiDBPrometheus (tidb-sql)1008031080pod SD (per-pod); NodePort also exposed
TiDBPrometheus (tikv)20180—pod SD (per-store)
TiDBPrometheus (pd)237932379pod SD (per-pod); NodePort also exposed
TiDBPrometheus (tiflash)823432234pod SD (per-pod); NodePort also exposed
PrestoHTTP (coordinator)80808080hostPort
PrestoPrometheus90909090pod SD; hostPort also exposed
TrinoHTTP (coordinator)80808080hostPort

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:

ModeHow metrics reach the OTel collector
scrapeOTel DaemonSet scrapes a Prometheus endpoint via hostPort
java-agentOTel Java agent inside the JVM pushes OTLP to localhost:4317
helm-nativeKit has built-in OTLP support configured via helm values

Metrics Registration Lifecycle

When a kit with type: scrape targets starts successfully, easy-db-lab:

  1. Creates one ConfigMap easydblab-metrics-<job> per scrape target in the default namespace, labeled easydblab.com/workload-metrics=true and easydblab.com/kit=<kit>, containing the job name, port, and path.
  2. Regenerates the OTel collector ConfigMap to include a Prometheus scrape job per target.
  3. Applies the updated OTel ConfigMap so the running collector picks it up.

When the kit stops:

  1. Deletes all of the kit's metrics ConfigMaps via the easydblab.com/kit label selector.
  2. 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:

RoleHosts
dbdb0, db1, ...
appapp0, app1, ...
controlcontrol0
sparkEMR 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:

LocationWhatWritten by
tempo/<tenant>/Tempo blocksTempo, while the cluster runs
pyroscope/Pyroscope v2 segments and blocksthe Pyroscope server, while the cluster runs
mimir/<tenant>/Mimir blocksMimir, 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>.jsonGrafana annotationsgrafana backup, and down
reports/<tenant>/<name>-<id>/Test documents: your .md files, an HTML copy of each, and index.htmlreport 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.

Info

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-config
  • logs 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 profile command 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 -javaagent at 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 -javaagent inside 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 -javaagent inside both the Presto coordinator and worker JVMs, injected via JAVA_TOOL_OPTIONS during presto start. Profiles appear under service_name=presto with component=coordinator or component=worker.
  • Pyroscope Java agent (Spark/EMR) — Runs as a -javaagent on Spark driver and executor JVMs, installed via EMR bootstrap action to /opt/pyroscope/pyroscope.jar. Profiles appear under service_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.

Never combine a CPU event with wall-clock sampling

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

Two --cstack values changed in async-profiler 4.4

The nodes run async-profiler 4.5, and you own the flags you pass. Both of these changed silently:

  • --cstack dwarf no longer yields DWARF unwinding on HotSpot. async-profiler promotes it to vm whenever VMStructs are available, with no error and no warning. Forcing real DWARF now needs features=agct. Upstream's own docs/StackWalkingModes.md is stale on this point.
  • --cstack lbr was removed. An unrecognised --cstack value falls through to "no C stacks", so passing lbr silently 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:

ConceptRejected spellingsWhy
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, nobatchIt 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
OptionDefaultMeaning
--loop15sJFR 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.
--retention60Minutes 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-bytes2 GiBByte 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:

MetricMeaning
edl_jfr_ship_failures_totalTransient upload failures (5xx or network). Rising means Pyroscope or the network is unwell; those chunks are retried.
edl_jfr_ship_rejected_totalChunks 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_attachedWhat 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_totalFailed 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_totalPasses 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_totalPasses 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_deferred1 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_totalProfiler 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_totalShipped chunks reclaimed by the retention window and by the byte ceiling. Only shipped chunks are ever pruned.
edl_jfr_recording_stopped_at_bound1 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_pendingChunks 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_unreadable1 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_truncated1 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_diskSize of the profile directory. If the byte ceiling ever engages, this is the signal.
edl_jfr_ship_last_success_timestamp_secondsWhen 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/start is the supported approach and is not affected.
  • --nobatch is 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 as jdk.ExecutionSample — indistinguishable from CPU samples — and Pyroscope merges them all into process_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

  1. Open Grafana (port 3000)
  2. Navigate to Dashboards and select the Profiling dashboard
  3. Use the Service dropdown to select a service (e.g. cassandra, cassandra-easy-stress, clickhouse-server)
  4. Use the Hostname dropdown to filter by specific nodes
  5. 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

The Cassandra panels are mutually exclusive by design

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 into process_cpu as 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

  1. Open Grafana (port 3000) and navigate to Explore
  2. Select the Pyroscope datasource
  3. Choose a profile type (e.g. process_cpu, memory, mutex, wall)
  4. 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.

ProfileProduced byDescription
process_cpu-e cpuCPU 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 wallWall-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)

ProfileDescription
cpuCPU time spent in each method
allocMemory allocation by method
lockLock contention

eBPF agent (all processes)

ProfileDescription
process_cpuCPU 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

LocationPurpose
/etc/easy-db-lab/profiling.jsonDesired 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-reconcileThe reconciler
/usr/local/bin/cassandra-readyThe 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.timersystemd 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.

Do not add PrivateTmp to cassandra.service

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/mimir on 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

  1. Check that Mimir is running and ready:

    kubectl get pods -l app.kubernetes.io/name=mimir
    kubectl logs -l app.kubernetes.io/name=mimir
    
  2. Check that the collector is sending. It scrapes its own telemetry into Mimir (job otel-collector); a failing exporter shows up in otelcol_exporter_send_failed_metric_points_total, and in the collector's log:

    kubectl logs -l app.kubernetes.io/name=otel-collector
    
  3. Check that the cluster-config ConfigMap 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:

SourcePathsource label
Cassandra JVM GC log/mnt/db1/cassandra/logs/gc.log*cassandra-gc
System logs/var/log/**/*.log, /var/log/messages, /var/log/syslogsystem
Tools run with exec run/var/log/easydblab/tools/*.logtool-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):

LabelValue
cluster<name>-<clusterId>; every stream carries it
host_namethe node (db0, app0, control0)
node_roledb, app or control
sourcesee the table above
service_namethe 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/loki on 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 under index/
  • 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"'
OptionDescriptionDefault
--source, -sLog source: cassandra (application logs, selects service_name="cassandra"), cassandra-gc (JVM GC log), journald, system, tool-runner, emrAll sources
--host, -HHostname (db0, app0, control0)All hosts
--unitsystemd unitAll units
--sinceTime range (1h, 30m, 1d)1h
--limit, -nMost lines to return100
--grep, -gOnly lines containing this textNone
--query, -qRaw LogQL queryNone

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:

FilterDescription
ClusterOne cluster, or all of them in the tenant
ServiceThe service_name label
SourceThe source label
SeverityLog severity
SearchFree text
FiltersAd-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

  1. Check that Loki is running and ready:

    kubectl get pods -l app.kubernetes.io/name=loki
    kubectl logs -l app.kubernetes.io/name=loki
    
  2. Check that the collector is sending. A failing exporter shows up in otelcol_exporter_send_failed_log_records_total in Mimir, and in the collector's log:

    kubectl logs -l app.kubernetes.io/name=otel-collector
    
  3. Check that the cluster-config ConfigMap 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:

ContainerWhat it does
mimir-compactorMimir'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-compactorLoki's compactor over loki/. It compacts every day's index, today's included.
tempo-backend-scheduler, tempo-backend-workerTempo's compaction over tempo/. Tempo 3 runs one target per process, so they are two containers.
configWrites 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=0 and -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

  • up starts 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, up leaves it as it is. up does 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, then observability compactor start.
  • down, after its teardown succeeds, stops the compactor when no other cluster uses the account bucket. It counts the VPCs tagged easy_cass_lab=1, in every enabled region, whose bucket tag names the bucket. The compactor's own VPC has no bucket tag, so it is never counted.
  • down --all keeps 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 TypeLabels
Cassandratype=db
Stresstype=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 kubeconfig in 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:

JobTargetSource
cilium-agentlocalhost:9962 on every nodeThe Cilium agent (hostNetwork DaemonSet)
hubblelocalhost:9965 on every nodeHubble metrics (dns, drop, tcp, flow, port-distribution, icmp, http)
cilium-operatorPort 9963 on the one node that runs the operatorFound 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:

MethodBest For
Tailscale VPN (Recommended)Production use, team sharing, persistent access
SOCKS ProxyQuick 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 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

  1. Go to Tailscale OAuth Settings
  2. Click Generate OAuth Client
  3. Configure:
    • Description: easy-db-lab
    • Scopes: Auth Keys write (auth_keys), to create the control node's key, and Devices › Core write (devices:core), so down and tailscale stop can remove the control node's device
    • Tags: Add tag:easy-db-lab
  4. 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:

CommandDescription
kubectlKubernetes CLI
helmKubernetes package manager
ciliumCilium CLI
k9sKubernetes TUI
curlHTTP client
skopeoContainer image tool
ssh, scp, sftpSSH to the nodes, with the workspace sshConfig
rsyncFile 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 run easy-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:

SettingValue
SOCKS Host127.0.0.1
SOCKS Portthe port socks start printed (socks5-status shows it too)
SOCKS Version5

Then access cluster services:

  • Grafana: http://control0:3000
  • Mimir: http://control0:9009/prometheus (send X-Scope-OrgID: <tenant>)
  • Loki: http://control0:3100 (send X-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:

  1. Check cluster status: easy-db-lab status
  2. Verify SSH works: ssh control0 hostname
  3. 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-plugin
    

    For other platforms, see the AWS CLI and Session Manager plugin install guides. up checks 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 ec2 allows ssm:StartSession with the SSH and port-forwarding session documents, only to instances tagged easy_cass_lab=1 (every cluster node and AMI build instance is), and allows ssm:TerminateSession and ssm:ResumeSession on your own sessions only (matched on the session's aws:ssmmessages:session-id tag, which works for IAM users and SSO or assumed roles alike), plus ssmmessages: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.

Note

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

FeatureTailscaleSOCKS Proxy
Setup time~10 min (one-time)Instant
PersistencePersistentPer-session
Requires source env.shNoYes
Browser accessDirectRequires proxy config
Team sharingYesNo
External dependencyTailscale accountNone

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

CommandDescription
c-allExecutes a command on every node in the cluster sequentially
c-startStarts Cassandra on all nodes
c-restartRestarts Cassandra on all nodes (not a graceful operation)
c-statusExecutes nodetool status on db0
c-tpstatsExecutes nodetool tpstats on all nodes
c-collect-artifactsCollects 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 NameDescription
initInitialize a directory for easy-db-lab
upProvision AWS infrastructure
cassandra_downShut down AWS infrastructure
cleanClean up generated files
statusDisplay full environment status
hostsList all hosts in the cluster
ipGet IP address for a host by alias

Cassandra Management

Tool NameDescription
cassandra_useSelect a Cassandra version
cassandra_listList available Cassandra versions
cassandra_startStart Cassandra on all nodes
cassandra_restartRestart Cassandra on all nodes
cassandra_update_configApply configuration patch to nodes

Cassandra Stress Testing

Tool NameDescription
cassandra_stress_startStart a stress job on K8s
cassandra_stress_stopStop and delete stress jobs
cassandra_stress_statusCheck status of stress jobs
cassandra_stress_logsView logs from stress jobs
cassandra_stress_listList available workloads
cassandra_stress_fieldsList available field generators
cassandra_stress_infoShow workload information

ClickHouse

Tool NameDescription
clickhouse_startDeploy ClickHouse cluster to K8s
clickhouse_stopRemove ClickHouse cluster
clickhouse_statusCheck ClickHouse cluster status

OpenSearch

Tool NameDescription
opensearch_startCreate AWS OpenSearch domain
opensearch_stopDelete OpenSearch domain
opensearch_statusCheck OpenSearch domain status

Spark

Tool NameDescription
spark_submitSubmit Spark job to EMR cluster
spark_statusCheck status of a Spark job
spark_jobsList recent Spark jobs
spark_logsDownload EMR logs from S3

Kubernetes

Tool NameDescription
k8_applyApply observability stack to K8s

Utilities

Tool NameDescription
prune_amisPrune 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

BenefitDescription
Direct ControlClaude executes easy-db-lab commands directly without manual intervention
Context AwarenessClaude maintains context about your cluster state and configuration
AutomationComplex multi-step operations can be automated through Claude
Intelligent AssistanceClaude can analyze logs, metrics, and provide optimization recommendations

Example Workflow

  1. Start the server in one terminal:

    easy-db-lab server
    
  2. In another terminal, start Claude Code from the same directory:

    claude
    

    Claude Code automatically detects the .mcp.json file generated by the server.

  3. 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:

FieldTypeDescription
cpuUsagePctdoubleCPU usage percentage (0-100)
memoryUsedByteslongMemory used in bytes
diskReadBytesPerSecdoubleDisk read throughput (bytes/sec)
diskWriteBytesPerSecdoubleDisk write throughput (bytes/sec)
filesystemUsedPctdoubleFilesystem usage percentage (0-100)

Cassandra — cluster-wide:

FieldTypeDescription
readP99MsdoubleRead latency p99 in milliseconds
writeP99MsdoubleWrite latency p99 in milliseconds
readOpsPerSecdoubleRead operations per second
writeOpsPerSecdoubleWrite operations per second
compactionPendinglongNumber of pending compactions
compactionCompletedPerSecdoubleCompactions completed per second
compactionBytesWrittenPerSecdoubleCompaction 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

OptionDescription
--help, -hShows help information
--vpc-idReconstruct state from existing VPC (requires ClusterId tag)
--forceForce 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 (direct or ssm; 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

ArgumentDescription
policy-nameOptional filter: ec2, iam, emr, opensearch or compactor

build-image

Build both base and Cassandra AMI images.

easy-db-lab build-image [options]
OptionDescriptionDefault
--archCPU architecture (AMD64, ARM64)AMD64
--regionAWS 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.

OptionDescriptionDefault
--db.count (alias --db, --cassandra, -c)Number of database instances3
--app.count (alias --app, --stress, -s)Number of application instances0
--db.instance-type (alias --instance, -i)Database instance typei4i.xlarge
--app.instance-type (alias --stress-instance, -si)Application instance typec6id.2xlarge
--azs, -zAvailability zones (e.g., a,b,c)all
--ebs.typeEBS volume type (NONE, gp2, gp3, io1, io2)NONE
--ebs.sizeEBS volume size in GB256
--ebs.iopsEBS IOPS (gp3 only)0
--ebs.throughputEBS throughput (gp3 only)0
--ebs.optimizedEnable EBS optimizationfalse
--untilWhen instances can be deletedtomorrow
--amiOverride AMI ID(auto-detected)
--openUnrestricted SSH accessfalse
--tagCustom tags (key=value, repeatable)-
--vpcUse existing VPC ID-
--cidrVPC 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)
--cniPod-network CNI: flannel (K3s built-in overlay) or cilium (ENI native routing). See Pod Networking (CNI)cilium
--tenantObservability 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 storeddefault
--upAuto-provision after initfalse
--cleanRemove existing config firstfalse

up

Provision AWS infrastructure.

easy-db-lab up [options]
OptionDescription
--no-setup, -nSkip 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]
ArgumentDescription
vpc-idOptional: specific VPC to tear down
OptionDescription
--allTear down all VPCs tagged with easy_cass_lab; the account compactor's VPC is kept
--packerTear down the packer infrastructure VPC
--forceSkip 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:

  1. 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.
  2. The OTel collector is stopped: its DaemonSet is deleted and down waits 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 down waits 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. down reports 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 its flushed marker, 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. down reports 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
OptionDescription
--cidrAn 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.7 and 203.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 to api.ipify.org and 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]
OptionDescription
--javaJava version to use: 8, 11, 17, 21 or 25
--hostsFilter 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]
OptionDescriptionDefault
<dir>Cassandra source checkout to buildcurrent directory
--java, -jJDK major version to build with. Required — it forms part of the build's name—
--jiraTicket this build is for, e.g. CASSANDRA-19000none
--nameLabel folded into the name, to tell your builds apart at a glancenone
--ant-flagsExtra flags passed to antnone

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]
OptionDescriptionDefault
--urlTarball URL (.tar.gz) or git repository URL (with --branch)from the declared entry
--branchGit branch to clone and build, requires --urlfrom the declared entry
--java, -jJava version to build and run this version withfrom the declared entry
--pythonPython version cqlsh runs under3.11.9
--ant-flagsExtra flags passed to ant when building from a branchfrom the declared entry
--hostsFilter to specific hostsall 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-flags changes nothing on that node, reports which fields disagree, and exits non-zero. To run the existing build under a different JDK, use cassandra 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

OptionDescriptionDefault
-t, --tokensNumber of tokens4

cassandra update-config

Apply configuration patch to all nodes.

easy-db-lab cassandra update-config [options]

Aliases: uc

OptionDescription
--restart, -rRestart Cassandra after applying
--hostsFilter to specific hosts

cassandra download-config

Download configuration files from nodes.

easy-db-lab cassandra download-config [options]

Aliases: dc

OptionDescription
--versionVersion 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]
OptionDescriptionDefault
--sleepTime between starts in seconds120
--hostsFilter to specific hosts-
--sidecar-imageContainer image for the sidecar DaemonSetghcr.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]
OptionDescription
--hostsFilter to specific hosts

cassandra restart

Restart Cassandra on all nodes.

easy-db-lab cassandra restart [options]
OptionDescription
--hostsFilter 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
OptionDefaultDescription
--loop15sJFR rotation interval
--retention60Minutes of profile data to keep on each node
--max-bytes2147483648Byte ceiling for each node's profile directory
--hostsallComma-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.

Warning

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
OptionDefaultDescription
--last5How many of the most recent completed chunks to download
--hostsallComma-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
OptionDefaultDescription
--last5How many of the most recent completed chunks to convert
--formathtmlOutput format handed to jfrconv
--hostsallComma-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
OptionDescription
-t, --typeServer type: cassandra, stress, control (default: cassandra)
--bgRun in background (returns immediately)
--nameName for the systemd unit, edl-exec-<name> (default: the command's first word and a timestamp). Characters systemd does not allow become -.
--hostsFilter to specific hosts
-pExecute 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]
OptionDescriptionDefault
--source, -sLog source: cassandra (application logs, selects service_name="cassandra"), cassandra-gc (JVM GC log), journald, system, tool-runner, emrAll sources
--host, -HHostname (db0, app0, control0)All hosts
--unitsystemd unitAll units
--sinceTime range (1h, 30m, 1d)1h
--limit, -nMost lines to return100
--grep, -gOnly lines containing this textNone
--query, -qRaw LogQL query, sent unchangedNone

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
OptionDescriptionDefault
--folderGrafana folder to install into; created if it does not existGeneral

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
OptionDescriptionDefault
--textThe annotation body text (required)-
--tagsTags to attach; repeat the flag or comma-separatenone
--timeStart time: now, a relative offset like -30m/-2h/-1d, an ISO-8601 instant, or epoch millisnow
--time-endOptional end time; produces a region annotation (same formats as --time)none
--dashboardOptional dashboard UID to scope the annotation to one dashboardall dashboards
--panelOptional panel id to scope the annotation to one panelall 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
ArgumentDescription
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]
OptionDescription
--step-idEMR step ID (defaults to the most recent job)
--verbose, -vShow detailed step information (equivalent to aws emr describe-step)
--logsDownload 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

PortPurpose
9042Cassandra Native Protocol (CQL)
7000Inter-node communication
7001Inter-node communication (SSL)
7199JMX monitoring

Observability Ports (Control Node)

PortService
3000Grafana
3080Test documents web server (read-only, in the Grafana pod)
3081Test documents signing proxy (loopback only, in the Grafana pod)
8081Grafana image renderer (in the Grafana pod)
4040Pyroscope (continuous profiling)
9009Mimir HTTP (metrics storage; Prometheus API under /prometheus)
9097Mimir gRPC
7947Mimir memberlist (loopback only)
11211Mimir's memcached caches (loopback only, in the Mimir pod)
3100Loki HTTP (log storage)
9098Loki gRPC
3200Tempo (trace storage)
5001YACE CloudWatch exporter (Prometheus)
8080kube-state-metrics (Prometheus; ClusterIP Service, pod network)

Observability Ports (All Nodes — DaemonSets)

PortService
4317OTel Collector gRPC
4318OTel Collector HTTP — the Cassandra JVM agent exports here
9400Beyla eBPF metrics (Prometheus)
9435ebpf_exporter metrics (Prometheus)

Cilium Ports (Cilium clusters only; the default CNI)

PortService
9962Cilium agent metrics (Prometheus, every node)
9963Cilium operator metrics (Prometheus, the node that runs the operator)
9965Hubble metrics (Prometheus, every node)
31234Hubble UI (NodePort, any node's private IP)

Kit Ports

Kits publish client ports through NodePort Services, reachable on any node's private IP.

PortKitService
31211memcachedmemcached client port (NodePort → 11211)
9150memcachedmemcached-exporter metrics (pod port, scraped by pod discovery)
30687neo4jBolt (NodePort → 7687)
30474neo4jHTTP (NodePort → 7474)
30432postgresPostgreSQL (NodePort → 5432)
30987postgresmetrics (NodePort → 9187, for manual inspection; scraped by pod discovery)
30435postgres-duckdbPostgreSQL (NodePort → 5432)
30990postgres-duckdbmetrics (NodePort → 9187)
30433postgres-postgisPostgreSQL (NodePort → 5432)
30988postgres-postgismetrics (NodePort → 9187)
30434postgres-timescaledbPostgreSQL (NodePort → 5432)
30989postgres-timescaledbmetrics (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

PortPurpose
8080Default 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 set OTEL_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_ENDPOINT
  • OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
  • OTEL_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 TypeK8s Labelnode_role ValueSource
Cassandra host metricsN/AdbHost name db<N>
Cassandra JVMN/Adbotel.resource.attributes in cassandra.in.sh
Stress host metricsN/AappHost name app<N>
Control host metricsN/AcontrolHost name control<N>
Spark/EMRN/AsparkEMR 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:

LabelSourceDescription
host_nameDaemonSet resource_detection processorK8s node name where the pod runs
instanceSidecar relabel_configsNode name with port (e.g., ip-10-0-1-50:9500)
clusterSidecar relabel_configsCluster 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 label job="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 by setup-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:

MetricAttributesSource MBean
cassandra.client.request.latency.{p50,p99,p999,max}cassandra.operationtype=ClientRequest,scope={Read,Write,RangeSlice,CASRead,CASWrite},name=Latency
cassandra.client.request.countcassandra.operationthe same five beans, Count
cassandra.client.request.errorcassandra.operation, cassandra.statusname={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, tabletype=Table
cassandra.table.sstable.count.livekeyspace, tabletype=Table,name=LiveSSTableCount
cassandra.table.compaction.pendingkeyspace, tabletype=Table,name=PendingCompactions
cassandra.table.{read,write}.latency.{p50,p99,p999,max}keyspace, tabletype=Table,name={Read,Write}Latency
cassandra.table.sstables.per.read.{p50,p99,max}keyspace, tabletype=Table,name=SSTablesPerReadHistogram
cassandra.thread_pool.tasks.{active,pending,blocked}pool, pool_pathtype=ThreadPools
cassandra.messages.droppedmessage_typetype=DroppedMessage
cassandra.table.range.latency.{p50,p99,p999,max}keyspace, tabletype=Table,name=RangeLatency
cassandra.table.live_rows.scanned.{p50,p99,p999,max}keyspace, tabletype=Table,name=LiveScannedHistogram
cassandra.table.read_repair.requestskeyspace, tabletype=Table,name=ReadRepairRequests
cassandra.table.short_read_protection.requestskeyspace, tabletype=Table,name=ShortReadProtectionRequests
cassandra.table.replica_filtering_protection.requestskeyspace, tabletype=Table,name=ReplicaFilteringProtectionRequests
cassandra.table.replica_filtering_protection.rows_cached.{p50,p99,max}keyspace, tabletype=Table,name=ReplicaFilteringProtectionRowsCachedPerQuery
cassandra.table.repaired_data.inconsistencies.{confirmed,unconfirmed}keyspace, tabletype=Table,name=RepairedDataInconsistencies{Confirmed,Unconfirmed}
cassandra.table.repaired_data.overread.rows.{p50,p99,max}keyspace, tabletype=Table,name=RepairedDataTrackingOverreadRows
cassandra.table.repaired_data.overread.time.{p50,p99,p999,max}keyspace, tabletype=Table,name=RepairedDataTrackingOverreadTime
cassandra.read_repair.{blocking,reconcile_reads}—type=ReadRepair,name={RepairedBlocking,ReconcileRead}
cassandra.buffer_pool.{hits,misses}pooltype=BufferPool,name={Hits,Misses}
cassandra.buffer_pool.{capacity,size,used,overflow}pooltype=BufferPool,name={Capacity,Size,UsedSize,OverflowSize}
cassandra.client.request.cas.contention.{p50,p99,p999,max}cassandra.operationtype=ClientRequest,scope={CASRead,CASWrite},name=ContentionHistogram
cassandra.client.request.cas.contendedcassandra.operationthe same beans, Count
cassandra.client.request.cas.unfinished_commitscassandra.operationtype=ClientRequest,scope={CASRead,CASWrite},name=UnfinishedCommit
cassandra.client.request.cas.condition_not_metcassandra.operationtype=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_leveltype=ClientRequest,scope={Read-QUORUM,Write-QUORUM},name=Latency
cassandra.table.cas.{prepare,propose,commit}.latency.{p50,p99,p999,max}keyspace, tabletype=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 _bucket series 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) and min by (cluster) instead.

Migrating from MAAC

Metric names and the job label both change, and nothing translates between them.

BeforeAfter
Metric prefixorg_apache_cassandra_metrics_*cassandra_*
Job labeljob="cassandra-maac"job="cassandra"
TransportPrometheus scrape of localhost:9000OTLP to localhost:4318
Latency shapehistogram bucketspercentile 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 as otel-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 systemd EnvironmentFile=- 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 label job.
  • service.instance.id: instance identifier, mapped to the Prometheus label instance. 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: hostname
  • node_role: node type — db, app, control, spark-master or spark-worker
  • cassandra_build: the build a Cassandra node is running, read from its own current symlink (Cassandra JVM only)

Configuration

The following environment variables are supported:

VariableDescriptionDefault
OTEL_EXPORTER_OTLP_ENDPOINTOTLP endpointNone (no export)
OTEL_EXPORTER_OTLP_PROTOCOLhttp/protobuf or grpchttp/protobuf
OTEL_SERVICE_NAMEOverride service nameeasy-db-lab
OTEL_RESOURCE_ATTRIBUTESAdditional resource attributesNone

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

  1. Verify OTEL_EXPORTER_OTLP_ENDPOINT is set. Without it the launcher turns every exporter off.
  2. Verify the endpoint is correct and reachable, and that the protocol matches the port: http/protobuf (the default) on 4318, grpc on 4317
  3. Look for OpenTelemetry agent logs on startup (set JAVA_OPTS=-Dotel.javaagent.debug=true to 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:

  • format should be set to jfr
  • name contains 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 to my-app{}, it will appear in Pyroscope as my-app.cpu{}
  • units is ignored — actual units depend on the profile types in the data
  • aggregationType is ignored — actual aggregation type depends on the profile types in the data

Supported JFR Profile Types

  • cpu — samples from runnable threads only
  • itimer — similar to cpu profiling
  • wall — samples from any thread regardless of state
  • alloc_in_new_tlab_objects — number of new TLAB objects created
  • alloc_in_new_tlab_bytes — size in bytes of new TLAB objects created
  • alloc_outside_tlab_objects — number of new allocated objects outside any TLAB
  • alloc_outside_tlab_bytes — size in bytes of new allocated objects outside any TLAB

JFR with Dynamic Labels

To ingest JFR data with dynamic labels:

  1. Use multipart/form-data Content-Type
  2. Send JFR data in a form file field called jfr
  3. Send LabelsSnapshot protobuf message in a form file field called labels
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 -javaagent flags are missing, spark.driver.extraJavaOptions may 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_LABELS env var must be set via spark-env classification with hostname=$(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 full check task) 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 runs check on JDK 21.
  • Kotlin and Gradle (the Gradle wrapper ./gradlew is 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

VariableRequiredDefaultDescription
AWS_PROFILEYes (for e2e tests)—AWS credentials profile from ~/.aws/config
EASY_DB_LAB_INSTANCE_TYPENoc5d.2xlargeEC2 instance type for database nodes
SIDECAR_IMAGENoghcr.io/apache/cassandra-sidecar:latestCustom 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 the PicoCommand interface. 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:

  1. Build the container:

    ./gradlew :docker-cassandra:buildDocker
    
  2. Run the container:

    docker run -it <image-name>
    

Memory Requirements

Use CaseRecommended Memory
Single container development4GB
Dashboard preview (multiple containers)8GB
Full integration testing16GB

Publishing

Pre-Release Checklist

  1. First check CI to ensure the build is clean and green
  2. Ensure the following environment variables are set:
    • DOCKER_USERNAME
    • DOCKER_PASSWORD
    • DOCKER_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:

  1. A GHCR Docker image that is a drop-in replacement for the Docker Official cassandra image — same docker-entrypoint.sh contract, CASSANDRA_* environment variables, exposed ports (7000/7001/7199/9042/9160), and /var/lib/cassandra data volume.
  2. 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

InputRequiredDefaultPurpose
refyes—Branch, tag, or commit SHA to build
reponoapache/cassandraSource repo (owner/name); set this to build a fork
jdknoautoOverride the build JDK (e.g. 11, 17, 21)
base_imagenoautoOverride 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:

  1. resolve — validates ref with git ls-remote (fails fast, naming the bad ref, if it does not exist), pins it to a full + short commit SHA, reads base.version from build.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.sh and is unit-tested by resolve-ref.test.sh (run via ./gradlew testCassandraResolveRef) with the git ls-remote call stubbed, so the fail-fast path is covered without a live run.
  2. build — checks out the exact resolved SHA, sets up the build JDK, runs ant artifacts on the runner, and uploads the tarball as an intra-workflow artifact.
  3. 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 immutable sha-<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. The latest tag 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 tagged cassandra-<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 Official cassandra layout but injects the branch-built tarball (passed as the TARBALL build arg) onto the BASE_IMAGE JRE 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

  1. Domain-Driven Design: Tests use business language, not implementation details
  2. Refactoring Safety: Changes to class internals don't break test logic
  3. Readability: Tests read like specifications
  4. Reusability: Common assertions are centralized
  5. Maintainability: Single place to update assertion logic
  6. 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

  1. Test Names: Use descriptive names with backticks

    @Test
    fun `should start cassandra node when status is DOWN`() { }
    
  2. 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()
    }
    
  3. Mock External Dependencies: Always mock AWS, SSH, and other external services

    class MyTest : BaseKoinTest() {
        override fun additionalTestModules() = listOf(
            module {
                single { mockRemoteOperationsService() }
            }
        )
    }
    
  4. Test Edge Cases: Include tests for error conditions and boundary cases

  5. 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:

  1. Exact match: The question text matches a key exactly
  2. 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

MethodDescription
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 text
  • default: The default value offered
  • secret: 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.
  • 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's system.log and debug.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-OrgID tenant 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):

LabelDescription
cluster<name>-<clusterId>; every stream carries it
sourceLog source: cassandra-gc, system, tool-runner, journald, annotation
host_nameHostname (db0, app0, control0)
node_roledb, app or control
service_nameOTel service name; unknown_service when the sender sets none
k8s_pod_nameName of the pod that emitted the log (container logs)
k8s_namespace_nameKubernetes namespace (container logs)
k8s_container_nameContainer 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"):

FieldDescription
k8s_app_instanceValue of the app.kubernetes.io/instance pod label — identifies the kit (e.g. presto, tidb)
systemd_unitsystemd unit name (journald)
trace_id, span_idTrace context, for OTLP logs from instrumented JVMs

Troubleshooting

No logs appearing

  1. Check Loki is running:

    kubectl get pods -l app.kubernetes.io/name=loki
    
  2. Check OTel Collector is running:

    kubectl get pods | grep otel
    
  3. 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:

  1. Ensure the cluster is running: easy-db-lab status
  2. The proxy is started automatically when needed
  3. Check that control node is accessible: ssh control0 hostname

Ports

PortServiceLocation
3100Loki HTTP APIControl node
9098Loki gRPCControl 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

ServicePortProtocol
HTTP API4040HTTP
gRPC9095gRPC
Memberlist gossip7946TCP/UDP
Embedded Grafana4041HTTP

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 in X-Scope-OrgID; the Grafana datasource sends it on every query.
  • Account bucket, pyroscope/ root — v2 lays out its own segments/ 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: 0s turns off the index cleanup that drives v2 retention, and limits.retention_period: 0s means 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.dir and metastore.raft.snapshots_dir live 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, default default). It is kept per browser in localStorage (pyroscope:tenantID) and sent as X-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 definition
  • bin/ — optional: shell scripts (start.sh.template, stop.sh.template, ...) for phases that have no typed steps. Every bin/*.sh becomes 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-benchmark keeps its sourced functions in lib/ and its pod-side scripts in pod/).
  • dashboards/ — optional: Grafana dashboard JSON files
  • <name>.yaml.template — optional: K8s manifest templates applied by manifest steps
  • METRICS.md and metrics-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

PhaseTriggerWhat happens after success
installeasy-db-lab kit install <name>Kit directory written to working dir
starteasy-db-lab <name> startMetrics registered, dashboards installed, hooks fired
stopeasy-db-lab <name> stopWaits for the runtime's pods to go, metrics deregistered, kit dropped from runningKits, hooks fired
uninstalleasy-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)
backupeasy-db-lab <name> backup <name>BACKUP_NAME env var set to first argument
restoreeasy-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:

  • KUBECONFIG is the absolute path of the workspace kubeconfig. The process does not start if that file does not exist.
  • <workspace>/bin is first on PATH. easy-db-lab writes a wrapper there for each of kubectl, helm, cilium, curl, skopeo, k9s, ssh, scp, sftp and rsync before the process starts. The ssh, scp, sftp and rsync wrappers use the workspace sshConfig, so a step can run ssh 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, aws included, get no proxy settings and connect directly.
  • A call that bypasses PATH is not wrapped: an absolute path such as /usr/local/bin/kubectl, env -i kubectl, or a script that sets its own PATH=/usr/bin:/bin. Call the tool by its name and keep the inherited PATH.
  • A wrapped tool sends all of its traffic through the tunnel, public URLs included. helm repo add of 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:

VariableDescription
CLUSTER_NAMEName of the cluster
KUBECONFIGAbsolute path to the workspace kubeconfig file
PATH<workspace>/bin (the tool wrappers) followed by the inherited PATH
CONTROL_HOSTPublic IP of the control node
CONTROL_HOST_PUBLICPublic IP of the control node
CONTROL_HOST_PRIVATEPrivate IP of the control node
DB_NODE_COUNTNumber of database nodes
APP_NODE_COUNTNumber of app/stress nodes
DB_NODE_IPSComma-separated private IPs of database nodes
APP_NODE_IPSComma-separated private IPs of app nodes
BUCKET_NAMES3 data bucket name
ACCOUNT_BUCKETS3 account-level bucket name
REGIONAWS region
KIT_NAMEName of this kit
STORAGE_SIZEStorage size (from --size arg, if used)
STORAGE_CLASS_WFCK8s storage class name with WaitForFirstConsumer binding
RUNNING_KITSComma-separated names of currently running kits
EASY_DB_LAB_EXECPath to the easy-db-lab binary
BACKUP_NAMEFirst positional argument (backup and restore phases only)
EDL_KIT_LIBAbsolute 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 cluster variable: label_values(up, cluster) on ${metrics_datasource}, multi-select, with "All" and allValue .+. Leave its current empty.
  • 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 cluster in every aggregation (sum by (cluster, instance), sum by (cluster) (...)), and show it in every legend by its short name: wrap the query in label_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$, not byName reads). A table keeps its legends and shows the cluster as a column: its organize hides cluster and renames cluster_name to "Cluster".
  • Declare a datasource picker for each signal they use: metrics_datasource ("type": "datasource", "query": "prometheus", label "Metrics"), and logs_datasource (loki) or traces_datasource (tempo) when they use logs or traces. Leave each picker's current empty.
  • Name every datasource through its picker, keeping the type: { "type": "prometheus", "uid": "${metrics_datasource}" }. Never name mimir, loki or tempo directly, not even inside an Explore link; pyroscope has no picker and stays as it is.
  • Pass each picker and cluster the dashboard declares on every /d/ link, as ${metrics_datasource:queryparam} (or an explicit var-<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 install refuses when the kit's scaffold directory already exists and is not empty. The event is Install.CollisionDetected. Pass --force to overwrite the scaffold.
  • <kit> start refuses when the workload the kit's runtime block declares is already in the cluster: pods matching the runtime selector in its namespace, or, for a helm runtime, the helm release and then any pod labelled app.kubernetes.io/instance=<release> in the runtime's namespace. Pods that are still terminating count too, so a pod left cleaning up by a manual kubectl delete blocks start until it is gone. The event is Kit.CollisionDetected, naming the objects it found, and no start step runs. Run <kit> stop first.

<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.

FunctionWhat 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,,} or wait -n.
  • Never expand an array that can be empty as "${arr[@]}" under set -u: bash before 4.4 fails with unbound 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 with local X="$(f)" or inside an if test, so a failure inside it still ends the script under set -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:

  1. The installed kit directory is named <kit>-<target> instead of <kit>, allowing multiple simultaneous instances (e.g. sysbench-clickhouse and sysbench-tidb).
  2. At install, the target must already be installed and must provide what the arg asks for. kit install refuses, before it writes anything, a target that lacks the capability or any type in endpoints, and names every missing type in one message (for example Target kit 'tidb' does not have the 'native' endpoint required by clickhouse-benchmark). An unknown type in endpoints fails when kit.yaml is parsed. With neither field, the target is not checked.
  3. In every phase, KitEndpointResolver reads the target kit's kit.yaml endpoints and injects them as TARGET_* environment variables.

TARGET_* injection rules

The variables injected depend on what endpoints the target kit declares:

Endpoint typeVariables injected
jdbcTARGET_JDBC_URL, TARGET_JDBC_USER, TARGET_JDBC_DRIVER
postgresqlTARGET_PG_HOST, TARGET_PG_PORT, TARGET_PG_USER, TARGET_PG_DATABASE
mysqlTARGET_MYSQL_HOST, TARGET_MYSQL_PORT, TARGET_MYSQL_USER, TARGET_MYSQL_DATABASE
nativeTARGET_NATIVE_HOST, TARGET_NATIVE_PORT, TARGET_NATIVE_USER, TARGET_NATIVE_DATABASE
httpTARGET_HTTP_URL
kafkaTARGET_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:

  • hostPort only works when the CNI chains the portmap plugin, which is a property of the datapath rather than of the kit.
  • hostNetwork pins 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

  1. Create src/main/resources/com/rustyrazorblade/easydblab/kits/<name>/kit.yaml
  2. Add lifecycle steps — start with start and stop at minimum
  3. Add a metrics block if the kit exposes Prometheus metrics
  4. Add a METRICS.md documenting the available metrics
  5. Create dashboards/<name>.json with panels for the key metrics
  6. Run easy-db-lab kit install <name> to scaffold the working directory
  7. Test <name> start and <name> stop against a real cluster

No Kotlin code is required. The install and kit runner commands register dynamically from the kit.yaml files at startup.