Skip to content

Commit 02451ee

Browse files
committed
feat: Add custom placeholder pages for scale-from-zero scenarios
Allows HTTPScaledObjects to serve configurable HTML pages while workloads scale up from zero, with support for templates, custom headers, and automatic refresh. Signed-off-by: malpou <[email protected]>
1 parent 6a6adfb commit 02451ee

File tree

14 files changed

+1027
-3
lines changed

14 files changed

+1027
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ This changelog keeps track of work items that have been completed and are ready
2727

2828
- **General**: Add configurable tracing support to the interceptor proxy ([#1021](https://github.com/kedacore/http-add-on/pull/1021))
2929
- **General**: Allow using HSO and SO with different names ([#1293](https://github.com/kedacore/http-add-on/issues/1293))
30+
- **General**: Add custom placeholder pages for scale-from-zero scenarios ([#874](https://github.com/kedacore/http-add-on/issues/874))
3031

3132
### Improvements
3233

config/crd/bases/http.keda.sh_httpscaledobjects.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,48 @@ spec:
8282
items:
8383
type: string
8484
type: array
85+
placeholderConfig:
86+
description: (optional) Configuration for placeholder pages during
87+
scale-from-zero
88+
properties:
89+
content:
90+
description: Inline HTML content for placeholder page (takes precedence
91+
over ContentConfigMap)
92+
type: string
93+
contentConfigMap:
94+
description: Path to ConfigMap containing placeholder HTML content
95+
(in same namespace)
96+
type: string
97+
contentConfigMapKey:
98+
default: template.html
99+
description: Key in ConfigMap containing the HTML template
100+
type: string
101+
enabled:
102+
default: false
103+
description: Enable placeholder page when replicas are scaled
104+
to zero
105+
type: boolean
106+
headers:
107+
additionalProperties:
108+
type: string
109+
description: Additional HTTP headers to include with placeholder
110+
response
111+
type: object
112+
refreshInterval:
113+
default: 5
114+
description: Refresh interval for client-side polling in seconds
115+
format: int32
116+
maximum: 60
117+
minimum: 1
118+
type: integer
119+
statusCode:
120+
default: 503
121+
description: HTTP status code to return with placeholder page
122+
format: int32
123+
maximum: 599
124+
minimum: 100
125+
type: integer
126+
type: object
85127
replicas:
86128
description: (optional) Replica information
87129
properties:

docs/ref/vX.X.X/http_scaled_object.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ spec:
3333
window: 1m
3434
concurrency:
3535
targetValue: 100
36+
placeholderConfig:
37+
enabled: true
38+
refreshInterval: 5
39+
statusCode: 503
40+
headers:
41+
X-Service-Status: "warming-up"
42+
content: |
43+
<!DOCTYPE html>
44+
<html>
45+
<head>
46+
<title>Service Starting</title>
47+
<meta http-equiv="refresh" content="{{.RefreshInterval}}">
48+
</head>
49+
<body>
50+
<h1>{{.ServiceName}} is starting...</h1>
51+
</body>
52+
</html>
3653
```
3754
3855
This document is a narrated reference guide for the `HTTPScaledObject`.
@@ -134,3 +151,52 @@ This section enables scaling based on the request concurrency.
134151
>Default: 100
135152

136153
This is the target value for the scaling configuration.
154+
155+
## `placeholderConfig`
156+
157+
This optional section enables serving placeholder pages when the workload is scaled to zero. When enabled, instead of returning an error while waiting for the workload to scale up, the interceptor will serve a customizable HTML page that refreshes automatically.
158+
159+
### `enabled`
160+
161+
>Default: false
162+
163+
Whether to enable placeholder pages for this HTTPScaledObject.
164+
165+
### `refreshInterval`
166+
167+
>Default: 5
168+
169+
The interval in seconds at which the placeholder page will refresh. This should be set based on your expected cold start time.
170+
171+
### `statusCode`
172+
173+
>Default: 503
174+
175+
The HTTP status code to return with the placeholder page. Common values are 503 (Service Unavailable) or 202 (Accepted).
176+
177+
### `headers`
178+
179+
>Default: {}
180+
181+
A map of custom HTTP headers to include in the placeholder response. Useful for adding service-specific headers.
182+
183+
### `content`
184+
185+
>Default: Built-in template
186+
187+
Custom HTML content for the placeholder page. Supports Go template syntax with the following variables:
188+
- `{{.ServiceName}}` - The name of the service from scaleTargetRef
189+
- `{{.Namespace}}` - The namespace of the HTTPScaledObject
190+
- `{{.RefreshInterval}}` - The configured refresh interval
191+
- `{{.RequestID}}` - The X-Request-ID header value if present
192+
- `{{.Timestamp}}` - The current timestamp in RFC3339 format
193+
194+
### `contentConfigMap`
195+
196+
The name of a ConfigMap containing the placeholder page template. This is an alternative to inline `content`.
197+
198+
### `contentConfigMapKey`
199+
200+
>Default: "template.html"
201+
202+
The key within the ConfigMap that contains the template content.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
kind: HTTPScaledObject
2+
apiVersion: http.keda.sh/v1alpha1
3+
metadata:
4+
name: xkcd
5+
spec:
6+
hosts:
7+
- myhost.com
8+
pathPrefixes:
9+
- /test
10+
scaleTargetRef:
11+
name: xkcd
12+
kind: Deployment
13+
apiVersion: apps/v1
14+
service: xkcd
15+
port: 8080
16+
replicas:
17+
min: 1
18+
max: 10
19+
scaledownPeriod: 300
20+
scalingMetric:
21+
requestRate:
22+
granularity: 1s
23+
targetValue: 100
24+
window: 1m
25+
placeholderConfig:
26+
enabled: true
27+
refreshInterval: 5
28+
statusCode: 503
29+
headers:
30+
X-Service-Status: "warming-up"
31+
content: |
32+
<!DOCTYPE html>
33+
<html>
34+
<head>
35+
<title>Service Starting</title>
36+
<meta http-equiv="refresh" content="{{.RefreshInterval}}">
37+
</head>
38+
<body>
39+
<h1>{{.ServiceName}} is starting...</h1>
40+
</body>
41+
</html>

interceptor/handler/placeholder.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package handler
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"html/template"
8+
"net/http"
9+
"time"
10+
11+
"github.com/kedacore/http-add-on/operator/apis/http/v1alpha1"
12+
"github.com/kedacore/http-add-on/pkg/routing"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/client-go/kubernetes"
15+
)
16+
17+
const defaultPlaceholderTemplate = `<!DOCTYPE html>
18+
<html>
19+
<head>
20+
<title>Service Starting</title>
21+
<meta http-equiv="refresh" content="{{.RefreshInterval}}">
22+
<meta charset="utf-8">
23+
<style>
24+
body {
25+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
26+
display: flex;
27+
align-items: center;
28+
justify-content: center;
29+
min-height: 100vh;
30+
margin: 0;
31+
background: #f5f5f5;
32+
}
33+
.container {
34+
text-align: center;
35+
padding: 2rem;
36+
background: white;
37+
border-radius: 8px;
38+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
39+
max-width: 400px;
40+
}
41+
h1 {
42+
color: #333;
43+
margin-bottom: 1rem;
44+
font-size: 1.5rem;
45+
}
46+
.spinner {
47+
width: 40px;
48+
height: 40px;
49+
margin: 1.5rem auto;
50+
border: 4px solid #f3f3f3;
51+
border-top: 4px solid #3498db;
52+
border-radius: 50%;
53+
animation: spin 1s linear infinite;
54+
}
55+
@keyframes spin {
56+
0% { transform: rotate(0deg); }
57+
100% { transform: rotate(360deg); }
58+
}
59+
p {
60+
color: #666;
61+
margin: 0.5rem 0;
62+
}
63+
.small {
64+
font-size: 0.875rem;
65+
color: #999;
66+
}
67+
</style>
68+
</head>
69+
<body>
70+
<div class="container">
71+
<h1>{{.ServiceName}} is starting up...</h1>
72+
<div class="spinner"></div>
73+
<p>Please wait while we prepare your service.</p>
74+
<p class="small">This page will refresh automatically every {{.RefreshInterval}} seconds.</p>
75+
</div>
76+
</body>
77+
</html>`
78+
79+
// PlaceholderHandler handles serving placeholder pages during scale-from-zero
80+
type PlaceholderHandler struct {
81+
k8sClient kubernetes.Interface
82+
routingTable routing.Table
83+
templateCache map[string]*template.Template
84+
defaultTmpl *template.Template
85+
}
86+
87+
// PlaceholderData contains data for rendering placeholder templates
88+
type PlaceholderData struct {
89+
ServiceName string
90+
Namespace string
91+
RefreshInterval int32
92+
RequestID string
93+
Timestamp string
94+
}
95+
96+
// NewPlaceholderHandler creates a new placeholder handler
97+
func NewPlaceholderHandler(k8sClient kubernetes.Interface, routingTable routing.Table) (*PlaceholderHandler, error) {
98+
defaultTmpl, err := template.New("default").Parse(defaultPlaceholderTemplate)
99+
if err != nil {
100+
return nil, fmt.Errorf("failed to parse default template: %w", err)
101+
}
102+
103+
return &PlaceholderHandler{
104+
k8sClient: k8sClient,
105+
routingTable: routingTable,
106+
templateCache: make(map[string]*template.Template),
107+
defaultTmpl: defaultTmpl,
108+
}, nil
109+
}
110+
111+
// ServePlaceholder serves a placeholder page based on the HTTPScaledObject configuration
112+
func (h *PlaceholderHandler) ServePlaceholder(w http.ResponseWriter, r *http.Request, hso *v1alpha1.HTTPScaledObject) error {
113+
if hso.Spec.PlaceholderConfig == nil || !hso.Spec.PlaceholderConfig.Enabled {
114+
http.Error(w, "Service temporarily unavailable", http.StatusServiceUnavailable)
115+
return nil
116+
}
117+
118+
config := hso.Spec.PlaceholderConfig
119+
120+
statusCode := int(config.StatusCode)
121+
if statusCode == 0 {
122+
statusCode = http.StatusServiceUnavailable
123+
}
124+
125+
for k, v := range config.Headers {
126+
w.Header().Set(k, v)
127+
}
128+
129+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
130+
w.Header().Set("X-KEDA-HTTP-Placeholder-Served", "true")
131+
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
132+
133+
tmpl, err := h.getTemplate(r.Context(), hso)
134+
if err != nil {
135+
w.WriteHeader(statusCode)
136+
fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>",
137+
hso.Spec.ScaleTargetRef.Service, config.RefreshInterval)
138+
return nil
139+
}
140+
141+
data := PlaceholderData{
142+
ServiceName: hso.Spec.ScaleTargetRef.Service,
143+
Namespace: hso.Namespace,
144+
RefreshInterval: config.RefreshInterval,
145+
RequestID: r.Header.Get("X-Request-ID"),
146+
Timestamp: time.Now().Format(time.RFC3339),
147+
}
148+
149+
var buf bytes.Buffer
150+
if err := tmpl.Execute(&buf, data); err != nil {
151+
w.WriteHeader(statusCode)
152+
fmt.Fprintf(w, "<h1>%s is starting up...</h1><meta http-equiv='refresh' content='%d'>",
153+
hso.Spec.ScaleTargetRef.Service, config.RefreshInterval)
154+
return nil
155+
}
156+
157+
w.WriteHeader(statusCode)
158+
_, err = w.Write(buf.Bytes())
159+
return err
160+
}
161+
162+
// getTemplate retrieves the template for the given HTTPScaledObject
163+
func (h *PlaceholderHandler) getTemplate(ctx context.Context, hso *v1alpha1.HTTPScaledObject) (*template.Template, error) {
164+
config := hso.Spec.PlaceholderConfig
165+
166+
if config.Content != "" {
167+
cacheKey := fmt.Sprintf("%s/%s/inline", hso.Namespace, hso.Name)
168+
if tmpl, ok := h.templateCache[cacheKey]; ok {
169+
return tmpl, nil
170+
}
171+
172+
tmpl, err := template.New("inline").Parse(config.Content)
173+
if err != nil {
174+
return nil, err
175+
}
176+
h.templateCache[cacheKey] = tmpl
177+
return tmpl, nil
178+
}
179+
180+
if config.ContentConfigMap != "" {
181+
cacheKey := fmt.Sprintf("%s/%s/cm/%s", hso.Namespace, hso.Name, config.ContentConfigMap)
182+
if tmpl, ok := h.templateCache[cacheKey]; ok {
183+
return tmpl, nil
184+
}
185+
186+
cm, err := h.k8sClient.CoreV1().ConfigMaps(hso.Namespace).Get(ctx, config.ContentConfigMap, metav1.GetOptions{})
187+
if err != nil {
188+
return nil, fmt.Errorf("failed to get ConfigMap %s: %w", config.ContentConfigMap, err)
189+
}
190+
191+
key := config.ContentConfigMapKey
192+
if key == "" {
193+
key = "template.html"
194+
}
195+
196+
content, ok := cm.Data[key]
197+
if !ok {
198+
return nil, fmt.Errorf("key %s not found in ConfigMap %s", key, config.ContentConfigMap)
199+
}
200+
201+
tmpl, err := template.New("configmap").Parse(content)
202+
if err != nil {
203+
return nil, err
204+
}
205+
h.templateCache[cacheKey] = tmpl
206+
return tmpl, nil
207+
}
208+
209+
return h.defaultTmpl, nil
210+
}
211+
212+
// ClearCache clears the template cache
213+
func (h *PlaceholderHandler) ClearCache() {
214+
h.templateCache = make(map[string]*template.Template)
215+
}

0 commit comments

Comments
 (0)