inlet/kafka: make Kafka load-balancing algorithm configurable
Some checks failed
CI / 🤖 Check dependabot status (push) Has been cancelled
CI / 🐧 Test on Linux (${{ github.ref_type == 'tag' }}, misc) (push) Has been cancelled
CI / 🐧 Test on Linux (coverage) (push) Has been cancelled
CI / 🐧 Test on Linux (regular) (push) Has been cancelled
CI / ❄️ Build on Nix (push) Has been cancelled
CI / 🍏 Build and test on macOS (push) Has been cancelled
CI / 🧪 End-to-end testing (push) Has been cancelled
CI / 🔍 Upload code coverage (push) Has been cancelled
CI / 🔬 Test only Go (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 20) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 22) (push) Has been cancelled
CI / 🔬 Test only JS (${{ needs.dependabot.outputs.package-ecosystem }}, 24) (push) Has been cancelled
CI / ⚖️ Check licenses (push) Has been cancelled
CI / 🐋 Build Docker images (push) Has been cancelled
CI / 🐋 Tag Docker images (push) Has been cancelled
CI / 🚀 Publish release (push) Has been cancelled
Update Nix dependency hashes / Update dependency hashes (push) Has been cancelled

And use random by default. This scales better. And even when not using
multiple outlets, there is little drawback to pin an exporter to a
partition.
This commit is contained in:
Vincent Bernat
2025-11-25 22:42:33 +01:00
parent fa7036427b
commit 11f878ca21
6 changed files with 87 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ GENERATED_GO = \
orchestrator/clickhouse/data/tcp.csv \
orchestrator/clickhouse/data/udp.csv \
console/filter/parser.go \
inlet/kafka/loadbalancealgorithm_enumer.go \
outlet/core/asnprovider_enumer.go \
outlet/core/netprovider_enumer.go \
outlet/metadata/provider/snmp/authprotocol_enumer.go \
@@ -105,6 +106,8 @@ inlet/flow/input/udp/reuseport_%.o: inlet/flow/input/udp/reuseport_kern.c inlet/
$Q ! $(CLANG) -print-targets 2> /dev/null | grep -qF $* || \
$(CLANG) -O2 -g -Wall -target $* -c $< -o $@
inlet/kafka/loadbalancealgorithm_enumer.go: go.mod inlet/kafka/config.go ; $(info $(M) generate enums for LoadBalanceAlgorithm)
$Q $(ENUMER) -type=LoadBalanceAlgorithm -text -transform=kebab -trimprefix=LoadBalance inlet/kafka/config.go
outlet/core/asnprovider_enumer.go: go.mod outlet/core/config.go ; $(info $(M) generate enums for ASNProvider)
$Q $(ENUMER) -type=ASNProvider -text -transform=kebab -trimprefix=ASNProvider outlet/core/config.go
outlet/core/netprovider_enumer.go: go.mod outlet/core/config.go ; $(info $(M) generate enums for NetProvider)

View File

@@ -132,6 +132,13 @@ The following keys are accepted:
- `compression-codec` defines the compression codec for messages: `none`,
`gzip`, `snappy`, `lz4` (default), or `zstd`.
- `queue-size` defines the maximum number of messages to buffer for Kafka.
- `load-balance` defines the load balancing algorithm for flows accross Kafka
partitions. The default value is `random`: each flow is assigned a random
partition, ensuring an even distribution. The other possible value is
`by-exporter`: all flows from a given exporter is assigned to a single
partition. This setting can be important if you have several outlets and IPFIX
or NetFlow: each outlet needs to receive the templates before decoding flows
and this is less likely when using `random`.
A version number is automatically added to the topic name. This is to prevent
problems if the protobuf schema changes in a way that is not

View File

