Skip to content

Commit e6861f0

Browse files
committed
feat: add UDP support for load balancing
1 parent e08527f commit e6861f0

8 files changed

Lines changed: 273 additions & 14 deletions

File tree

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
English | [中文](README_CN.md)
44

5-
A lightweight Layer-4 TCP load balancer based on Linux IPVS, using declarative reconcile mode to dynamically manage IPVS services.
5+
A lightweight Layer-4 TCP/UDP load balancer based on Linux IPVS, using declarative reconcile mode to dynamically manage IPVS services.
66

77
## Features
88

9-
- **IPVS Kernel-Level Load Balancing**: High-performance Layer-4 forwarding powered by Linux IPVS
9+
- **IPVS Kernel-Level Load Balancing**: High-performance Layer-4 TCP/UDP forwarding powered by Linux IPVS
1010
- **Declarative Reconcile**: Automatically compares desired state with actual IPVS rules and applies incremental changes
1111
- **Multiple Scheduling Algorithms**: Round Robin (rr), Weighted Round Robin (wrr), Least Connection (lc), Weighted Least Connection (wlc), Destination Hashing (dh), Source Hashing (sh)
1212
- **TCP & HTTP Health Checks**: Independent health check configuration per service, supporting TCP connection probes and HTTP GET probes with configurable path and expected status code
@@ -70,6 +70,18 @@ services:
7070
weight: 1
7171
- address: 192.168.2.11:8443
7272
weight: 1
73+
74+
- name: dns-service
75+
listen: 10.0.0.2:53
76+
protocol: udp # UDP load balancing
77+
scheduler: rr
78+
health_check:
79+
enabled: false
80+
backends:
81+
- address: 192.168.3.10:53
82+
weight: 1
83+
- address: 192.168.3.11:53
84+
weight: 1
7385
```
7486
7587
### Usage

README_CN.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
[English](README.md) | 中文
44

5-
基于 Linux IPVS 的四层 TCP 负载均衡工具,采用声明式 Reconcile 模式动态管理 IPVS 服务。
5+
基于 Linux IPVS 的四层 TCP/UDP 负载均衡工具,采用声明式 Reconcile 模式动态管理 IPVS 服务。
66

77
## 特性
88

9-
- **IPVS 内核级负载均衡**:基于 Linux IPVS 实现高性能四层转发
9+
- **IPVS 内核级负载均衡**:基于 Linux IPVS 实现高性能四层 TCP/UDP 转发
1010
- **声明式 Reconcile**:自动对比期望状态与实际 IPVS 规则,增量同步变更
1111
- **多种调度算法**:支持轮询 (rr)、加权轮询 (wrr)、最少连接 (lc)、加权最少连接 (wlc)、目标地址哈希 (dh)、源地址哈希 (sh)
1212
- **TCP & HTTP 健康检查**:每个服务独立配置检查参数,支持 TCP 连接探测和 HTTP GET 探测(可配置路径和期望状态码)
@@ -70,6 +70,18 @@ services:
7070
weight: 1
7171
- address: 192.168.2.11:8443
7272
weight: 1
73+
74+
- name: dns-service
75+
listen: 10.0.0.2:53
76+
protocol: udp # UDP 负载均衡
77+
scheduler: rr
78+
health_check:
79+
enabled: false
80+
backends:
81+
- address: 192.168.3.10:53
82+
weight: 1
83+
- address: 192.168.3.11:53
84+
weight: 1
7385
```
7486
7587
### 运行

