Storage Service

πŸ“– Overview

HUATUO supports persisting Linux kernel events collected by the Tracer and AutoTracing data to external storage backends. Both Elasticsearch and OpenSearch are supported.

After serialization to JSON, collected events are written concurrently to the local node directory (huatuo-local/) and the configured remote storage backend. The local directory retains a local copy of events; the remote backend provides durable storage and structured query capabilities.

This document covers configuration and verification for both Elasticsearch and OpenSearch. Examples use Docker deployments. In production, replace the addresses with your actual service endpoints β€” the configuration format is the same.


🎯 Use Cases

Kubernetes Cloud-Native Fault Tracing

In containerized environments, kernel events such as Pod OOM and node Hung Task are transient β€” logs are often purged shortly after the event occurs. By writing events to Elasticsearch or OpenSearch, operations teams can query the historical timeline of anomalies by time range and precisely identify the root cause of intermittent failures during post-incident reviews.

AI Compute Cluster Stability Auditing

During long-running GPU training workloads, the historical distribution of events such as ras hardware errors and iotracing I/O latency is critical for capacity planning and hardware health assessment. Persisting collected data enables aggregate queries to establish node stability baselines and supports proactive maintenance decisions.

Compliance and Event Retention

Security compliance standards require that system anomaly events be traceable. Writing HUATUO-captured kernel events to OpenSearch and configuring an index lifecycle policy satisfies compliance requirements for event retention periods and query capabilities.

Observability Platform Integration

Both Elasticsearch and OpenSearch provide native data source integrations with Grafana. Once HUATUO events are written to storage, you can build kernel event trend dashboards in Grafana, overlaid with application-layer metrics for historical analysis and alert review.


πŸ’Ž Value

Dimension Local Storage Only With External Storage Backend
Data Durability Limited by node disk capacity; may be lost on restart Persisted to distributed storage; supports long-term retention
Query Capability No structured queries; relies on file search Full-text search, field filtering, time-range aggregation
Visualization Not supported Direct integration with Grafana, Kibana, and similar platforms
Multi-node Aggregation Data scattered across individual nodes Centralized storage; supports cross-node queries
Compliance Retention Difficult to meet retention requirements Configurable index lifecycle policies; meets compliance retention requirements

πŸš€ Usage

OpenSearch V2

1. Deploy OpenSearch

docker pull opensearchproject/opensearch:2.6.0
docker run -d --name opensearch --network host \
  -e "discovery.type=single-node" \
  opensearchproject/opensearch:2.6.0

2. Verify Service Status

curl -k -u admin:admin https://localhost:9200

Example response:

{
  "name" : "22ca72df78c0",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "yxb3foceQVKzXXO6bHpPHQ",
  "version" : {
    "distribution" : "opensearch",
    "number" : "2.6.0",
    "build_type" : "tar",
    "build_hash" : "7203a5af21a8a009aece1474446b437a3c674db6",
    "build_date" : "2023-02-24T18:57:04.388618985Z",
    "build_snapshot" : false,
    "lucene_version" : "9.5.0",
    "minimum_wire_compatibility_version" : "7.10.0",
    "minimum_index_compatibility_version" : "7.0.0"
  },
  "tagline" : "The OpenSearch Project: https://opensearch.org/"
}

If verification fails, check the container logs:

docker logs opensearch

3. Configure huatuo-bamai

Add the following configuration to huatuo-bamai.conf. The default username and password for the OpenSearch container image are both admin. For a full description of storage configuration options, see the Configuration Guide.

[Storage.Elasticsearch]
    Address = "https://127.0.0.1:9200"
    Index = "huatuo_bamai"
    Username = "admin"
    Password = "admin"

4. Start huatuo-bamai

Use --config-dir to specify the directory containing the configuration file:

./_output/bin/huatuo-bamai --region dev --config-dir .

When files (e.g., net_rx_latency) appear in the local storage directory huatuo-local/, kernel events have been successfully captured. Query data from OpenSearch with:

curl -k -u admin:admin \
  -X GET "https://localhost:9200/huatuo_bamai/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query": {"match_all": {}}}'

