Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- A new `readinessProbe` for KRaft controllers that fails when new pods cannot join the quorum.
Disabled controller scaling tests as they are failing now. See the documentation on known KRaft
scaling issues for details ([#1006]).

### Changed

- Internal operator refactoring: introduce a build() step in the reconciler that
Expand All @@ -28,6 +34,7 @@ All notable changes to this project will be documented in this file.
[#994]: https://github.com/stackabletech/kafka-operator/pull/994
[#998]: https://github.com/stackabletech/kafka-operator/pull/998
[#1000]: https://github.com/stackabletech/kafka-operator/pull/1000
[#1006]: https://github.com/stackabletech/kafka-operator/pull/1006

## [26.7.0] - 2026-07-21

Expand Down
27 changes: 23 additions & 4 deletions docs/modules/kafka/pages/usage-guide/kraft-controller.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,18 @@ KRaft mode requires major configuration changes compared to ZooKeeper:
* `cluster-id`: This is set to the `metadata.name` of the KafkaCluster resource during initial formatting
* `node.id`: This is a calculated integer, hashed from the `role` and `rolegroup` and added `replica` id.
* `process.roles`: Will always only be `broker` or `controller`. Mixed `broker,controller` servers are not supported.
* The operator configures a static voter list containing the controller pods. Controllers are not dynamically managed.
* The operator uses Kafka's dynamic KRaft quorum mechanism (KIP-853): controllers are pointed at
each other via `controller.quorum.bootstrap.servers`, and the initial voter set is established
by passing `--initial-controllers` to `kafka-storage.sh format`. The operator does not configure
a static `controller.quorum.voters` list. However, it does not perform the follow-up step
required to add a controller to an *already-formed* quorum's voter set (an explicit
`AddVoter`/`add-controller` request against the current leader, or enabling
`controller.quorum.auto.join.enable`) - see the Known Issues section below.

== Known Issues

* Automatic migration from Apache ZooKeeper to KRaft is not supported.
* Scaling controller replicas might lead to unstable clusters.
* Scaling controller replicas up is not supported; see <<_scaling_issues, Scaling issues>> below.
* Kerberos is currently not supported for KRaft in all versions.

== Troubleshooting
Expand All @@ -110,8 +116,21 @@ The Stackable Kafka operator currently does not support the migration.

=== Scaling issues

The https://developers.redhat.com/articles/2024/11/27/dynamic-kafka-controller-quorum[Dynamic scaling] is only supported from Kafka version 3.9.0.
If you are using older versions, automatic scaling may not work properly (e.g. adding or removing controller replicas).
Scaling `spec.controllers.roleGroups.<name>.replicas` up on a running cluster is not supported by the operator.

When a new controller pod is added, it registers itself and starts up, but it is never actually
admitted into the Raft quorum's voter set. It permanently reports the Raft `observer` state instead
of `leader`, `follower`, or `voted`.

Admitting a new voter into an existing quorum requires an explicit `AddVoter`/`add-controller`
request against the current leader (KIP-853), or a newer Kafka KIP-853 feature,
`controller.quorum.auto.join.enable`, that lets an observer auto-promote itself.

Until the operator supports the required quorum-reconfiguration step, do not plan on scaling
controller replica counts up or down on a running cluster. If a controller role group's replica
count needs to change, expect to need external, manual intervention against the running quorum
using Kafka's own KRaft tooling - there is currently no supported, automated migration path for
this in the operator.

== Kraft migration guide

Expand Down
224 changes: 202 additions & 22 deletions rust/operator-binary/src/controller/build/resource/statefulset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ use stackable_operator::{
STACKABLE_LOG_DIR, ValidatedContainerLogConfigChoice, vector_container,
},
role_group_utils::ResourceNames,
types::kubernetes::{ConfigMapKey, ContainerName, PersistentVolumeClaimName, VolumeName},
types::{
common::Port,
kubernetes::{ConfigMapKey, ContainerName, PersistentVolumeClaimName, VolumeName},
},
},
};

Expand Down Expand Up @@ -527,27 +530,30 @@ pub fn build_controller_rolegroup_statefulset(
.add_volume_mount(STACKABLE_LOG_DIR_NAME, STACKABLE_LOG_DIR)
.context(AddVolumeMountSnafu)?
.resources(merged_config.resources().clone().into())
// TODO: improve probes
.liveness_probe(Probe {
tcp_socket: Some(TCPSocketAction {
port: IntOrString::Int(kafka_security.client_port().into()),
..Default::default()
}),
timeout_seconds: Some(10),
period_seconds: Some(10),
failure_threshold: Some(6),
..Probe::default()
})
.readiness_probe(Probe {
tcp_socket: Some(TCPSocketAction {
port: IntOrString::Int(kafka_security.client_port().into()),
..Default::default()
}),
timeout_seconds: Some(10),
period_seconds: Some(10),
failure_threshold: Some(6),
..Probe::default()
});
// The controller listener socket only opens once the KRaft node has finished replaying
// its metadata log, which can take a while on a slow first boot or after a long outage.
// The startupProbe gives it up to 5 minutes (60 * 5s) before the liveness probe is
// allowed to start counting failures at all, so a slow (but progressing) boot is never
// mistaken for a stuck process.
.startup_probe(controller_tcp_probe(
kafka_security.client_port(),
/* timeout_seconds */ 5,
/* period_seconds */ 5,
/* failure_threshold */ 60,
))
// Liveness intentionally stays a plain TCP check, same as startupProbe
.liveness_probe(controller_tcp_probe(
kafka_security.client_port(),
/* timeout_seconds */ 10,
/* period_seconds */ 10,
/* failure_threshold */ 6,
))
.readiness_probe(controller_raft_state_probe(
METRICS_PORT,
/* timeout_seconds */ 10,
/* period_seconds */ 10,
/* failure_threshold */ 6,
));

add_log_config_volume(
&mut pod_builder,
Expand Down Expand Up @@ -640,6 +646,57 @@ pub fn build_controller_rolegroup_statefulset(
})
}

/// A `Probe` that dials the controller's KRaft listener socket via a plain TCP connect.
///
/// This only proves the socket is open, not that the node has a healthy Raft state (leader,
/// follower, or voted). It is intentionally still used for `startupProbe` (there is no
/// meaningful Raft state to check yet while the process is still starting) and for
/// `livenessProbe` (an unhealthy Raft state, e.g. `candidate`/`unattached`, means the node
/// cannot currently reach its peers, which restarting this pod cannot fix on its own).
fn controller_tcp_probe(
port: Port,
timeout_seconds: i32,
period_seconds: i32,
failure_threshold: i32,
) -> Probe {
Probe {
tcp_socket: Some(TCPSocketAction {
port: IntOrString::Int(port.into()),
..Default::default()
}),
timeout_seconds: Some(timeout_seconds),
period_seconds: Some(period_seconds),
failure_threshold: Some(failure_threshold),
..Probe::default()
}
}

/// A `Probe` that curls the JMX Prometheus exporter's `/metrics` endpoint and checks that the
/// controller's Raft state is one of the healthy states (`leader`, `follower`, or `voted`)
/// rather than stuck in `unattached` or `candidate`.
fn controller_raft_state_probe(
metrics_port: Port,
timeout_seconds: i32,
period_seconds: i32,
failure_threshold: i32,
) -> Probe {
Probe {
exec: Some(ExecAction {
command: Some(vec![
"bash".to_string(),
"-c".to_string(),
format!(
"curl -s localhost:{metrics_port}/metrics | grep -E 'kafka_server_raft_metrics_current_state\\{{state=\"(leader|follower|voted)\",?\\}}'"
),
]),
}),
timeout_seconds: Some(timeout_seconds),
period_seconds: Some(period_seconds),
failure_threshold: Some(failure_threshold),
..Probe::default()
}
}

/// We only expose client HTTP / HTTPS and Metrics ports.
fn container_ports(kafka_security: &ValidatedKafkaSecurity) -> Vec<ContainerPort> {
let mut ports = vec![
Expand Down Expand Up @@ -801,3 +858,126 @@ fn add_vector_container(
));
}
}

#[cfg(test)]
mod tests {
use stackable_operator::k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;

use crate::controller::{
build::build,
test_support::{minimal_kafka, validated_cluster},
};

/// A minimal KRaft cluster with one controller role group, resolved through the real
/// validate step (mirroring the fixtures in `build/mod.rs`'s own tests), since
/// `ValidatedCluster` carries several resolved types that are impractical to construct by
/// hand.
fn kraft_mode_cluster() -> crate::controller::ValidatedCluster {
let kafka = minimal_kafka(
r#"
apiVersion: kafka.stackable.tech/v1alpha1
kind: KafkaCluster
metadata:
name: simple-kafka
namespace: default
uid: 12345678-1234-1234-1234-123456789012
spec:
image:
productVersion: 3.9.2
clusterConfig:
metadataManager: kraft
controllers:
roleGroups:
default:
replicas: 3
brokers:
roleGroups:
default:
replicas: 3
"#,
);
validated_cluster(&kafka)
}

fn controller_kafka_container(
cluster: &crate::controller::ValidatedCluster,
) -> stackable_operator::k8s_openapi::api::core::v1::Container {
let resources = build(cluster).expect("build succeeds");
let sts = resources
.stateful_sets
.into_iter()
.find(|sts| sts.metadata.name.as_deref() == Some("simple-kafka-controller-default"))
.expect("the controller StatefulSet is built");
sts.spec
.expect("the StatefulSet has a spec")
.template
.spec
.expect("the pod template has a spec")
.containers
.into_iter()
.find(|c| c.name == "kafka")
.expect("the kafka container is built")
}

#[test]
fn controller_kafka_container_has_a_startup_probe() {
let cluster = kraft_mode_cluster();
let container = controller_kafka_container(&cluster);
let client_port = cluster.cluster_config.kafka_security.client_port();

let startup_probe = container
.startup_probe
.expect("the controller kafka container must have a startupProbe");
let tcp_socket = startup_probe
.tcp_socket
.expect("the startupProbe must be a tcpSocket check");
assert_eq!(tcp_socket.port, IntOrString::Int(client_port.into()));
assert_eq!(startup_probe.timeout_seconds, Some(5));
assert_eq!(startup_probe.period_seconds, Some(5));
assert_eq!(startup_probe.failure_threshold, Some(60));
}

#[test]
fn controller_kafka_container_liveness_probe_is_a_plain_tcp_check() {
let cluster = kraft_mode_cluster();
let container = controller_kafka_container(&cluster);
let client_port = cluster.cluster_config.kafka_security.client_port();

// Liveness intentionally stays a bare TCP check, not the Raft-state exec probe used for
// readiness: an unreachable-quorum Raft state is not something restarting this pod can
// fix, so liveness must not fail on it.
let liveness_probe = container
.liveness_probe
.expect("the controller kafka container must have a livenessProbe");
let tcp_socket = liveness_probe
.tcp_socket
.expect("the livenessProbe must be a tcpSocket check, not an exec check");
assert_eq!(tcp_socket.port, IntOrString::Int(client_port.into()));
assert_eq!(liveness_probe.timeout_seconds, Some(10));
assert_eq!(liveness_probe.period_seconds, Some(10));
assert_eq!(liveness_probe.failure_threshold, Some(6));
}

#[test]
fn controller_kafka_container_readiness_probe_checks_raft_state() {
let cluster = kraft_mode_cluster();
let container = controller_kafka_container(&cluster);

let readiness_probe = container.readiness_probe.expect("readiness probe is set");
let exec = readiness_probe
.exec
.expect("readiness probe is an exec check");
let command = exec.command.expect("exec has a command");
assert_eq!(
command,
vec![
"bash".to_string(),
"-c".to_string(),
"curl -s localhost:9606/metrics | grep -E 'kafka_server_raft_metrics_current_state\\{state=\"(leader|follower|voted)\",?\\}'".to_string(),
]
);
assert_eq!(readiness_probe.timeout_seconds, Some(10));
assert_eq!(readiness_probe.period_seconds, Some(10));
assert_eq!(readiness_probe.failure_threshold, Some(6));
}
}
Loading