cmd/ezlb/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
var (
1717
BuildTime string
1818
BuildCommit string
19-
Version = "0.1.6"
19+
Version = "0.2.0"
2020
configPath string
2121
showVersion bool
2222
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
### 扩展支持UDP流量负载均衡 ###
2+
在现有 TCP 负载均衡基础上扩展支持 UDP 协议。由于 IPVS 底层和类型转换层已完全支持 UDP,核心改动集中在配置校验层,同时需要处理同 IP:Port 不同协议的服务共存场景,并补充测试和文档。
3+
4+
5+
## 现状分析
6+
7+
项目的 IPVS 操作层(`pkg/lvs/`)已经完全支持 UDP 协议:
8+
- `pkg/lvs/types.go``protocolFromString`/`protocolToString` 已处理 `"udp"` <-> `syscall.IPPROTO_UDP`
9+
- `pkg/lvs/ipvs_models.go``Service.Protocol``uint16`,天然支持 TCP/UDP
10+
- `pkg/lvs/ipvs_handle_linux.go``ipvs_handle_fake.go` 通过 protocol 字段区分服务,无需修改
11+
- `pkg/lvs/types_test.go` 已有 UDP 协议转换测试
12+
13+
**唯一的阻塞点**在配置校验层 `pkg/config/config.go`,以及相关的测试和文档。
14+
15+
## 实施步骤
16+
17+
### 步骤 1:修改配置校验 — 允许 UDP 协议
18+
19+
**文件**: `pkg/config/config.go`
20+
21+
1.`validProtocols``{"tcp": true}` 扩展为 `{"tcp": true, "udp": true}`
22+
2. 修改 `Validate` 函数中协议校验的错误信息,将 `(supported: tcp)` 改为 `(supported: tcp, udp)`
23+
3. 修改 `listenSet` 的去重 key,从单纯的 `svc.Listen`(即 `IP:Port`)改为 `svc.Listen + "/" + protocol`,因为 IPVS 允许同一 IP:Port 同时存在 TCP 和 UDP 服务
24+
25+
### 步骤 2:补充配置校验单元测试
26+
27+
**文件**: `pkg/config/config_test.go`
28+
29+
新增测试用例:
30+
- `TestValidate_ProtocolUDP` — protocol 为 `udp` 时校验通过
31+
- `TestValidate_ProtocolEmptyDefaultsTCP` — 已有,确认 UDP 不影响默认行为
32+
- `TestValidate_SameListenDifferentProtocol` — 同一 IP:Port 分别配置 TCP 和 UDP 服务,校验通过(不报重复)
33+
- `TestValidate_SameListenSameProtocolDuplicate` — 同一 IP:Port 同一协议,校验报重复
34+
35+
### 步骤 3:补充 Reconciler 单元测试
36+
37+
**文件**: `pkg/lvs/reconciler_test.go`
38+
39+
新增测试用例:
40+
- `TestReconcile_UDPService` — 验证 UDP 协议的 service 能正确创建和 reconcile
41+
- `TestReconcile_TCPAndUDPSameAddress` — 验证同一 IP:Port 的 TCP 和 UDP 服务能共存且独立管理
42+
43+
### 步骤 4:更新示例配置
44+
45+
**文件**: `examples/ezlb.yaml`
46+
47+
新增一个 UDP 服务的示例配置,如 DNS 负载均衡场景:
48+
```yaml
49+
- name: dns-service
50+
listen: 10.0.0.3:53
51+
protocol: udp
52+
scheduler: rr
53+
health_check:
54+
enabled: false
55+
backends:
56+
- address: 192.168.4.10:53
57+
weight: 1
58+
- address: 192.168.4.11:53
59+
weight: 1
60+
```
61+
62+
### 步骤 5:更新文档
63+
64+
**文件**: `README.md`、`README_CN.md`
65+
66+
1. 特性描述从 "Layer-4 TCP load balancer" 改为 "Layer-4 TCP/UDP load balancer"
67+
2. 配置示例中增加 UDP 服务示例
68+
3. 说明 `protocol` 字段支持 `tcp`(默认)和 `udp`
69+
70+
## 不需要修改的模块
71+
72+
- **`pkg/lvs/types.go`** — 已支持 UDP 协议转换
73+
- **`pkg/lvs/manager.go`** — 协议无关,无需修改
74+
- **`pkg/lvs/reconciler.go`** — 通过 `ServiceKey`(含 Protocol)区分服务,已支持
75+
- **`pkg/lvs/ipvs_handle*.go`** — 底层 IPVS 操作已支持 UDP
76+
- **`pkg/healthcheck/`** — 健康检查类型(TCP/HTTP)与服务协议独立,UDP 服务可选择禁用健康检查或使用 TCP/HTTP 探测
77+
- **`pkg/server/`** — 无协议相关逻辑
78+
79+
80+
updateAtTime: 2026/2/12 08:09:18
81+
82+
planId: 845ead06-d442-4206-aae1-5044843630b5

examples/ezlb.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,15 @@ services:
5050
weight: 1
5151
- address: 192.168.3.11:9090
5252
weight: 1
53+
54+
- name: dns-service
55+
listen: 10.0.0.3:53
56+
protocol: udp
57+
scheduler: rr
58+
health_check:
59+
enabled: false
60+
backends:
61+
- address: 192.168.4.10:53
62+
weight: 1
63+
- address: 192.168.4.11:53
64+
weight: 1

pkg/config/config.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ var validSchedulers = map[string]bool{
143143
// validProtocols is the set of supported protocols.
144144
var validProtocols = map[string]bool{
145145
"tcp": true,
146+
"udp": true,
146147
}
147148

148149
// Manager handles configuration loading, validation, and hot-reload.
@@ -227,22 +228,23 @@ func Validate(cfg *Config) error {
227228
return fmt.Errorf("service %q: listen port must be a positive number", svc.Name)
228229
}
229230

230-
listenKey := svc.Listen
231-
if listenSet[listenKey] {
232-
return fmt.Errorf("service %q: duplicate listen address %q", svc.Name, svc.Listen)
233-
}
234-
listenSet[listenKey] = true
235-
236231
// Validate protocol (default to tcp)
237232
protocol := svc.Protocol
238233
if protocol == "" {
239234
cfg.Services[i].Protocol = "tcp"
240235
protocol = "tcp"
241236
}
242237
if !validProtocols[protocol] {
243-
return fmt.Errorf("service %q: unsupported protocol %q (supported: tcp)", svc.Name, protocol)
238+
return fmt.Errorf("service %q: unsupported protocol %q (supported: tcp, udp)", svc.Name, protocol)
244239
}
245240

241+
// Deduplicate by listen address + protocol (IPVS allows same IP:Port for different protocols)
242+
listenKey := svc.Listen + "/" + protocol
243+
if listenSet[listenKey] {
244+
return fmt.Errorf("service %q: duplicate listen address %q for protocol %q", svc.Name, svc.Listen, protocol)
245+
}
246+
listenSet[listenKey] = true
247+
246248
// Validate scheduler
247249
if !validSchedulers[svc.Scheduler] {
248250
return fmt.Errorf("service %q: unsupported scheduler %q (supported: rr, wrr, lc, wlc, dh, sh)", svc.Name, svc.Scheduler)

pkg/config/config_test.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func TestValidate_ListenAddressDuplicate(t *testing.T) {
107107
svc1 := validServiceConfig()
108108
svc2 := validServiceConfig()
109109
svc2.Name = "test-svc-2"
110-
// same listen address as svc1
110+
// same listen address and protocol as svc1
111111
cfg := &Config{Services: []ServiceConfig{svc1, svc2}}
112112
err := Validate(cfg)
113113
if err == nil {
@@ -127,15 +127,52 @@ func TestValidate_ProtocolEmptyDefaultsTCP(t *testing.T) {
127127
}
128128
}
129129

130-
func TestValidate_ProtocolUnsupported(t *testing.T) {
130+
func TestValidate_ProtocolUDP(t *testing.T) {
131131
cfg := validConfig()
132132
cfg.Services[0].Protocol = "udp"
133133
err := Validate(cfg)
134+
if err != nil {
135+
t.Fatalf("expected valid config with udp protocol, got: %v", err)
136+
}
137+
}
138+
139+
func TestValidate_ProtocolUnsupported(t *testing.T) {
140+
cfg := validConfig()
141+
cfg.Services[0].Protocol = "sctp"
142+
err := Validate(cfg)
134143
if err == nil {
135144
t.Fatal("expected error for unsupported protocol, got nil")
136145
}
137146
}
138147

148+
func TestValidate_SameListenDifferentProtocol(t *testing.T) {
149+
svc1 := validServiceConfig()
150+
svc1.Protocol = "tcp"
151+
svc2 := validServiceConfig()
152+
svc2.Name = "test-svc-udp"
153+
svc2.Protocol = "udp"
154+
// Same listen address, different protocol — should be allowed
155+
cfg := &Config{Services: []ServiceConfig{svc1, svc2}}
156+
err := Validate(cfg)
157+
if err != nil {
158+
t.Fatalf("expected same listen address with different protocols to be valid, got: %v", err)
159+
}
160+
}
161+
162+
func TestValidate_SameListenSameProtocolDuplicate(t *testing.T) {
163+
svc1 := validServiceConfig()
164+
svc1.Protocol = "udp"
165+
svc2 := validServiceConfig()
166+
svc2.Name = "test-svc-2"
167+
svc2.Protocol = "udp"
168+
// Same listen address, same protocol — should be rejected
169+
cfg := &Config{Services: []ServiceConfig{svc1, svc2}}
170+
err := Validate(cfg)
171+
if err == nil {
172+
t.Fatal("expected error for duplicate listen address with same protocol, got nil")
173+
}
174+
}
175+
139176
func TestValidate_SchedulerUnsupported(t *testing.T) {
140177
cfg := validConfig()
141178
cfg.Services[0].Scheduler = "random"

pkg/lvs/reconciler_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package lvs
22

33
import (
4+
"syscall"
45
"testing"
56

67
"github.com/easzlab/ezlb/pkg/config"
@@ -488,6 +489,107 @@ func TestReconcile_BackendRecovery(t *testing.T) {
488489
}
489490
}
490491

492+
// --- UDP protocol tests ---
493+
494+
func TestReconcile_UDPService(t *testing.T) {
495+
mgr, _, reconciler := newReconcilerTestEnv(t)
496+
defer mgr.Close()
497+
498+
configs := []config.ServiceConfig{
499+
{
500+
Name: "dns-svc",
501+
Listen: "10.0.0.1:53",
502+
Protocol: "udp",
503+
Scheduler: "rr",
504+
HealthCheck: config.HealthCheckConfig{
505+
Enabled: boolPtr(false),
506+
},
507+
Backends: []config.BackendConfig{
508+
makeBackend("192.168.1.1:53", 1),
509+
makeBackend("192.168.1.2:53", 1),
510+
},
511+
},
512+
}
513+
514+
if err := reconciler.Reconcile(configs); err != nil {
515+
t.Fatalf("Reconcile failed: %v", err)
516+
}
517+
518+
services, err := mgr.GetServices()
519+
if err != nil {
520+
t.Fatalf("GetServices failed: %v", err)
521+
}
522+
if len(services) != 1 {
523+
t.Fatalf("expected 1 service, got %d", len(services))
524+
}
525+
if services[0].Protocol != syscall.IPPROTO_UDP {
526+
t.Errorf("expected protocol IPPROTO_UDP (%d), got %d", syscall.IPPROTO_UDP, services[0].Protocol)
527+
}
528+
529+
dests, err := mgr.GetDestinations(services[0])
530+
if err != nil {
531+
t.Fatalf("GetDestinations failed: %v", err)
532+
}
533+
if len(dests) != 2 {
534+
t.Fatalf("expected 2 destinations, got %d", len(dests))
535+
}
536+
}
537+
538+
func TestReconcile_TCPAndUDPSameAddress(t *testing.T) {
539+
mgr, _, reconciler := newReconcilerTestEnv(t)
540+
defer mgr.Close()
541+
542+
configs := []config.ServiceConfig{
543+
{
544+
Name: "dns-tcp",
545+
Listen: "10.0.0.1:53",
546+
Protocol: "tcp",
547+
Scheduler: "rr",
548+
HealthCheck: config.HealthCheckConfig{
549+
Enabled: boolPtr(false),
550+
},
551+
Backends: []config.BackendConfig{
552+
makeBackend("192.168.1.1:53", 1),
553+
},
554+
},
555+
{
556+
Name: "dns-udp",
557+
Listen: "10.0.0.1:53",
558+
Protocol: "udp",
559+
Scheduler: "rr",
560+
HealthCheck: config.HealthCheckConfig{
561+
Enabled: boolPtr(false),
562+
},
563+
Backends: []config.BackendConfig{
564+
makeBackend("192.168.1.2:53", 2),
565+
},
566+
},
567+
}
568+
569+
if err := reconciler.Reconcile(configs); err != nil {
570+
t.Fatalf("Reconcile failed: %v", err)
571+
}
572+
573+
services, err := mgr.GetServices()
574+
if err != nil {
575+
t.Fatalf("GetServices failed: %v", err)
576+
}
577+
if len(services) != 2 {
578+
t.Fatalf("expected 2 services (TCP + UDP), got %d", len(services))
579+
}
580+
581+
// Verify each service has its own destinations
582+
for _, svc := range services {
583+
dests, err := mgr.GetDestinations(svc)
584+
if err != nil {
585+
t.Fatalf("GetDestinations failed: %v", err)
586+
}
587+
if len(dests) != 1 {
588+
t.Errorf("expected 1 destination per service, got %d for protocol %d", len(dests), svc.Protocol)
589+
}
590+
}
591+
}
592+
491593
// --- Error handling ---
492594

493595
func TestReconcile_InvalidListenAddress(t *testing.T) {

0 commit comments

Comments
 (0)