Skip to content

Commit ee87525

Browse files
committed
Add router option to bind ports only when ready
By default the router binds ports 80 and 443 even if no routing configuration is available. This may be desirable when haproxy is serving traffic directly to clients. However, the f5 loadbalancer will treat bound ports as indicating that a backend is ready to receive traffic. This means that router pods that are starting or restarting may be put into rotation before they are ready to serve the current route state and clients may see 503s for perfectly healthy backend endpoints. To avoid this possibility, this change adds an option (BIND_ONLY_WHEN_READY/--bind-only-when-ready) to the router that ensures that ports are bound only when a router instance has route and endpoint state available. Reference: bz1383663
1 parent 7ddc72e commit ee87525

File tree

7 files changed

+149
-3
lines changed

7 files changed

+149
-3
lines changed

contrib/completions/bash/openshift

+2
Original file line numberDiff line numberDiff line change
@@ -20096,6 +20096,8 @@ _openshift_infra_router()
2009620096
local_nonpersistent_flags+=("--allowed-domains=")
2009720097
flags+=("--as=")
2009820098
local_nonpersistent_flags+=("--as=")
20099+
flags+=("--bind-only-when-ready")
20100+
local_nonpersistent_flags+=("--bind-only-when-ready")
2009920101
flags+=("--certificate-authority=")
2010020102
flags_with_completion+=("--certificate-authority")
2010120103
flags_completion+=("_filedir")

contrib/completions/zsh/openshift

+2
Original file line numberDiff line numberDiff line change
@@ -20257,6 +20257,8 @@ _openshift_infra_router()
2025720257
local_nonpersistent_flags+=("--allowed-domains=")
2025820258
flags+=("--as=")
2025920259
local_nonpersistent_flags+=("--as=")
20260+
flags+=("--bind-only-when-ready")
20261+
local_nonpersistent_flags+=("--bind-only-when-ready")
2026020262
flags+=("--certificate-authority=")
2026120263
flags_with_completion+=("--certificate-authority")
2026220264
flags_completion+=("_filedir")

images/router/haproxy/conf/haproxy-config.template

+4
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ listen stats :1936
100100
stats auth {{.StatsUser}}:{{.StatsPassword}}
101101
{{ end }}
102102

103+
{{ if (and .BindOnlyWhenReady (not (and .State .ServiceUnits))) }}
104+
# Avoiding binding ports until routing configuration has been synchronized.
105+
{{ else }}
103106
frontend public
104107
bind :{{env "ROUTER_SERVICE_HTTP_PORT" "80"}}
105108
mode http
@@ -529,6 +532,7 @@ backend be_secure_{{$cfgIdx}}
529532
{{ end }}{{/* end range over serviceUnitNames */}}
530533
{{ end }}{{/* end tls==reencrypt */}}
531534
{{ end }}{{/* end loop over routes */}}
535+
{{ end }}{{/* end bind only when ready */}}
532536
{{ end }}{{/* end haproxy config template */}}
533537

534538
{{/*--------------------------------- END OF HAPROXY CONFIG, BELOW ARE MAPPING FILES ------------------------*/}}

pkg/cmd/infra/router/template.go

+3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type TemplateRouter struct {
6262
DefaultCertificateDir string
6363
ExtendedValidation bool
6464
RouterService *ktypes.NamespacedName
65+
BindOnlyWhenReady bool
6566
}
6667

6768
// reloadInterval returns how often to run the router reloads. The interval
@@ -86,6 +87,7 @@ func (o *TemplateRouter) Bind(flag *pflag.FlagSet) {
8687
flag.StringVar(&o.ReloadScript, "reload", util.Env("RELOAD_SCRIPT", ""), "The path to the reload script to use")
8788
flag.DurationVar(&o.ReloadInterval, "interval", reloadInterval(), "Controls how often router reloads are invoked. Mutiple router reload requests are coalesced for the duration of this interval since the last reload time.")
8889
flag.BoolVar(&o.ExtendedValidation, "extended-validation", util.Env("EXTENDED_VALIDATION", "true") == "true", "If set, then an additional extended validation step is performed on all routes admitted in by this router. Defaults to true and enables the extended validation checks.")
90+
flag.BoolVar(&o.BindOnlyWhenReady, "bind-only-when-ready", util.Env("BIND_ONLY_WHEN_READY", "") == "true", "Only bind ports when route state has been synchronized")
8991
}
9092

