Caching and CDN for OTT delivery

An appendix for operating large installations: how caching of Perfect Streamer OTT responses works and how to place a caching reverse proxy or a CDN in front of the node. Delivery modes, URL formats and authorisation are described in the main section OTT and DVR.

The server produces responses of three categories. They differ in content lifetime and in whether they can be cached on intermediate nodes (reverse proxy, CDN, client cache).

1. Caching model

1.1. Resources and HTTP headers

Resource

URL

Content-Type

Cache-Control

TS segment (HLS)

/h<sess>/<keyHex>.ts

video/mp2t

public, max-age=60, immutable

VTT segment (subtitles)

/h<sess>/sub/<pid>/<keyHex>.vtt

text/vtt; charset=utf-8

public, max-age=60, immutable

fMP4 segment (DASH / LL-HLS)

/h<sess>/init.mp4, /h<sess>/<key|N>.m4s

video/mp4

public, max-age=60, immutable

DASH MPD

/h<sess>/index.mpd

application/dash+xml; charset=utf-8

public, max-age=1

HLS master

/hls/<stream>/<login>/<pass>/index.m3u8

application/vnd.apple.mpegurl

public, max-age=1

HLS media

/h<sess>/index.m3u8, /h<sess>/sub/<pid>/index.m3u8

application/vnd.apple.mpegurl

public, max-age=1

302 Redirect

/dash/<stream>/<login>/<pass>/index.mpd

—

no-cache, no-store

Raw TS

/http/<stream>/<login>/<pass>

video/mp2t

not set; not cached

1.2. Segment properties

The hexadecimal segment identifier in the URL (<keyHex> in paths /h<sess>/<keyHex>.ts) is computed as a CRC64 of the segment start time and the stream ID, and is globally unique. A segment URL addresses immutable content — repeated requests for the same URL return an identical byte stream (as long as the segment stays within the sliding window).

The immutable directive suppresses conditional revalidation by the client (If-None-Match, If-Modified-Since). The value max-age=60 provides compatibility with a typical timeShiftBufferDepth=40s.

CMAF fMP4 segments (.m4s) and the shared init.mp4 for DASH / Low-Latency HLS are addressed in the same way and cached under the same model (immutable, max-age=60). LL-HLS partial segments (parts) are byte-range requests into the same .m4s, so they do not create a separate cache entry.

1.3. Manifest properties

With max-age=1, cached content is at most one second stale. Combined with proxy_cache_lock on (nginx), bursts of manifest requests merge into one request to the origin per second.

1.4. Content variability

With absPath=0 (the default value, no a URL parameter) the HLS media manifests and the DASH MPD contain no session identifier in the body. The manifest is the same in all sessions of one (stream, param) combination. A reverse-proxy cache can therefore reuse an entry across sessions if the cache key is normalised.

With absPath=1 (URL parameter a=1) the manifest body contains absolute URLs that include the scheme, the host and the session identifier. The content becomes specific to each session, and a cache entry cannot be reused across sessions.

2. Client behaviour

Client

Manifest refresh URL

Effect on the number of sessions

HLS players using the media playlist

/h<sess>/index.m3u8

One session per playback session

DASH players using the root URL

/dash/<stream>/.../index.mpd (refresh loop)

Handled by session reuse (see 3.2)

Browser players (MSE)

/h<sess>/... via <Location> / session URL

One session per playback session

3. Special mechanisms

3.1. HTTP 302 Redirect for DASH

A request of the form /dash/<stream>/<login>/<pass>/index.mpd returns a 302 Found response with the header Location: /h<sess>/index.mpd. The response body is empty. The server checks authorisation and allocates the session while it processes the redirect.

Clients that support redirect caching address the session URL directly in subsequent requests. Clients that do not support it repeat the redirect request. Reprocessing the redirect comes down to the authentication check and the session reuse operations.

3.2. Session reuse for DASH

When processing a /dash/.../index.mpd request from the same login to the same stream (with the same adaptive flag), the server finds an already existing DASH session and returns its identifier again. No new session is created and no slot of the concurrent-connection limit is consumed. Only sessions in the same playback mode are reused: a live session and an archive VOD session (parameters t/d/epg) are not merged.