Example response:

{
    "_index" : "huatuo_bamai",
    "_id" : "yjPG_50Bu_OF-hukxKR7",
    "_score" : 1.0,
    "_source" : {
      "hostname" : "hostname",
      "region" : "dev",
      "uploaded_timestamp" : "2026-05-07T00:11:49.753166222Z",
      "tracer_name" : "net_rx_latency",
      "observed_timestamp" : "2026-05-07T00:11:49.753166222Z",
      "tracer_type" : "event",
      "tracer_data" : {
        "comm" : "<nil>",
        "pid" : 0,
        "latency_stage" : "RX_STAGE_NETIF",
        "latency_ms" : 1776078133565,
        "tcp_saddr" : "127.0.0.1",
        "tcp_daddr" : "127.0.0.1",
        "tcp_sport" : 37736,
        "tcp_dport" : 9200,
        "tcp_seq" : 1080592402,
        "tcp_ack_seq" : 2465063876,
        "packet_len_bytes" : 781
      }
    }
}

To get the total document count without listing individual records:

curl -k -u admin:admin -X GET "https://localhost:9200/huatuo_bamai/_count?pretty"

Example response: the count value equals the total number of written records.

{
  "count" : 2680,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  }
}

Elasticsearch V8

1. Deploy Elasticsearch

docker pull docker.elastic.co/elasticsearch/elasticsearch:8.15.5
docker run -d --name elasticsearch --network host \
  -e "discovery.type=single-node" \
  -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
  -e "ELASTIC_PASSWORD=123456" \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.5

2. Verify Service Status

curl -k -u elastic:123456 https://localhost:9200

Example response:

{
  "name" : "ab0b562f8dbd",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "aVfOVgJTQXuhZ3HGotK3ww",
  "version" : {
    "number" : "8.15.5",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "b10896bcfe167cce44a84ba2771d101fb596d40d",
    "build_date" : "2024-11-21T22:06:13.985834967Z",
    "build_snapshot" : false,
    "lucene_version" : "9.11.1",
    "minimum_wire_compatibility_version" : "7.17.0",
    "minimum_index_compatibility_version" : "7.0.0"
  },
  "tagline" : "You Know, for Search"
}

3. Configure huatuo-bamai

Add the following configuration to huatuo-bamai.conf. The default username for the Elasticsearch container image is elastic; the password is set via the ELASTIC_PASSWORD environment variable. For a full description of storage configuration options, see the Configuration Guide.

[Storage.Elasticsearch]
    Address = "https://127.0.0.1:9200"
    Index = "huatuo_bamai"
    Username = "elastic"
    Password = "123456"

4. Start huatuo-bamai

Use --config-dir to specify the directory containing the configuration file:

./_output/bin/huatuo-bamai --region dev --config-dir .

When files (e.g., net_rx_latency) appear in the local storage directory huatuo-local/, kernel events have been successfully captured. Query data from Elasticsearch with:

curl -k -u elastic:123456 \
  -X GET "https://localhost:9200/huatuo_bamai/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query": {"match_all": {}}}'

Example response:

{
    "_index" : "huatuo_bamai",
    "_id" : "WtNZAJ4BQ8x-thPHEY1i",
    "_score" : 1.0,
    "_source" : {
      "hostname" : "hostname",
      "region" : "dev",
      "uploaded_timestamp" : "2026-05-07T02:51:37.696263325Z",
      "tracer_name" : "net_rx_latency",
      "observed_timestamp" : "2026-05-07T02:51:37.696263325Z",
      "tracer_type" : "event",
      "tracer_data" : {
        "comm" : "<nil>",
        "pid" : 0,
        "latency_stage" : "RX_STAGE_NETIF",
        "latency_ms" : 1776078133565,
        "tcp_saddr" : "127.0.0.1",
        "tcp_daddr" : "127.0.0.1",
        "tcp_sport" : 2379,
        "tcp_dport" : 36706,
        "tcp_seq" : 950542706,
        "tcp_ack_seq" : 1960972383,
        "packet_len_bytes" : 91
      }
    }
}

