Skip to content

Commit 08327da

Browse files
Make keepalive and timeouts settable per load balancer rule
#12586 made the idle timeout global. Keepalive is set on the network offering, at create time only, with no UI. Both land in the haproxy defaults section, so every rule on a router shares them. Three optional parameters on createLoadBalancerRule and updateLoadBalancerRule, each written to that rule's own listen section: | parameter | haproxy directive | unset | | keepalive | option http-keep-alive / httpclose | offering | | idletimeout | timeout client, timeout server | global | | keepalivetimeout | timeout http-keep-alive | idletimeout| Held as firewall rule details, so no schema change. A rule on port 80 can now have keepalive and X-Forwarded-For together, which no combination of the existing settings can produce: offering flag off mode http closes per response XFF works offering flag on mode tcp reuses connections XFF lost per rule mode http reuses connections XFF works A rule that sets nothing behaves exactly as before, including the fall back to tcp mode when the offering flag is on. Negative timeouts are rejected at the API and dropped in the generator. Haproxy treats one as a fatal parse error, which would strand every rule on the router. Applies to public LB rules. Application load balancers are created through a different command and are not covered. Signed-off-by: Brad House <bhouse@nexthop.ai>
1 parent e0521a3 commit 08327da

15 files changed

Lines changed: 449 additions & 14 deletions

File tree