Applies to DASH only. HLS requires no separate reuse mechanism: HLS clients refresh the media playlist via the session URL and do not create a new session on every refresh.

3.3. Segment reuse across sessions

The path /h<sess>/<keyHex>.ts does not depend on <sess> when <keyHex> is resolved to content: <keyHex> globally and unambiguously identifies a TS segment within the stream. Nginx with a normalised cache key (one that strips the /h<sess>/ prefix) serves all requests for the same <keyHex> from a single cache entry, regardless of which clients issued them.

Deduplication applies only to content-addressed names — segments with a 16-character hexadecimal identifier (.ts, .m4s with <keyHex>, .vtt). Numbered live-DASH segments (<number>.m4s), init.mp4 and manifests are unique only within their own session, so their cache key must keep the /h<sess>/ prefix, otherwise the cache will serve the content of one stream to the viewers of another. For DASH, manifest deduplication between clients of the same login is provided by session reuse (see 3.2).

4. Request parameters

Parameter

Default value

Effect

a

0

1 — absolute URLs in manifests; 0 — relative

s

40

timeShiftBufferDepth in seconds

m

40

Minimum window length required to serve a manifest

v

6 for OTT modes, 3 for Peering / HLS

#EXT-X-VERSION in HLS (ignored by DASH); an explicit value in the URL overrides the default

h3

absent (off)

HTTP/3 (QUIC) on request: the server sends Alt-Svc in the response; the value is bound to the session URL. See HTTP/3 (QUIC)

If you change a parameter through the query string, the values stored in the session are updated the next time it is reopened. The archive playback parameters t, d and epg are described in Archive playback (VOD).

5. Load characteristics

The load on the origin grows with the number of distinct streams that viewers watch at the same time. If a reverse-proxy cache with a normalised cache key stands in front of the origin, a growing number of clients watching the same stream does not increase the number of requests to the origin.

Scenario

Origin request rate (ref.)

1 client on stream X

MPD: 0.4 req/s, segment: 0.2 req/s

N clients on a single stream X (cache enabled)

MPD: 1 req/s, segment: 0.2 req/s

N DASH clients in refresh loop on a single stream

MPD: 1 req/s (with proxy_cache_lock)

N clients on N distinct streams

MPD: 0.4·N req/s, segment: 0.2·N req/s

Segments are deduplicated globally by their content-addressed names; manifest deduplication between clients is provided by session reuse (DASH) and by caching within the session.

6. Nginx as a caching reverse proxy

6.1. Basic configuration

proxy_cache_path /var/cache/nginx/pss_segments
    levels=1:2 keys_zone=pss_segments:100m
    max_size=20g inactive=30m use_temp_path=off;

proxy_cache_path /var/cache/nginx/pss_manifests
    levels=1:2 keys_zone=pss_manifests:10m
    max_size=256m inactive=5m use_temp_path=off;

upstream pss_backend {
    server 127.0.0.1:43972;
    keepalive 64;
}

map $uri $pss_cache_key {
    "~^/h[0-9a-f]{16}(?<tail>(/[0-9]+)?/[0-9a-f]{16}\.(ts|m4s)|/sub/[0-9]+/[0-9a-f]{16}\.vtt)$"  "stream:$tail";
    default  $uri;
}

server {
    listen 80;
    server_name stream.example.com;

    location ~* "^/h[0-9a-f]{16}(/[0-9]+|/sub/[0-9]+)?/([0-9a-f]+\.(ts|m4s|vtt)|init\.mp4)$" {
        proxy_cache               pss_segments;
        proxy_cache_key           $pss_cache_key;
        proxy_cache_valid         200 60s;
        proxy_cache_valid         404 403 0s;
        proxy_cache_lock          on;
        proxy_cache_use_stale     updating error timeout;
        proxy_cache_revalidate    on;
        proxy_ignore_headers      Vary;
        add_header                X-Cache-Status $upstream_cache_status;

        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
        proxy_buffering           on;
    }

    location ~* "(^/h[0-9a-f]{16}(/[0-9]+|/sub/[0-9]+)?/index\.(m3u8|mpd)$|^/(hls|dash|llhls)/.*\.(m3u8|mpd)$)" {
        proxy_cache               pss_manifests;
        proxy_cache_key           $pss_cache_key;
        proxy_cache_valid         200 1s;
        proxy_cache_valid         404 403 0s;
        proxy_cache_lock          on;
        proxy_cache_lock_timeout  2s;
        proxy_cache_use_stale     updating;
        add_header                X-Cache-Status $upstream_cache_status;

        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
    }

    location / {
        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
        proxy_set_header          X-Forwarded-Proto $scheme;
        proxy_set_header          X-Forwarded-Host  $host;
        proxy_buffering           off;
        proxy_read_timeout        3600s;
    }
}