@@ -13,6 +13,8 @@ identified with a specific icon:
## Unreleased
- 🩹 *docker*: restart geoip container on boot
- 🌱 *inlet*: make load-balancing algorithm for Kafka partitions configurable
(`random` or `by-exporter`) and revert back to `random` by default (like before 2.0.3)
- 🌱 *orchestrator*: add `kafka``manage-topic` flag to enable or disable topic management
- 🌱 *cmd*: make `akvorado healthcheck` use an abstract Unix socket to check service liveness

View File

@@ -19,6 +19,8 @@ type Configuration struct {
CompressionCodec CompressionCodec
// QueueSize defines the maximum number of messages to buffer.
QueueSize int `validate:"min=1"`
// LoadBalance defines the load-balancing algorithm to use for Kafka partitions.
LoadBalance LoadBalanceAlgorithm
}
// DefaultConfiguration represents the default configuration for the Kafka exporter.
@@ -27,9 +29,20 @@ func DefaultConfiguration() Configuration {
Configuration: kafka.DefaultConfiguration(),
CompressionCodec: CompressionCodec(kgo.Lz4Compression()),
QueueSize: 4096,
LoadBalance: LoadBalanceRandom,
}
}
// LoadBalanceAlgorithm represents the load-balance algorithm for Kafka partitions
type LoadBalanceAlgorithm int
const (
// LoadBalanceRandom randomly balances flows accross Kafka partitions.
LoadBalanceRandom LoadBalanceAlgorithm = iota
// LoadBalanceByExporter hashes exporter IP addresses for load balancing.
LoadBalanceByExporter
)
// CompressionCodec represents a compression codec.
type CompressionCodec kgo.CompressionCodec

View File

@@ -6,7 +6,9 @@ package kafka
import (
"context"
"encoding/binary"
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -107,9 +109,15 @@ func (c *Component) Stop() error {
// Send a message to Kafka.
func (c *Component) Send(exporter string, payload []byte, finalizer func()) {
key := []byte(exporter)
switch c.config.LoadBalance {
case LoadBalanceRandom:
key = make([]byte, 4)
binary.BigEndian.PutUint32(key, rand.Uint32())
}
record := &kgo.Record{
Topic: c.kafkaTopic,
Key: []byte(exporter),
Key: key,
Value: payload,
}
c.kafkaClient.Produce(context.Background(), record, func(r *kgo.Record, err error) {

View File

@@ -126,3 +126,56 @@ func TestKafka(t *testing.T) {
t.Fatalf("Metrics (-got, +want):\n%s", diff)
}
}
func TestLoadBalancingAlgorithm(t *testing.T) {
for _, algo := range []LoadBalanceAlgorithm{LoadBalanceRandom, LoadBalanceByExporter} {
t.Run(algo.String(), func(t *testing.T) {
topic := fmt.Sprintf("balance-%s", algo)
r := reporter.NewMock(t)
config := DefaultConfiguration()
config.QueueSize = 1
config.Topic = topic
c, mock := NewMock(t, r, config)
defer mock.Close()
total := 500
// Intercept messages
var wg sync.WaitGroup
wg.Add(total)
var mu sync.Mutex
messages := make(map[int32]int)
kafka.InterceptMessages(t, mock, func(r *kgo.Record) {
mu.Lock()
defer mu.Unlock()
messages[r.Partition]++
wg.Done()
})
// Send messages
for i := range total {
c.Send("127.0.0.1", []byte(fmt.Sprintf("hello %d", i)), func() {})
}
wg.Wait()
expected := make(map[int32]int, len(messages))
if algo == LoadBalanceRandom {
for p, count := range messages {
if count > total/len(messages)*6/10 && count < total/len(messages)*14/10 {
expected[p] = count
} else {
expected[p] = total / len(messages)
}
}
} else if algo == LoadBalanceByExporter {
for p, count := range messages {
expected[p] = count
}
}
if diff := helpers.Diff(messages, expected); diff != "" {
t.Fatalf("Messages per partitions (-got, +want):\n%s", diff)
}
})
}
}