9193
type RouterStats struct {
@@ -188,6 +190,7 @@ func (o *TemplateRouterOptions) Run() error {
188190
StatsUsername: o.StatsUsername,
189191
StatsPassword: o.StatsPassword,
190192
PeerService: o.RouterService,
193+
BindOnlyWhenReady: o.BindOnlyWhenReady,
191194
IncludeUDP: o.RouterSelection.IncludeUDP,
192195
AllowWildcardRoutes: o.RouterSelection.AllowWildcardRoutes,
193196
}

pkg/router/template/plugin.go

+2
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type TemplatePluginConfig struct {
5050
IncludeUDP bool
5151
AllowWildcardRoutes bool
5252
PeerService *ktypes.NamespacedName
53+
BindOnlyWhenReady bool
5354
}
5455

5556
// routerInterface controls the interaction of the plugin with the underlying router implementation
@@ -143,6 +144,7 @@ func NewTemplatePlugin(cfg TemplatePluginConfig, lookupSvc ServiceLookup) (*Temp
143144
statsPort: cfg.StatsPort,
144145
allowWildcardRoutes: cfg.AllowWildcardRoutes,
145146
peerEndpointsKey: peerKey,
147+
bindOnlyWhenReady: cfg.BindOnlyWhenReady,
146148
}
147149
router, err := newTemplateRouter(templateRouterCfg)
148150
return newDefaultTemplatePlugin(router, cfg.IncludeUDP, lookupSvc), err

pkg/router/template/router.go

+7
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ type templateRouter struct {
8686
lock sync.Mutex
8787
// the router should only reload when the value is false
8888
skipCommit bool
89+
// If true, haproxy should only bind ports when it has route and endpoint state
90+
bindOnlyWhenReady bool
8991
}
9092

9193
// templateRouterCfg holds all configuration items required to initialize the template router
@@ -103,6 +105,7 @@ type templateRouterCfg struct {
103105
allowWildcardRoutes bool
104106
peerEndpointsKey string
105107
includeUDP bool
108+
bindOnlyWhenReady bool
106109
}
107110

108111
// templateConfig is a subset of the templateRouter information that should be passed to the template for generating
@@ -124,6 +127,8 @@ type templateData struct {
124127
StatsPassword string
125128
//port to expose stats with (if the template supports it)
126129
StatsPort int
130+
// whether the router should bind the default ports only when ready
131+
BindOnlyWhenReady bool
127132
}
128133

129134
func newTemplateRouter(cfg templateRouterCfg) (*templateRouter, error) {
@@ -162,6 +167,7 @@ func newTemplateRouter(cfg templateRouterCfg) (*templateRouter, error) {
162167
allowWildcardRoutes: cfg.allowWildcardRoutes,
163168
peerEndpointsKey: cfg.peerEndpointsKey,
164169
peerEndpoints: []Endpoint{},
170+
bindOnlyWhenReady: cfg.bindOnlyWhenReady,
165171

166172
rateLimitedCommitFunction: nil,
167173
rateLimitedCommitStopChannel: make(chan struct{}),
@@ -394,6 +400,7 @@ func (r *templateRouter) writeConfig() error {
394400
StatsUser: r.statsUser,
395401
StatsPassword: r.statsPassword,
396402
StatsPort: r.statsPort,
403+
BindOnlyWhenReady: r.bindOnlyWhenReady,
397404
}
398405
if err := template.Execute(file, data); err != nil {
399406
file.Close()

test/integration/router_test.go

+129-3
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,10 @@ u3YLAbyW/lHhOCiZu2iAI8AbmXem9lW6Tr7p/97s0w==
12561256
// createAndStartRouterContainer is responsible for deploying the router image in docker. It assumes that all router images
12571257
// will use a command line flag that can take --master which points to the master url
12581258
func createAndStartRouterContainer(dockerCli *dockerClient.Client, masterIp string, routerStatsPort int, reloadInterval int) (containerId string, err error) {
1259+
return createAndStartRouterContainerBindWhenReady(dockerCli, masterIp, routerStatsPort, reloadInterval, false)
1260+
}
1261+
1262+
func createAndStartRouterContainerBindWhenReady(dockerCli *dockerClient.Client, masterIp string, routerStatsPort int, reloadInterval int, bindOnlyWhenReady bool) (containerId string, err error) {
12591263
ports := []string{"80", "443"}
12601264
if routerStatsPort > 0 {
12611265
ports = append(ports, fmt.Sprintf("%d", routerStatsPort))
@@ -1291,6 +1295,7 @@ func createAndStartRouterContainer(dockerCli *dockerClient.Client, masterIp stri
12911295
fmt.Sprintf("STATS_USERNAME=%s", statsUser),
12921296
fmt.Sprintf("STATS_PASSWORD=%s", statsPassword),
12931297
fmt.Sprintf("DEFAULT_CERTIFICATE=%s", defaultCert),
1298+
fmt.Sprintf("BIND_ONLY_WHEN_READY=%s", strconv.FormatBool(bindOnlyWhenReady)),
12941299
}
12951300

12961301
reloadIntVar := fmt.Sprintf("RELOAD_INTERVAL=%ds", reloadInterval)
@@ -1580,10 +1585,131 @@ func TestRouterReloadCoalesce(t *testing.T) {
15801585
}
15811586
}
15821587

1588+
func waitForRouter(host string, port int, path string) {
1589+
hostAndPort := fmt.Sprintf("%s:%d", host, port)
1590+
uri := fmt.Sprintf("%s/%s", hostAndPort, path)
1591+
waitForRoute(uri, hostAndPort, "http", nil, "")
1592+
}
1593+
15831594
// waitForRouterToBecomeAvailable checks for the router start up and waits
15841595
// till it becomes available.
15851596
func waitForRouterToBecomeAvailable(host string, port int) {
1586-
hostAndPort := fmt.Sprintf("%s:%d", host, port)
1587-
uri := fmt.Sprintf("%s/healthz", hostAndPort)
1588-
waitForRoute(uri, hostAndPort, "http", nil, "")
1597+
waitForRouter(host, port, "healthz")
1598+
}
1599+
1600+
// waitForRouterToBecomeReady checks that the router is ready.
1601+
func waitForRouterToBecomeReady(host string, port int) {
1602+
waitForRouter(host, port, "readyz")
1603+
}
1604+
1605+
func makeRouterRequest(t *testing.T, scheme string) (*http.Response, error) {
1606+
uri := fmt.Sprintf("%s://%s", scheme, getRouteAddress())
1607+
var tlsConfig *tls.Config
1608+
if scheme == "https" {
1609+
tlsConfig = &tls.Config{
1610+
InsecureSkipVerify: true,
1611+
}
1612+
}
1613+
1614+
httpClient := &http.Client{
1615+
Transport: knet.SetTransportDefaults(&http.Transport{
1616+
TLSClientConfig: tlsConfig,
1617+
}),
1618+
}
1619+
req, err := http.NewRequest("GET", uri, nil)
1620+
if err != nil {
1621+
t.Fatalf("Error creating %s request : %v", scheme, err)
1622+
}
1623+
1624+
return httpClient.Do(req)
1625+
}
1626+
1627+
// Ensure that when configured with BIND_ONLY_WHEN_READY=true, haproxy
1628+
// binds ports only when it has routes and endpoints to expose.
1629+
func TestRouterPortsBoundOnlyWhenReady(t *testing.T) {
1630+
// Create a new master but do not start it yet to simulate a router without api access.
1631+
fakeMasterAndPod := tr.NewTestHttpService()
1632+
1633+
dockerCli, err := testutil.NewDockerClient()
1634+
if err != nil {
1635+
t.Fatalf("Unable to get docker client: %v", err)
1636+
}
1637+
1638+
bindOnlyWhenReady := true
1639+
routerId, err := createAndStartRouterContainerBindWhenReady(dockerCli, fakeMasterAndPod.MasterHttpAddr, statsPort, 1, bindOnlyWhenReady)
1640+
if err != nil {
1641+
t.Fatalf("Error starting container %s : %v", getRouterImage(), err)
1642+
}
1643+
defer cleanUp(t, dockerCli, routerId)
1644+
1645+
waitForRouterToBecomeAvailable("127.0.0.1", statsPort)
1646+
1647+
// Validate that the default ports are not bound due to the router not being ready.
1648+
schemes := []string{"http", "https"}
1649+
for _, scheme := range schemes {
1650+
_, err = makeRouterRequest(t, scheme)
1651+
if err == nil {
1652+
t.Fatalf("Router is unexpectedly accepting connections via %v", scheme)
1653+
} else if !strings.HasSuffix(fmt.Sprintf("%v", err), "connection refused") {
1654+
t.Fatalf("Unexpected error when dispatching %v request: %v", scheme, err)
1655+
}
1656+
}
1657+
1658+
// Start the master
1659+
defer fakeMasterAndPod.Stop()
1660+
err = fakeMasterAndPod.Start()
1661+
validateServer(fakeMasterAndPod, t)
1662+
if err != nil {
1663+
t.Fatalf("Unable to start http server: %v", err)
1664+
}
1665+
1666+
// Create events that will allow the router to have enough state to consider itself 'ready'
1667+
httpEndpoint, err := getEndpoint(fakeMasterAndPod.PodHttpAddr)
1668+
if err != nil {
1669+
t.Fatalf("Couldn't get http endpoint: %v", err)
1670+
}
1671+
endpointEvent := &watch.Event{
1672+
Type: watch.Added,
1673+
Object: &kapi.Endpoints{
1674+
ObjectMeta: kapi.ObjectMeta{
1675+
Name: "myService",
1676+
Namespace: "default",
1677+
},
1678+
Subsets: []kapi.EndpointSubset{httpEndpoint},
1679+
},
1680+
}
1681+
routeEvent := &watch.Event{
1682+
Type: watch.Added,
1683+
Object: &routeapi.Route{
1684+
ObjectMeta: kapi.ObjectMeta{
1685+
Name: "path",
1686+
Namespace: "default",
1687+
},
1688+
Spec: routeapi.RouteSpec{
1689+
Host: "www.example.com",
1690+
Path: "/test",
1691+
To: routeapi.RouteTargetReference{
1692+
Name: "myService",
1693+
},
1694+
TLS: &routeapi.TLSConfig{},
1695+
},
1696+
},
1697+
}
1698+
sendTimeout(t, fakeMasterAndPod.EndpointChannel, eventString(endpointEvent), 30*time.Second)
1699+
sendTimeout(t, fakeMasterAndPod.RouteChannel, eventString(routeEvent), 30*time.Second)
1700+
1701+
waitForRouterToBecomeReady("127.0.0.1", statsPort)
1702+
1703+
// Validate that the default ports are now bound
1704+
for _, scheme := range schemes {
1705+
resp, err := makeRouterRequest(t, scheme)
1706+
if err != nil {
1707+
t.Fatalf("Error dispatching %s request : %v", scheme, err)
1708+
}
1709+
defer resp.Body.Close()
1710+
1711+
if resp.StatusCode != 503 {
1712+
t.Fatalf("Router %s response error, got %v expected 503.", scheme, resp.StatusCode)
1713+
}
1714+
}
15891715
}

0 commit comments

Comments
 (0)