The origin port in the example is the default streaming HTTP server port (43972; HTTPS — 43982). Only content-addressed segment names are normalised (see 3.3); manifests, init.mp4 and numbered segments are cached under the full URI of their own session. The proxy_ignore_headers Vary directive in the segment location is justified: the server adds the Vary: Origin header to OTT responses, and without the directive the cache would be split into variants by the client Origin value (see 7).

6.2. Purpose of the directives

Directive

Purpose

proxy_cache_lock on

Serialises upstream requests on concurrent cache misses for the same key

proxy_cache_use_stale updating

Returns the stale copy to concurrent requests while the cache is being updated

proxy_cache_revalidate on

Uses If-Modified-Since on a cache miss when a stored copy is present

proxy_cache_valid 404 403 0s

Disables caching of authorisation errors and 404

keepalive 64 in upstream

Maintains a pool of persistent connections to the origin

proxy_buffering on

For segments; enables response buffering in nginx

proxy_buffering off

For the / section; disables buffering (raw streaming)

6.3. Calculating max_size of the segment cache

Approximate value: bitrate × timeShiftBufferDepth × distinct_streams × 2

Example: 10 streams × 8 Mbps × 40s × 2 ≈ 800 MB. A 10x margin for bitrate fluctuations is recommended.

6.4. TLS termination

The Perfect Streamer server accepts connections on the HTTP and HTTPS ports. When TLS is terminated on nginx, the upstream uses the HTTP port. The X-Forwarded-Proto and X-Forwarded-Host headers must be forwarded: without them the server builds absolute URLs incorrectly when absPath=1.

server {
    listen 443 ssl http2;
    server_name stream.example.com;

    ssl_certificate           /etc/letsencrypt/live/stream.example.com/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/stream.example.com/privkey.pem;
    ssl_protocols             TLSv1.2 TLSv1.3;
    ssl_session_cache         shared:SSL:10m;
    ssl_session_timeout       1d;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location ... {
        proxy_pass                http://pss_backend;
        proxy_set_header          X-Forwarded-Proto https;
        proxy_set_header          X-Forwarded-Host  $host;
        proxy_set_header          Host              $host;
        # + caching directives from 6.1
    }
}

server {
    listen 80;
    server_name stream.example.com;
    return 301 https://$host$request_uri;
}

When HTTPS is used between nginx and the origin, the directives proxy_ssl_verify and proxy_ssl_trusted_certificate apply. For loopback connections encryption is redundant.

6.5. Multi-host

If one nginx process serves several server_name values, $host is added to the cache key to isolate the content:

map $uri $pss_cache_key {
    "~^/h[0-9a-f]{16}(?<tail>(/[0-9]+)?/[0-9a-f]{16}\.(ts|m4s)|/sub/[0-9]+/[0-9a-f]{16}\.vtt)$"  "$host:stream:$tail";
    default  "$host:$uri";
}

The keys_zone size is calculated as 8000 keys/MB. For multi-host installations with thousands of streams, keys_zone=...:300m or higher is recommended.

7. Client-side caching

Cache-Control: immutable is honoured by modern browsers. The client cache returns the segment without a conditional request on repeated access (including a backward seek within the player buffer).

Service Workers may apply a cache-first strategy based on the Cache-Control header. DASH players use MSE through SourceBuffer; a segment placed into the buffer remains available without a repeated HTTP request until it falls outside the player buffer.

For cross-origin requests the server returns Access-Control-Allow-Origin: * together with the header Vary: Origin, Access-Control-Request-Headers. A caching proxy that honours Vary splits the entries into variants by the client Origin value (browser players send Origin, console clients do not), which lowers the HIT rate. Since the ACAO is the universal “*”, it is safe to add proxy_ignore_headers Vary in the segment location (see 6.1).