api/src/main/java/com/cloud/agent/api/to/LoadBalancerTO.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ public class LoadBalancerTO {
5454
final static int MAX_HEALTHCHECK_POLICIES = 1;
5555

5656
private String cidrList;
57+
private Boolean keepAlive;
58+
private Long idleTimeout;
59+
private Long keepAliveTimeout;
5760

5861
public LoadBalancerTO(String uuid, String srcIp, int srcPort, String protocol, String algorithm, boolean revoked, boolean alreadyAdded, boolean inline,
5962
List<LbDestination> destinations) {
@@ -249,6 +252,30 @@ public String getCidrList() {
249252
return cidrList;
250253
}
251254

255+
public void setKeepAlive(Boolean keepAlive) {
256+
this.keepAlive = keepAlive;
257+
}
258+
259+
public Boolean getKeepAlive() {
260+
return keepAlive;
261+
}
262+
263+
public void setIdleTimeout(Long idleTimeout) {
264+
this.idleTimeout = idleTimeout;
265+
}
266+
267+
public Long getIdleTimeout() {
268+
return idleTimeout;
269+
}
270+
271+
public void setKeepAliveTimeout(Long keepAliveTimeout) {
272+
this.keepAliveTimeout = keepAliveTimeout;
273+
}
274+
275+
public Long getKeepAliveTimeout() {
276+
return keepAliveTimeout;
277+
}
278+
252279
public static class StickinessPolicyTO {
253280
private String methodName;
254281
private List<Pair<String, String>> params;

api/src/main/java/com/cloud/network/lb/LoadBalancingRulesService.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ LoadBalancer createPublicLoadBalancerRule(String xId, String name, String descri
7070

7171
LoadBalancer updateLoadBalancerRule(UpdateLoadBalancerRuleCmd cmd);
7272

73+
/**
74+
* Stores the optional per rule haproxy settings. A null leaves the current value alone.
75+
*
76+
* @return true if any value changed
77+
*/
78+
boolean updateLoadBalancerConnectionSettings(long lbRuleId, Boolean keepAlive, Long idleTimeout, Long keepAliveTimeout);
79+
7380
boolean deleteLoadBalancerRule(long lbRuleId, boolean apply);
7481

7582
/**

api/src/main/java/com/cloud/network/rules/LoadBalancer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
*/
2222
public interface LoadBalancer extends FirewallRule, LoadBalancerContainer {
2323

24+
// Optional per rule haproxy settings, held as firewall rule details. Absent means inherit.
25+
String KEEPALIVE = "keepalive";
26+
String IDLE_TIMEOUT = "idletimeout";
27+
String KEEPALIVE_TIMEOUT = "keepalivetimeout";
28+
2429
int getDefaultPortStart();
2530

2631
int getDefaultPortEnd();

api/src/main/java/org/apache/cloudstack/api/ApiConstants.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ public class ApiConstants {
357357
public static final String IS_ISO = "isiso";
358358
public static final String IS_PORTABLE = "isportable";
359359
public static final String IS_PUBLIC = "ispublic";
360+
public static final String IDLE_TIMEOUT = "idletimeout";
360361
public static final String IS_PERSISTENT = "ispersistent";
361362
public static final String EGRESS_DEFAULT_POLICY = "egressdefaultpolicy";
362363
public static final String IS_READY = "isready";
@@ -368,6 +369,8 @@ public class ApiConstants {
368369
public static final String JAVA_VERSION = "javaversion";
369370
public static final String JOB_ID = "jobid";
370371
public static final String JOB_STATUS = "jobstatus";
372+
public static final String KEEPALIVE = "keepalive";
373+
public static final String KEEPALIVE_TIMEOUT = "keepalivetimeout";
371374
public static final String KEEPALIVE_ENABLED = "keepaliveenabled";
372375
public static final String KERNEL_VERSION = "kernelversion";
373376
public static final String KEYPAIR_ID = "keypairid";

api/src/main/java/org/apache/cloudstack/api/command/user/loadbalancer/CreateLoadBalancerRuleCmd.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ public class CreateLoadBalancerRuleCmd extends BaseAsyncCreateCmd /*implements L
119119
@Parameter(name = ApiConstants.FOR_DISPLAY, type = CommandType.BOOLEAN, description = "An optional field, whether to the display the rule to the end user or not", since = "4.4", authorized = {RoleType.Admin})
120120
private Boolean display;
121121

122+
@Parameter(name = ApiConstants.KEEPALIVE, type = CommandType.BOOLEAN, since = "4.23.0",
123+
description = "Whether the load balancer keeps client connections open between requests. "
124+
+ "Only applies to rules the router serves in HTTP mode. If not set, the network offering's setting is used.")
125+
private Boolean keepAlive;
126+
127+
@Parameter(name = ApiConstants.IDLE_TIMEOUT, type = CommandType.LONG, since = "4.23.0",
128+
description = "How long an idle connection is held open, in milliseconds. Use 0 for infinite. "
129+
+ "If not set, the global setting network.loadbalancer.haproxy.idle.timeout is used.")
130+
private Long idleTimeout;
131+
132+
@Parameter(name = ApiConstants.KEEPALIVE_TIMEOUT, type = CommandType.LONG, since = "4.23.0",
133+
description = "How long an idle keepalive connection is held open waiting for the next request, "
134+
+ "in milliseconds. Only applies to rules the router serves in HTTP mode. "
135+
+ "If not set, idletimeout applies.")
136+
private Long keepAliveTimeout;
137+
122138
/////////////////////////////////////////////////////
123139
/////////////////// Accessors ///////////////////////
124140
/////////////////////////////////////////////////////
@@ -135,6 +151,18 @@ public boolean isDisplay() {
135151
public String getAlgorithm() {
136152
return algorithm;
137153
}
154+
public Boolean getKeepAlive() {
155+
return keepAlive;
156+
}
157+
158+
public Long getIdleTimeout() {
159+
return idleTimeout;
160+
}
161+
public Long getKeepAliveTimeout() {
162+
return keepAliveTimeout;
163+
}
164+
165+
138166

139167
public String getDescription() {
140168
return description;
@@ -307,6 +335,7 @@ public void create() {
307335
getCidrList());
308336
this.setEntityId(result.getId());
309337
this.setEntityUuid(result.getUuid());
338+
_lbService.updateLoadBalancerConnectionSettings(result.getId(), getKeepAlive(), getIdleTimeout(), getKeepAliveTimeout());
310339
} catch (NetworkRuleConflictException e) {
311340
logger.warn("Exception: ", e);
312341
throw new ServerApiException(ApiErrorCode.NETWORK_RULE_CONFLICT_ERROR, e.getMessage());

api/src/main/java/org/apache/cloudstack/api/command/user/loadbalancer/UpdateLoadBalancerRuleCmd.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,41 @@ public class UpdateLoadBalancerRuleCmd extends BaseAsyncCustomIdCmd {
6868
@Parameter(name = ApiConstants.CIDR_LIST, type = CommandType.LIST, collectionType = CommandType.STRING, description = "the cidr list to forward traffic from", since = "4.22")
6969
private List<String> cidrList;
7070

71+
@Parameter(name = ApiConstants.KEEPALIVE, type = CommandType.BOOLEAN, since = "4.23.0",
72+
description = "Whether the load balancer keeps client connections open between requests. "
73+
+ "Only applies to rules the router serves in HTTP mode. If not set, the network offering's setting is used.")
74+
private Boolean keepAlive;
75+
76+
@Parameter(name = ApiConstants.IDLE_TIMEOUT, type = CommandType.LONG, since = "4.23.0",
77+
description = "How long an idle connection is held open, in milliseconds. Use 0 for infinite. "
78+
+ "If not set, the global setting network.loadbalancer.haproxy.idle.timeout is used.")
79+
private Long idleTimeout;
80+
81+
@Parameter(name = ApiConstants.KEEPALIVE_TIMEOUT, type = CommandType.LONG, since = "4.23.0",
82+
description = "How long an idle keepalive connection is held open waiting for the next request, "
83+
+ "in milliseconds. Only applies to rules the router serves in HTTP mode. "
84+
+ "If not set, idletimeout applies.")
85+
private Long keepAliveTimeout;
86+
7187
/////////////////////////////////////////////////////
7288
/////////////////// Accessors ///////////////////////
7389
/////////////////////////////////////////////////////
7490

7591
public String getAlgorithm() {
7692
return algorithm;
7793
}
94+
public Boolean getKeepAlive() {
95+
return keepAlive;
96+
}
97+
98+
public Long getIdleTimeout() {
99+
return idleTimeout;
100+
}
101+
public Long getKeepAliveTimeout() {
102+
return keepAliveTimeout;
103+
}
104+
105+
78106

79107
public String getDescription() {
80108
return description;

api/src/main/java/org/apache/cloudstack/api/response/LoadBalancerResponse.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ public class LoadBalancerResponse extends BaseResponse implements ControlledEnti
6363
@Param(description = "The ID of the guest Network the LB rule belongs to")
6464
private String networkId;
6565

66+
@SerializedName(ApiConstants.KEEPALIVE)
67+
@Param(description = "whether the load balancer keeps client connections open between requests, unset means the network offering's setting is used", since = "4.23.0")
68+
private Boolean keepAlive;
69+
70+
@SerializedName(ApiConstants.IDLE_TIMEOUT)
71+
@Param(description = "how long an idle connection is held open, in milliseconds, unset means the global setting is used", since = "4.23.0")
72+
private Long idleTimeout;
73+
74+
@SerializedName(ApiConstants.KEEPALIVE_TIMEOUT)
75+
@Param(description = "how long an idle keepalive connection is held open waiting for the next request, in milliseconds", since = "4.23.0")
76+
private Long keepAliveTimeout;
77+
6678
@SerializedName(ApiConstants.CIDR_LIST)
6779
@Param(description = "The CIDR list to allow traffic, all other CIDRs will be blocked. Multiple entries must be separated by a single comma character (,).")
6880
private String cidrList;
@@ -143,6 +155,18 @@ public void setCidrList(String cidrs) {
143155
this.cidrList = cidrs;
144156
}
145157

158+
public void setKeepAlive(Boolean keepAlive) {
159+
this.keepAlive = keepAlive;
160+
}
161+
162+
public void setIdleTimeout(Long idleTimeout) {
163+
this.idleTimeout = idleTimeout;
164+
}
165+
166+
public void setKeepAliveTimeout(Long keepAliveTimeout) {
167+
this.keepAliveTimeout = keepAliveTimeout;
168+
}
169+
146170
public void setAlgorithm(String algorithm) {
147171
this.algorithm = algorithm;
148172
}

core/src/main/java/com/cloud/network/HAProxyConfigurator.java

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,18 @@ private String getLbSubRuleForStickiness(final LoadBalancerTO lbTO) {
480480
return sb.toString();
481481
}
482482

483+
/**
484+
* Haproxy rejects a negative timeout, and one bad value costs the whole file. Drop it and keep
485+
* whatever the defaults section says, the same way the global idle timeout does.
486+
*/
487+
private Long timeoutOrNull(final LoadBalancerTO lbTO, final String name, final Long value) {
488+
if (value != null && value < 0) {
489+
logger.warn("Ignoring negative {} [{}] on lb rule {}:{}", name, value, lbTO.getSrcIp(), lbTO.getSrcPort());
490+
return null;
491+
}
492+
return value;
493+
}
494+
483495
private List<String> getRulesForPool(final LoadBalancerTO lbTO, final LoadBalancerConfigCommand lbCmd) {
484496
StringBuilder sb = new StringBuilder();
485497
final String poolName = sb.append(lbTO.getSrcIp().replace(".", "_")).append('-').append(lbTO.getSrcPort()).toString();
@@ -572,12 +584,30 @@ private List<String> getRulesForPool(final LoadBalancerTO lbTO, final LoadBalanc
572584
if (stickinessSubRule != null && !destsAvailable) {
573585
logger.warn("Haproxy stickiness policy for lb rule: " + lbTO.getSrcIp() + ":" + lbTO.getSrcPort() + ": Not Applied, cause: backends are unavailable");
574586
}
575-
boolean keepAliveEnabled = lbCmd.keepAliveEnabled;
576-
boolean http = (publicPort == NetUtils.HTTP_PORT && !keepAliveEnabled);
577-
if (http || httpbasedStickiness || sslOffloading) {
587+
final Boolean ruleKeepAlive = lbTO.getKeepAlive();
588+
final Long ruleIdleTimeout = timeoutOrNull(lbTO, "idletimeout", lbTO.getIdleTimeout());
589+
final Long ruleKeepAliveTimeout = timeoutOrNull(lbTO, "keepalivetimeout", lbTO.getKeepAliveTimeout());
590+
final boolean keepAliveEnabled = ruleKeepAlive != null ? ruleKeepAlive : lbCmd.keepAliveEnabled;
591+
// A rule that asks for keepalive itself stays in http mode on port 80, so forwardfor keeps
592+
// working. Without it, keepalive falls back to tcp mode as it always has.
593+
final boolean port80HttpMode = publicPort == NetUtils.HTTP_PORT && (ruleKeepAlive != null || !keepAliveEnabled);
594+
final boolean httpMode = port80HttpMode || httpbasedStickiness || sslOffloading;
595+
if (httpMode) {
578596
frontendConfigs.add("\tmode http");
579-
String keepAliveLine = keepAliveEnabled ? "\toption http-keep-alive" : "\toption httpclose";
580-
frontendConfigs.add(keepAliveLine);
597+
frontendConfigs.add(keepAliveEnabled ? "\toption http-keep-alive" : "\toption httpclose");
598+
if (keepAliveEnabled && ruleKeepAliveTimeout != null) {
599+
frontendConfigs.add("\ttimeout http-keep-alive " + ruleKeepAliveTimeout);
600+
} else if (ruleKeepAliveTimeout != null) {
601+
logger.warn("Keepalive timeout ignored for lb rule {}:{}, keepalive is off for this rule",
602+
lbTO.getSrcIp(), lbTO.getSrcPort());
603+
}
604+
} else if (ruleKeepAlive != null || ruleKeepAliveTimeout != null) {
605+
logger.warn("Keepalive ignored for lb rule {}:{}, it is served in tcp mode. Keepalive applies on port {}, "
606+
+ "with ssl offload, or with http based stickiness.", lbTO.getSrcIp(), lbTO.getSrcPort(), NetUtils.HTTP_PORT);
607+
}
608+
if (ruleIdleTimeout != null) {
609+
frontendConfigs.add("\ttimeout client " + ruleIdleTimeout);
610+
frontendConfigs.add("\ttimeout server " + ruleIdleTimeout);
581611
}
582612

583613
// add line like this: "listen 65_37_141_30-80\n\tbind 65.37.141.30:80"

core/src/test/java/com/cloud/network/HAProxyConfiguratorTest.java

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@
3131
import com.cloud.agent.api.routing.LoadBalancerConfigCommand;
3232
import com.cloud.agent.api.to.LoadBalancerTO;
3333
import com.cloud.network.lb.LoadBalancingRule.LbDestination;
34-
import com.cloud.utils.Pair;
35-
import com.cloud.network.rules.LbStickinessMethod.StickinessMethodType;
3634
import com.cloud.network.lb.LoadBalancingRule.LbStickinessPolicy;
35+
import com.cloud.network.rules.LbStickinessMethod.StickinessMethodType;
36+
import com.cloud.utils.Pair;
3737
import com.cloud.network.lb.LoadBalancingRule.LbSslCert;
3838

3939
import java.util.List;
@@ -202,6 +202,114 @@ public void generateConfigurationTestIdleTimeoutDoesNotLeakToNextConfig() {
202202
assertTrue("an unset idle timeout should fall back to the default", result.contains("\ttimeout server 50000"));
203203
}
204204

205+
private LoadBalancerConfigCommand cmdFor(LoadBalancerTO lb, boolean offeringKeepAlive) {
206+
LoadBalancerTO[] lba = new LoadBalancerTO[1];
207+
lba[0] = lb;
208+
return new LoadBalancerConfigCommand(lba, "10.0.0.1", "10.1.0.1", "10.1.1.1", null, 1L, "12", offeringKeepAlive, 50000L);
209+
}
210+
211+
private LoadBalancerTO httpRule() {
212+
return new LoadBalancerTO("1", "10.2.0.1", 80, "http", "roundrobin", false, false, false, null);
213+
}
214+
215+
/** Just the "listen" block for this rule, so assertions cannot match the defaults or stats sections. */
216+
private String poolSection(String config, String poolName) {
217+
int start = config.indexOf("listen " + poolName + "\n");
218+
Assert.assertTrue("no listen block for " + poolName, start >= 0);
219+
int next = config.indexOf("\nlisten ", start + 1);
220+
return next < 0 ? config.substring(start) : config.substring(start, next);
221+
}
222+
223+
@Test
224+
public void generateConfigurationTestPerRuleKeepAliveKeepsHttpMode() {
225+
LoadBalancerTO lb = httpRule();
226+
lb.setKeepAlive(true);
227+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(lb, false)), "10_2_0_1-80");
228+
assertTrue("the rule should stay in http mode so forwardfor still works", pool.contains("\tmode http"));
229+
assertTrue(pool.contains("\toption http-keep-alive"));
230+
Assert.assertFalse(pool.contains("\toption httpclose"));
231+
}
232+
233+
@Test
234+
public void generateConfigurationTestPerRuleKeepAliveOverridesTheOffering() {
235+
LoadBalancerTO lb = httpRule();
236+
lb.setKeepAlive(false);
237+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(lb, true)), "10_2_0_1-80");
238+
assertTrue("the rule should win over the offering", pool.contains("\toption httpclose"));
239+
Assert.assertFalse(pool.contains("\toption http-keep-alive"));
240+
}
241+
242+
@Test
243+
public void generateConfigurationTestUnsetKeepAliveLeavesOldBehaviourAlone() {
244+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(httpRule(), true)), "10_2_0_1-80");
245+
Assert.assertFalse("keepalive from the offering still drops to tcp mode", pool.contains("\tmode http"));
246+
247+
pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(httpRule(), false)), "10_2_0_1-80");
248+
assertTrue(pool.contains("\tmode http"));
249+
assertTrue(pool.contains("\toption httpclose"));
250+
}
251+
252+
@Test
253+
public void generateConfigurationTestKeepAliveTimeout() {
254+
LoadBalancerTO lb = httpRule();
255+
lb.setKeepAlive(true);
256+
lb.setKeepAliveTimeout(15000L);
257+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(lb, false)), "10_2_0_1-80");
258+
assertTrue(pool.contains("\ttimeout http-keep-alive 15000"));
259+
}
260+
261+
@Test
262+
public void generateConfigurationTestKeepAliveTimeoutNeedsKeepAlive() {
263+
LoadBalancerTO lb = httpRule();
264+
lb.setKeepAliveTimeout(15000L);
265+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(lb, false)), "10_2_0_1-80");
266+
Assert.assertFalse("without keepalive there is no idle connection to time out", pool.contains("timeout http-keep-alive"));
267+
}
268+
269+
@Test
270+
public void generateConfigurationTestIdleTimeoutOverridesTheGlobalPerRule() {
271+
LoadBalancerTO lb = new LoadBalancerTO("1", "10.2.0.1", 3306, "tcp", "roundrobin", false, false, false, null);
272+
lb.setIdleTimeout(600000L);
273+
String config = genConfig(new HAProxyConfigurator(), cmdFor(lb, false));
274+
String pool = poolSection(config, "10_2_0_1-3306");
275+
assertTrue("a tcp rule gets the timeouts too", pool.contains("\ttimeout client 600000"));
276+
assertTrue(pool.contains("\ttimeout server 600000"));
277+
assertTrue("the global still sets the defaults", config.contains("\ttimeout client 50000"));
278+
}
279+
280+
@Test
281+
public void generateConfigurationTestNegativeTimeoutsAreDropped() {
282+
LoadBalancerTO lb = httpRule();
283+
lb.setKeepAlive(true);
284+
lb.setIdleTimeout(-1L);
285+
lb.setKeepAliveTimeout(-5L);
286+
String pool = poolSection(genConfig(new HAProxyConfigurator(), cmdFor(lb, false)), "10_2_0_1-80");
287+
Assert.assertFalse("a negative timeout is a fatal haproxy parse error", pool.contains("-1"));
288+
Assert.assertFalse("a negative timeout is a fatal haproxy parse error", pool.contains("-5"));
289+
Assert.assertFalse(pool.contains("timeout client"));
290+
Assert.assertFalse(pool.contains("timeout http-keep-alive"));
291+
}
292+
293+
@Test
294+
public void generateConfigurationTestPerRuleSettingsDoNotCrossContaminate() {
295+
LoadBalancerTO tuned = httpRule();
296+
tuned.setKeepAlive(true);
297+
tuned.setIdleTimeout(600000L);
298+
tuned.setKeepAliveTimeout(15000L);
299+
LoadBalancerTO plain = new LoadBalancerTO("2", "10.2.0.1", 8080, "tcp", "roundrobin", false, false, false, null);
300+
LoadBalancerTO[] lba = new LoadBalancerTO[] {tuned, plain};
301+
LoadBalancerConfigCommand cmd = new LoadBalancerConfigCommand(lba, "10.0.0.1", "10.1.0.1", "10.1.1.1", null, 1L, "12", false, 50000L);
302+
String config = genConfig(new HAProxyConfigurator(), cmd);
303+
304+
String tunedPool = poolSection(config, "10_2_0_1-80");
305+
assertTrue(tunedPool.contains("\toption http-keep-alive"));
306+
assertTrue(tunedPool.contains("\ttimeout client 600000"));
307+
308+
String plainPool = poolSection(config, "10_2_0_1-8080");
309+
Assert.assertFalse("the other rule's settings must not leak here", plainPool.contains("http-keep-alive"));
310+
Assert.assertFalse("the other rule's settings must not leak here", plainPool.contains("timeout client"));
311+
}
312+
205313
@Test
206314
public void generateConfigurationTestAppCookieStickinessUsesAStickTable() {
207315
List<Pair<String, String>> params = new ArrayList<>();

0 commit comments

Comments
 (0)