Skip to content

Commit 944a7fd

Browse files
authored
Merge pull request #1692 from rackerlabs/cdn
feat: Add manifests for basic CDN for firmware images
2 parents ada7661 + 38798a9 commit 944a7fd

10 files changed

Lines changed: 407 additions & 0 deletions

File tree

apps/site/understack-cdn.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
component: understack-cdn
3+
sources:
4+
- ref: understack
5+
path: 'components/understack-cdn'
6+
helm:
7+
valueFiles:
8+
- $understack/components/understack-cdn/values.yaml
9+
- $deploy/{{.name}}/understack-cdn/values.yaml
10+
ignoreMissingValueFiles: true
11+
- ref: deploy
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{{- if eq (include "understack.isEnabled" (list $.Values.site "understack_cdn")) "true" }}
2+
---
3+
apiVersion: argoproj.io/v1alpha1
4+
kind: Application
5+
metadata:
6+
name: {{ printf "%s-%s" $.Release.Name "understack-cdn" }}
7+
finalizers:
8+
- resources-finalizer.argocd.argoproj.io
9+
annotations:
10+
argocd.argoproj.io/compare-options: ServerSideDiff=true,IncludeMutationWebhook=true
11+
spec:
12+
destination:
13+
namespace: understack-cdn
14+
server: {{ $.Values.cluster_server }}
15+
project: understack
16+
sources:
17+
- helm:
18+
ignoreMissingValueFiles: true
19+
valueFiles:
20+
- $understack/components/understack-cdn/values.yaml
21+
- $deploy/{{ include "understack.deploy_path" $ }}/understack-cdn/values.yaml
22+
path: components/understack-cdn
23+
ref: understack
24+
repoURL: {{ include "understack.understack_url" $ }}
25+
targetRevision: {{ include "understack.understack_ref" $ }}
26+
- ref: deploy
27+
repoURL: {{ include "understack.deploy_url" $ }}
28+
targetRevision: {{ include "understack.deploy_ref" $ }}
29+
syncPolicy:
30+
automated:
31+
prune: true
32+
selfHeal: true
33+
managedNamespaceMetadata:
34+
annotations:
35+
argocd.argoproj.io/sync-options: Delete=false
36+
syncOptions:
37+
- CreateNamespace=true
38+
- ServerSideApply=true
39+
- RespectIgnoreDifferences=true
40+
- ApplyOutOfSyncOnly=true
41+
{{- end }}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
apiVersion: v2
2+
name: understack-cdn
3+
description: HTTP service to serve firmware images from central store
4+
type: application
5+
version: 0.1.0
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Poor-man's CDN for serving firmware images
2+
3+
Images are stored in Object Store
4+
5+
Caching reverse-proxies at each fabric will fetch the images from Object Store
6+
and make them available via HTTPS.
7+
8+
This allows a device to access firmware images via an HTTPS request to a
9+
cluster-local tendot IP address.
10+
11+
## Proxy configuration
12+
13+
The proxy edge service caches files locally on a persistent volume.
14+
15+
All files are proxied to our object bucket. Anonymous credentials are used,
16+
therefore we need to make the files in our bucket readable by anonymous if they
17+
are to be accessible via HTTP.
18+
19+
## Uploading file to object storage
20+
21+
We have an ObjectBucket provisioned via rook/ceph. The operator for our
22+
ObjectBucketClaim creates credentials for us to access this bucket.
23+
24+
Our credentials and bucket info is in a secret and a configmap both named after
25+
the bucketclaim:
26+
27+
``` sh
28+
KEY_ID=`kubectl -n understack-cdn get secrets firmware-images -o jsonpath='{.data.AWS_ACCESS_KEY_ID}' | base64 -d`
29+
KEY=`kubectl -n understack-cdn get secrets firmware-images -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d`
30+
```
31+
32+
I was able to manage the bucket using the minio CLI client called "mc".
33+
34+
``` sh
35+
mc alias set myrook https://object-storage.dev.undercloud.rackspace.net/firmware-images/ $KEY_ID $KEY
36+
mc anonymous set download myrook/firmware-images
37+
mc cp DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE myrook/firmware-images/DELL/R7615/
38+
mc anonymous set download myrook/firmware-images/DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE
39+
```
40+
41+
## Testing with curl
42+
43+
curl https://cdn.dev.undercloud.rackspace.net/firmware-images/DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE | shasum
44+
45+
## See nginx logs to check that it is Caching
46+
47+
``` sh
48+
⇒ kubectl -n understack-cdn logs deployments/cdn-edge
49+
Defaulted container "nginx" out of: nginx, cache-dir-init (init)
50+
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
51+
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
52+
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
53+
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
54+
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
55+
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
56+
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
57+
/docker-entrypoint.sh: Configuration complete; ready for start up
58+
10.64.49.118 - - [26/Feb/2026:12:36:47 +0000] "GET /DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE HTTP/1.1" 200 2523429 "-" "curl/8.14.1" "10.64.50.136" cache=EXPIRED
59+
10.64.49.118 - - [26/Feb/2026:12:36:56 +0000] "GET /DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE HTTP/1.1" 200 32591328 "-" "curl/8.14.1" "10.64.50.136" cache=HIT
60+
10.64.49.118 - - [26/Feb/2026:12:45:18 +0000] "GET /DELL/R7615/BIOS_H3TGJ_WN64_1.15.3.EXE HTTP/1.1" 200 32591328 "-" "curl/8.14.1" "10.64.50.136" cache=HIT
61+
```
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
apiVersion: apps/v1
2+
kind: Deployment
3+
metadata:
4+
name: cdn-edge
5+
labels:
6+
app: cdn-edge
7+
spec:
8+
replicas: 1
9+
selector:
10+
matchLabels:
11+
app: cdn-edge
12+
strategy:
13+
type: RollingUpdate
14+
rollingUpdate:
15+
maxSurge: 1
16+
maxUnavailable: 0
17+
template:
18+
metadata:
19+
labels:
20+
app: cdn-edge
21+
spec:
22+
# Init container to set correct permissions on cache dir
23+
initContainers:
24+
- name: cache-dir-init
25+
image: busybox:1.36
26+
command: ["sh", "-c", "mkdir -p /var/cache/nginx/cdn && chown -R 101:101 /var/cache/nginx"]
27+
volumeMounts:
28+
- name: nginx-cache
29+
mountPath: /var/cache/nginx
30+
31+
containers:
32+
- name: nginx
33+
image: nginx:1.27-alpine
34+
ports:
35+
- containerPort: 8080
36+
name: http
37+
38+
resources:
39+
requests:
40+
cpu: "250m"
41+
memory: "256Mi"
42+
limits:
43+
cpu: "2"
44+
memory: "1Gi"
45+
46+
volumeMounts:
47+
- name: nginx-config
48+
mountPath: /etc/nginx/nginx.conf
49+
subPath: nginx.conf
50+
- name: nginx-config
51+
mountPath: /etc/nginx/conf.d/default.conf
52+
subPath: default.conf
53+
- name: nginx-cache
54+
mountPath: /var/cache/nginx
55+
56+
livenessProbe:
57+
httpGet:
58+
path: /healthz
59+
port: 8080
60+
initialDelaySeconds: 5
61+
periodSeconds: 10
62+
63+
readinessProbe:
64+
httpGet:
65+
path: /healthz
66+
port: 8080
67+
initialDelaySeconds: 3
68+
periodSeconds: 5
69+
70+
# Graceful shutdown — let in-flight transfers complete
71+
lifecycle:
72+
preStop:
73+
exec:
74+
command: ["/bin/sh", "-c", "sleep 5 && nginx -s quit"]
75+
76+
securityContext:
77+
runAsNonRoot: true
78+
runAsUser: 101 # nginx user in nginx:alpine
79+
allowPrivilegeEscalation: false
80+
readOnlyRootFilesystem: true
81+
82+
volumes:
83+
- name: nginx-config
84+
configMap:
85+
name: nginx-config
86+
- name: nginx-cache
87+
persistentVolumeClaim:
88+
claimName: nginx-cache
89+
90+
terminationGracePeriodSeconds: 30
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
apiVersion: v1
2+
kind: ConfigMap
3+
metadata:
4+
name: nginx-config
5+
data:
6+
nginx.conf: |
7+
worker_processes auto;
8+
error_log /var/log/nginx/error.log warn;
9+
pid /var/cache/nginx/nginx.pid;
10+
11+
# Tune for large file serving
12+
worker_rlimit_nofile 65535;
13+
14+
events {
15+
worker_connections 4096;
16+
use epoll;
17+
multi_accept on;
18+
}
19+
20+
http {
21+
include /etc/nginx/mime.types;
22+
default_type application/octet-stream;
23+
24+
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
25+
'$status $body_bytes_sent "$http_referer" '
26+
'"$http_user_agent" "$http_x_forwarded_for" '
27+
'cache=$upstream_cache_status';
28+
29+
access_log /var/log/nginx/access.log main;
30+
31+
# Large file optimisations
32+
sendfile on;
33+
tcp_nopush on;
34+
tcp_nodelay on;
35+
keepalive_timeout 65;
36+
37+
# Proxy cache zone configuration:
38+
# keys_zone=cdn_cache:50m — 50MB for cache keys/metadata (~400k keys)
39+
# max_size=50g — on-disk cache (adjust to your PVC size)
40+
# inactive=30d — evict if not accessed in this time
41+
# use_temp_path=off — write directly to cache dir (avoids extra copy)
42+
proxy_cache_path /var/cache/nginx/cdn
43+
levels=1:2
44+
keys_zone=cdn_cache:50m
45+
max_size={{ .Values.cdn.cacheSize }}
46+
inactive={{ .Values.cdn.inactive }}
47+
use_temp_path=off;
48+
49+
# Don't buffer large files to disk before sending — stream them
50+
proxy_buffering on;
51+
proxy_request_buffering off;
52+
53+
# Increase timeouts for large file transfers
54+
proxy_connect_timeout 10s;
55+
proxy_send_timeout 300s;
56+
proxy_read_timeout 300s;
57+
send_timeout 300s;
58+
59+
# Hide upstream headers we don't want to leak
60+
proxy_hide_header x-amz-request-id;
61+
proxy_hide_header x-amz-id-2;
62+
63+
include /etc/nginx/conf.d/*.conf;
64+
}
65+
default.conf: |
66+
upstream s3_origin {
67+
server {{ .Values.cdn.objectStorageServerHostname }}:443;
68+
keepalive 32;
69+
}
70+
71+
server {
72+
listen 8080;
73+
server_name _;
74+
75+
# TLS — cert mounted from a k8s secret via ingress or directly
76+
#ssl_certificate /etc/nginx/tls/tls.crt;
77+
#ssl_certificate_key /etc/nginx/tls/tls.key;
78+
#ssl_protocols TLSv1.2 TLSv1.3;
79+
#ssl_ciphers HIGH:!aNULL:!MD5;
80+
81+
proxy_cache cdn_cache;
82+
proxy_cache_valid 200 206 7d; # Cache 200 and partial content
83+
proxy_cache_valid 404 1m; # Don't cache 404s for long
84+
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
85+
proxy_cache_lock on; # Collapse simultaneous requests for the same file
86+
proxy_cache_lock_timeout 10s;
87+
88+
proxy_cache_key "$scheme$proxy_host$uri";
89+
90+
add_header X-Cache-Status $upstream_cache_status always;
91+
add_header X-Served-By $hostname always;
92+
93+
add_header X-Content-Type-Options nosniff always;
94+
add_header X-Frame-Options DENY always;
95+
96+
proxy_cache_revalidate on;
97+
proxy_cache_bypass 0;
98+
proxy_no_cache 0;
99+
proxy_ignore_headers Cache-Control Expires Set-Cookie;
100+
101+
location /{{ .Values.cdn.bucketName }}/ {
102+
# Forward to Object Storage.
103+
# S3-compatible API expects requests in the form: /bucket-name/key
104+
# Our clients are using URL paths in the exact same format.
105+
proxy_pass https://s3_origin$request_uri;
106+
107+
proxy_http_version 1.1;
108+
proxy_set_header Connection ""; # keepalive to upstream
109+
proxy_set_header Host rook-ceph-rgw-ceph-objectstore.rook-ceph.svc;
110+
proxy_set_header X-Real-IP $remote_addr;
111+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
112+
113+
# Don't forward auth headers downstream
114+
proxy_set_header Authorization "";
115+
116+
# SSL settings for upstream connection
117+
proxy_ssl_server_name on;
118+
proxy_ssl_protocols TLSv1.2 TLSv1.3;
119+
120+
# Tell clients files are immutable — they should cache forever
121+
add_header Cache-Control "public, max-age=31536000, immutable" always;
122+
123+
# Support resumable downloads
124+
proxy_force_ranges on;
125+
126+
# Stream large files rather than buffering defeats the cache, so keep buffering on:
127+
proxy_buffering on;
128+
}
129+
130+
# Health check endpoint (used by k8s liveness/readiness probes)
131+
location /healthz {
132+
access_log off;
133+
return 200 "ok\n";
134+
add_header Content-Type text/plain;
135+
}
136+
137+
# Expose basic cache stats (restrict to internal)
138+
location /nginx_status {
139+
stub_status;
140+
allow 10.0.0.0/8;
141+
allow 172.16.0.0/12;
142+
allow 192.168.0.0/16;
143+
deny all;
144+
}
145+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{{- if .Values.cdn.objectBucketIsLocal }}
2+
apiVersion: objectbucket.io/v1alpha1
3+
kind: ObjectBucketClaim
4+
metadata:
5+
name: {{ .Values.cdn.bucketName }}
6+
spec:
7+
bucketName: {{ .Values.cdn.bucketName }}
8+
storageClassName: ceph-bucket
9+
additionalConfig:
10+
maxObjects: "1000"
11+
maxSize: {{ .Values.cdn.bucketMaxSize }}
12+
{{- end }}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Persistent volume for the Nginx cache.
2+
apiVersion: v1
3+
kind: PersistentVolumeClaim
4+
metadata:
5+
name: nginx-cache
6+
spec:
7+
accessModes:
8+
- ReadWriteOnce
9+
storageClassName: {{ .Values.cdn.CacheStorageClassName }}
10+
resources:
11+
requests:
12+
storage: {{ .Values.cdn.cacheSize }}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: v1
2+
kind: Service
3+
metadata:
4+
name: cdn-edge
5+
spec:
6+
selector:
7+
app: cdn-edge
8+
ports:
9+
- name: http
10+
port: 80
11+
targetPort: 8080
12+
type: ClusterIP

0 commit comments

Comments
 (0)