8. Delivery through a CDN

Perfect Streamer is compatible with CDNs in pull-from-origin mode.

Origin shield. If clients are spread across the world, it is recommended to place one or several shield nodes between the CDN edge and the origin: they reduce the request rate to the origin.

Purge. Content-addressed segments require no purge. When the stream metadata changes (codec, resolution), the manifests are refreshed within max-age=1 without an explicit purge.

Cache warming. If a load increase on a particular stream is expected, the CDN can be warmed up from several geographic locations before the broadcast starts.

Geographic distribution. Segments (max-age=60) are well suited to geographically distributed caching. Manifests (max-age=1) may lag by up to one second; for live broadcasting that does not need low latency, this is acceptable.

9. Monitoring

9.1. X-Cache-Status

Add add_header X-Cache-Status $upstream_cache_status; to every location with caching. Values:

Value

Description

HIT

Response served from the cache

MISS

Not present in the cache, fetched from the origin and stored

EXPIRED

Expired, refreshed

UPDATING

A stale copy was served to a concurrent request during the update

STALE

use_stale returned an expired copy (the origin is unavailable)

REVALIDATED

The origin returned 304 Not Modified

BYPASS

proxy_cache_bypass was triggered

9.2. Access log format

log_format pss_cache '$remote_addr $status $request_method "$request" '
                     '$body_bytes_sent rt=$request_time ut=$upstream_response_time '
                     'cache=$upstream_cache_status key=$pss_cache_key';

server {
    access_log /var/log/nginx/pss.log pss_cache;
}

9.3. Metrics

The nginx-vts module exports metrics by zone in Prometheus format:

GET /status/format/prometheus

Recommended alert thresholds:

Metric

Threshold

Possible cause

Segment HIT rate

< 90% over 5 minutes

Cache key normalisation is broken; max_size is too small

Manifest MISS rate

> 50% over 1 minute

proxy_cache_lock is not serialising the requests

Upstream response time p95

> 500 ms over 1 minute

Origin overload

Cache zone fill

> 90% over 10 minutes

Approaching max_size, LRU eviction is imminent

Delivery quality on the node side is visible in the session metrics on the “Clients” screen (Clients).

10. Diagnostics

Symptom

Probable cause

Resolution

Low segment HIT rate

Vary: Origin splits the cache into variants; broken normalisation in map

Add proxy_ignore_headers Vary to the segment location; check the regex in the map directive

404 on segments after they leave the window

A cached 404 for a segment that has fallen out of the sliding window

Add proxy_cache_valid 404 0s to the segment location

Playback start delay of 2–5 s

proxy_cache_lock_timeout exceeds the target latency

Reduce to 1–2 s; enable proxy_cache_use_stale updating

Manifest is not refreshed

proxy_ignore_headers Cache-Control is set and a proxy_cache_valid with a long TTL is in effect

Specify proxy_cache_valid 200 1s explicitly or stop ignoring the headers

Growth of TIME_WAIT on the upstream

No keepalive in the upstream block

Add keepalive 64, proxy_http_version 1.1, proxy_set_header Connection ""

403 on /dash/.../<segment>.m4s from console clients

The client resolves relative URLs against the pre-redirect URL

The server emits <BaseURL> with an absolute path; compatible in the current build

Stutter and frequent rebuffering on remote clients

Low effective TCP throughput caused by slow start and idle restart at high RTT (300 ms and above)

Tune the Linux network stack on the origin: see 10.1

10.1. Origin TCP tuning for high-RTT clients

The problem appears on clients with a high RTT to the origin (for example, 300 ms and above) when the stream bitrate is close to the channel capacity. The symptoms in the player are frequent rebuffering and dropouts; on the server the client looks normal and no errors are reported.

Cause:

  • TCP slow start. Every new TCP connection starts with a congestion window of about 14 KB and grows it over several RTTs. At an RTT of 300 ms, reaching the full window takes 2–3 seconds. During that time an HLS/DASH segment of 5 s duration (4–6 MB) is downloaded noticeably slower than real time.

  • TCP idle restart. Between segment requests, a client following the HLS pull model pauses for 4–5 s. By default, after such a pause the Linux kernel resets the connection congestion window back to the initial cwnd (the behaviour of net.ipv4.tcp_slow_start_after_idle=1). As a result, on the next GET the keep-alive connection starts transmitting from slow start again — even on an already warmed-up session.