To get the total document count without listing individual records:

curl -k -u elastic:123456 -X GET "https://localhost:9200/huatuo_bamai/_count?pretty"

Example response: the count value equals the total number of written records.

{
  "count" : 2680,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  }
}

Elasticsearch V7

Elasticsearch V7 uses HTTP by default. Replace https with http in all commands.

1. Deploy Elasticsearch

docker pull docker.elastic.co/elasticsearch/elasticsearch:7.10.1
docker run -d --name elasticsearch --network host \
  -e "discovery.type=single-node" \
  -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" \
  -e "ELASTIC_PASSWORD=123456" \
  docker.elastic.co/elasticsearch/elasticsearch:7.10.1

2. Verify Service Status

curl -k -u elastic:123456 http://localhost:9200

Example response:

{
  "name" : "d88c9e8df48b",
  "cluster_name" : "docker-cluster",
  "cluster_uuid" : "_ZZefWx4SniAc255t_lIVg",
  "version" : {
    "number" : "7.10.1",
    "build_flavor" : "default",
    "build_type" : "docker",
    "build_hash" : "1c34507e66d7db1211f66f3513706fdf548736aa",
    "build_date" : "2020-12-05T01:00:33.671820Z",
    "build_snapshot" : false,
    "lucene_version" : "8.7.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  },
  "tagline" : "You Know, for Search"
}

3. Configure huatuo-bamai

[Storage.Elasticsearch]
    Address = "http://127.0.0.1:9200"
    Index = "huatuo_bamai"
    Username = "elastic"
    Password = "123456"

4. Start huatuo-bamai

Use --config-dir to specify the directory containing the configuration file:

./_output/bin/huatuo-bamai --region dev --config-dir .

When files (e.g., net_rx_latency) appear in the local storage directory huatuo-local/, kernel events have been successfully captured. Query data from Elasticsearch with:

curl -k -u elastic:123456 \
  -X GET "http://localhost:9200/huatuo_bamai/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{"query": {"match_all": {}}}'

To get the total document count:

curl -k -u elastic:123456 -X GET "http://localhost:9200/huatuo_bamai/_count?pretty"

βš™οΈ How It Works

System Architecture

The HUATUO Storage module runs on each node. It writes kernel events captured by the Tracer to the local directory and to Elasticsearch or OpenSearch. Both backends share the same [Storage.Elasticsearch] configuration interface and are differentiated by address.

The remote write path uses the ES/OpenSearch Bulk API (_bulk): events are queued in an in-memory buffer and submitted in batches by background workers based on size and time thresholds, with transport-layer retries on transient failures.

graph TB
    subgraph kernel["Linux Kernel"]
        K1[Kernel Events]
        K2[AutoTracing]
    end

    subgraph huatuo["HUATUO Agent (node-level)"]
        T["Tracer Layer"]
        L["Local Directory\nhuatuo-local/"]
        S["Storage Module\nBulkIndexer Buffer"]
    end

    subgraph backends["Storage Backends"]
        ES[Elasticsearch]
        OS[OpenSearch]
    end

    kernel --> T
    T --> L
    T --> S
    S -->|Bulk API + auto retry| ES
    S -->|Bulk API + auto retry| OS

Filter semantics

The internal storage query API combines filters with AND. SQLite and Elasticsearch share the following contract for non-null scalar values:

Operator Meaning
OpEq, OpNe Equal / not equal; missing and null values do not match
OpGt, OpGte, OpLt, OpLte Strict / inclusive range bounds
OpIn Membership in a non-empty slice or array of scalar values
OpExists, OpNotExists Has / lacks a non-null queryable value; omit Value

Empty strings are literal values: OpEq("") matches an empty string, while OpNe("") requires an existing, non-null, non-empty string. To query missing fields, use OpNotExists; OpEq("") no longer means field absence. Host profiling queries use OpNotExists on container_hostname to preserve their selection.

Operands accept built-in string, boolean, integer, and finite floating-point types. Range comparisons exclude booleans. time.Time and timeutil.Timestamp are normalized to UTC strings with nine fractional digits, including in OpIn. Nil, pointers, objects, nested collections, and non-finite numbers return driver.ErrInvalidQuery. Integer range bounds retain their original JSON precision; each backend’s supported numeric domain and field mapping still apply.

For Elasticsearch, callers must supply the exact indexed field: use the original field for a keyword mapping, or an explicitly configured keyword subfield such as hostname.keyword for a text mapping. The backend neither guesses subfields nor combines text and keyword queries. OpNe applies exists and must_not term to the same field. Full-text matching and array equality are outside this scalar comparison contract.

Elasticsearch existence refers to indexed values, not _source key presence. Mapping options such as ignore_above, null_value, and normalizers can change existence or equality behavior. Use mappings that preserve the intended scalar values when requiring equivalence with SQLite. A date field cannot be compared to an empty string; use an existence operator when the intent is a populated date.

Write Flow

Save returns immediately after the event is buffered. Background workers flush the buffer to the remote backend when any of the following triggers fire: byte threshold, time threshold, or process shutdown. The local directory write is synchronous and independent of the remote Bulk path.

sequenceDiagram
    participant T as Tracer Layer
    participant L as Local Directory (huatuo-local/)
    participant S as Storage Module (BulkIndexer)
    participant B as ES / OpenSearch

    T->>S: Kernel event captured, serialized to JSON
    par Local path (sync)
        S->>L: Write to local file
    and Remote path (async batch)
        S->>S: Enqueue into bulk buffer, return immediately
        Note over S: Flush on 5 MB / 1 s / shutdown
        S->>B: POST /_bulk (multiple records)
        B-->>S: 200 OK + per-item results
        Note over S: Failed items reported via OnFailure callback
    end

Bulk Write Mechanism

Buffering and Flush Triggers

Parameter Value Meaning
FlushBytes 5 MB Flush when accumulated bytes reach the threshold
FlushInterval 1 s Force-flush 1 second after the previous flush
NumWorkers 4 Concurrent workers submitting Bulk requests
Process shutdown Close(ctx) SIGTERM/SIGINT triggers a 10 s bounded drain

Two-Tier Retry Policy

Bulk failures are split into two layers with different retry semantics:

Layer Trigger Behavior Retried?
Whole-batch retry Transport error (connect / timeout / TLS)
HTTP status: 429 / 502 / 503 / 504
Client retries with exponential backoff: 100 ms β†’ 200 ms β†’ 400 ms β†’ 800 ms, up to 3 attempts βœ… auto
Whole-batch reject HTTP status: 400 / 401 / 403 / 404 / 413, etc. Not retried; all records in the batch are dropped, an error is logged via OnError ❌ drop
Per-item failure 200 OK with per-item error: version conflict, mapping error, document too large Not retried; only the failed item is dropped, OnFailure logs index/id/status/type/reason ❌ drop
Per-item success 200 OK with per-item success Primary write succeeded; replica failures produce a warning with index, document ID, and failed shard count β€”

Why this design: 429/5xx and transport errors signal transient remote unavailability where retries are effective; 4xx (except 429) and per-item errors are client-side semantic issues (data shape, permissions) where retries would only amplify the failure β€” they should be surfaced via logs for human investigation.

Batched Reads

Query(ctx, query, consume) calls the consumer synchronously for each nonempty batch. Limit caps the total records delivered after Offset matching records are skipped. storage.Store.Query requires a positive Limit and non-negative Offset and BatchSize before calling a backend; backends consume validated input. BatchSize controls each batch, defaults to 100 when zero, and is capped by the remaining total. Callers must supply a positive total limit when they need data. Count and Values use only Filters from the query and ignore Sorts, Limit, Offset, and BatchSize, including zero and negative pagination values. Count counts all matching records. Values uses its separate size argument, whose non-negative constraint is checked by storage.Store.Values before dispatch; backends do not repeat pagination or size validation for these operations. Local file storage remains write-only.

SQLite streams one SELECT ... LIMIT ... OFFSET ... through rows. A consumer must not query the same SQLite backend synchronously while rows hold its single connection. Job recovery reads 1000 Jobs at a time using ascending IDs, then starts their supervisors after the read closes; status changes cannot shift the next page. Elasticsearch 7/8 and OpenSearch use Scroll snapshots, including for offsets beyond the result window. Deep offsets still require reading and skipping earlier records. Separate API calls do not share a snapshot.

Each batch has independent ownership. Callback errors, decoding errors, backend errors, and cancellation stop the query immediately; earlier callback effects are not rolled back. Scroll cleanup uses a separate bounded context even after cancellation. Query requests are not retried because a lost Scroll response may already have advanced the cursor. Write retry behavior is unchanged.

API defaults apply only when limit is omitted: Job lists default to 100 with a maximum of 1000; raw Profile pages default to 20 with a maximum of 100 and retain the 64 MiB response budget. Explicit zero, negative, or oversized limits are rejected. One additional record is fetched to determine has_more.

Profile flamegraphs merge batches as they arrive, with a total budget of 100000 windows. One extra record detects overflow and rejects the query with an instruction to narrow its time range or filters. Read or merge failure never returns a partial flamegraph. Input batches can be released as processing advances; the merged profile still grows with distinct stacks.

Response Errors

HTTP success does not guarantee a complete result. Query and Values return an error when a search times out or a shard fails. Query stops before delivering the failing page; already consumed batches remain consumed. Values does not return partial results. Count returns zero and an error on shard failure. A search timeout takes precedence over shard failures. Shard errors include the failed shard count and the first failure’s available type, reason, and nested causes.

After an HTTP success response, DeleteByQuery checks for timeouts and failures in that order and returns the first error with the reported deleted count. HTTP errors return zero and an error, except that a missing index returns zero without an error. Any completed deletions are not rolled back. Direct writes and deletes retain primary-operation success when replicas fail; they log a warning with the available failure details. Bulk writes also report replica failures through warnings, without retrying the successful primary write.

HTTP errors include the response status and available Elasticsearch error details. Unrecognized or oversized error bodies are reported as text, limited to 4 KiB plus a truncation marker.

Data-Loss Scenarios

In all three scenarios below, Save returns nil but the event never reaches the index:

  1. Abnormal process exit: SIGKILL or host power loss drops whatever is still buffered in the BulkIndexer (the local directory still keeps a copy).
    • Mitigation: SIGTERM/SIGINT trigger graceful shutdown; Close force-flushes the buffer with a 10 s deadline.
  2. Whole-batch permanent rejection: 4xx (non-429) errors discard every record in the batch. Common causes: disabled index, expired credentials, document exceeding the cluster’s http.max_content_length.
    • Diagnosis: OnError log includes ES’s type and reason.
  3. Permanent per-item failure: mapping conflict, version conflict, malformed document.
    • Diagnosis: OnFailure log identifies the record by index/id.

The local directory is always a fallback: even if remote writes are lost, events remain available in huatuo-local/ as the eventual-consistency safety net.

Problems This Solves

Replacing per-event Index API calls with a buffered BulkIndexer + auto-retry addresses four classes of problems:

Problem Old approach bottleneck Bulk approach improvement
TLS handshake CPU cost One HTTPS handshake per event saturated CPU under FIPS/RSA-PSS Many events share one connection and one handshake; TLS PSK tickets cached
Remote RTT throughput ceiling One round-trip per event capped node-level write rate One Bulk request carries up to 5 MB; throughput scales with batch size
Transient remote jitter / 429 throttle A single failure dropped the event with no retry Client-level retry absorbs short-lived faults
Decoupling tracer layer from backend Slow remote backed pressure into capture, delaying tracing Async buffer decouples capture from network β€” capture is no longer blocked on remote latency

🌟 Stay Connected

Kernel observation time

Event documents can include a top-level kernel_observed_timestamp containing a UTC date-time. observed_timestamp is userspace observation time, and uploaded_timestamp is storage write time. These fields do not fill one another. Older documents and events without kernel timestamps omit the new field; historical data is not migrated automatically. The raw kernel_observed_ns stays in process for correlation and is not written to new documents.