An aggravating factor is that the default CUBIC congestion control copes poorly with long RTTs and packet loss on intermediate network segments.

The solution: two sysctl parameters on the origin.

# Keep congestion window across idle pauses inside keep-alive sessions.
sysctl -w net.ipv4.tcp_slow_start_after_idle=0

# Use BBR instead of CUBIC: better behaviour on long-RTT paths
# with mild packet loss; paces sending instead of bursting.
modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr

To make the change permanent:

cat > /etc/sysctl.d/99-pss-net.conf <<EOF
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_congestion_control    = bbr
EOF
sysctl --system

The main effect comes from the first parameter (tcp_slow_start_after_idle=0) — it directly eliminates the repeated slow start between segment requests within a single keep-alive connection. The second one (BBR) provides additional stability and applies to all new connections. The tuning does not require a restart of Perfect Streamer. These parameters do not apply to HTTP/3 (QUIC) clients: QUIC runs over UDP with its own congestion control.

11. Security

11.1. Session URL

A URL of the form /h<sess>/... serves as a session token: it requires no repeated authentication. Its lifetime is bounded by an inactivity timeout of 60 seconds; with no activity the session is deleted automatically.

Requirements:

  • HTTPS for all OTT paths (/hls/, /dash/, /h<sess>/) in production;

  • The session ID in the Location header of the 302 response is not cached (no-cache, no-store).

11.2. Rate limiting

limit_req_zone $binary_remote_addr zone=dash_top:10m  rate=5r/s;
limit_req_zone $binary_remote_addr zone=hls_top:10m   rate=5r/s;
limit_req_zone $binary_remote_addr zone=llhls_top:10m rate=5r/s;

server {
    location /dash/ {
        limit_req zone=dash_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
    location /hls/ {
        limit_req zone=hls_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
    location /llhls/ {
        limit_req zone=llhls_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
}

The session URL (/h<sess>/) requires no rate limiting — processing is cheap and the responses are cached.

11.3. Caching of error responses

proxy_cache_valid 200 60s;
proxy_cache_valid 301 302 0s;
proxy_cache_valid 404 403 0s;
proxy_cache_valid any 1s;

This setting disables caching of redirects (a unique sess in Location) and of responses reporting authorisation errors or a missing resource.

11.4. Restricting network access to the origin

The streaming HTTP server port (43972 by default; 43982 for HTTPS) must be closed to external traffic. Acceptable configurations:

  1. Bind Perfect Streamer to 127.0.0.1 (when nginx is local)

  2. Firewall rule:

iptables -A INPUT -p tcp -m multiport --dports 43972,43982 ! -s 10.0.0.0/8 -j DROP

12. Middleware integration

12.1. The prefix-login model

Through the prefix-login mechanism, Perfect Streamer delegates user identification to a middleware system. This is a built-in alternative to full integration with external billing (accounts verified by an external system are configured on the “Users / Logins” screen, the “External (billing)” tab, Users / Logins).

The prefix flag is enabled on a local login, after which the server accepts URLs with a login of the form <prefix><subscriber_id>:

/dash/test1/sub42/xxx/index.mpd
/hls/test1/sub43/xxx/index.m3u8

Here sub is the configured login prefix, and 42/43 are the middleware subscriber identifiers; the password is shared.

12.2. Per-subscriber statistics

In the client list each session shows both the configured login and the actual subscriber URL login (match-login), by which the middleware maps sessions to its own subscribers. The data is visible on the “Clients” screen (Clients).

12.3. Limitations of prefix-login

  • Shared password. All subscribers of a prefix pool use the same password. Whoever learns the password gets access to any <prefix><string>.

  • Access granularity. The list of permitted streams applies to the entire prefix pool. Per-subscriber access control is provided by integration with external billing.

  • Password rotation. Changing the password disconnects all active subscribers. To change the password gradually, two prefix logins are set up for a while.