DEV Community

Cover image for Migrating a Live TV & Radio Streaming Platform from VM to Kubernetes
Muhammad Awais Zahid
Muhammad Awais Zahid

Posted on

Migrating a Live TV & Radio Streaming Platform from VM to Kubernetes

Introduction

When our team was tasked with migrating a production live streaming platform to Kubernetes, we knew it wouldn't be straightforward. This platform records 50+ live TV and radio channels 24/7, processes them with AI models, and serves them to thousands of users in real time.

This article covers our complete journey — the architecture decisions, the unexpected challenges, and the solutions we found along the way. If you're working with live streaming, Kubernetes, or both, this should save you significant time.

What We Were Migrating

Our platform, built for a government media organization, does the following:

  • Records 50+ live TV channels (UDP multicast) and radio stations (HTTP streams) continuously

  • Processes recordings with AI models — Speech-to-Text, Voice Sentiment, Speaker Recognition, OCR

  • Serves live HLS streams to a React frontend

  • Stores everything on TrueNAS NFS shared storage

  • Runs background tasks via Celery for channel management and playback date updates

Old setup:Single Ubuntu VM running everything via Docker Compose
New setup:RKE2 Kubernetes cluster with Calico CNI, 5 master nodes, 8 worker nodes

Old Architecture (VM-based)

UDP Multicast (239.x.x.x)
        ↓
VM receives multicast directly
        ↓
Docker Compose services:
  - gstream (new_gstream.py) → records TV channels
  - radio-stream (radio_streams.py) → records radio
  - clipping-backend (Django/Gunicorn)
  - celery-worker + celery-beat
  - nginx-media (serves HLS files)
  - redis
        ↓
NFS mount at /app/LIVEFTP
        ↓
Frontend plays live.m3u8
Enter fullscreen mode Exit fullscreen mode

Simple, but had limitations:

  • Single point of failure

  • No auto-scaling

  • Manual restarts when services crashed

  • No CI/CD pipeline

New Architecture (Kubernetes)

UDP Multicast (239.x.x.x)
        ↓
MediaMTX DaemonSet (hostNetwork: true)
converts UDP → RTSP on every node
        ↓
gstream Deployment
pulls RTSP via cluster DNS
rtsp://mediamtx-service.svc.cluster.local:8554/channel
        ↓
radio-stream Deployment
pulls HTTP streams directly
        ↓
TrueNAS NFS (PV/PVC)
/mnt/truenas/clipping-server2
        ↓
nginx-media Deployment (2 replicas)
serves HLS files
        ↓
Ingress Controller
clipping-nginx.k8s.local
        ↓
Frontend plays stream
Enter fullscreen mode Exit fullscreen mode

Challenge 1: UDP Multicast in Kubernetes
This was our biggest challenge. Our TV channels use UDP multicast (udp://239.x.x.x:port). Multicast traffic works at the network level — a single stream is sent to a multicast group address, and any device that "joins" the group receives it.

The problem:Kubernetes pods have their own network namespace. They cannot receive multicast traffic by default because:

  • Pods get their own virtual network interface

  • Multicast join requests don't reach the physical network interface

  • Standard CNI plugins don't bridge multicast to pod networks

What we tried first:Running gstream with hostNetwork: true
This worked but meant the pod used the node's network directly — defeating the purpose of Kubernetes networking and creating port conflicts.

Our solution: MediaMTX DaemonSet
MediaMTX is a production-ready media server that can receive multicast UDP and re-stream it as RTSP, HLS, WebRTC, or SRT.

We deployed it as a DaemonSet with hostNetwork: true — but only MediaMTX needed host networking, not our application pods:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: mediamtx
  namespace: staging-nimar-clipping
spec:
  selector:
    matchLabels:
      app: mediamtx
  template:
    spec:
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet
      containers:
      - name: mediamtx
        image: bluenviron/mediamtx:latest
        volumeMounts:
        - name: mediamtx-config
          mountPath: /mediamtx.yml
          subPath: mediamtx.yml
Enter fullscreen mode Exit fullscreen mode
protocols: [tcp]
rtspAddress: :8554
api: true
apiAddress: :9997

paths:
  ary-news:
    source: udp://239.1.1.9:1111
  ptv-news:
    source: udp://239.1.1.10:1111
  # ... all 50+ channels
Enter fullscreen mode Exit fullscreen mode

Now gstream could pull streams normally via cluster DNS:

rtsp://mediamtx-service.staging-nimar-clipping.svc.cluster.local:8554/ary-news

No hostNetwork needed for application pods. Clean k8s networking.

Challenge 2: inotify Limit on Nodes

After deploying MediaMTX, some pods on certain nodes kept crashing with:
ERR: couldn't initialize inotify: too many open files

Root cause:Linux has a default limit of 128 inotify instances per user (fs.inotify.max_user_instances). Our nodes were already running many pods, each consuming inotify instances. When MediaMTX tried to start on those nodes, the limit was already hit.

Diagnosis:
sysctl fs.inotify.max_user_instances
fs.inotify.max_user_instances = 128

Fix:
#Immediate fix
sudo sysctl -w fs.inotify.max_user_instances=1280

Permanent fix

echo "fs.inotify.max_user_instances=1280" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

After applying this to all affected nodes, all MediaMTX pods came up healthy. This is a common issue in production Kubernetes clusters — always increase inotify limits on your nodes.

Challenge 3: Stream URLs Stored in Database

Our application (new_gstream.py) reads stream URLs from the database:

channel = Channels.objects.get(id=channel_id)
input_url = channel.rtpUrl # reads from DB

On VM, the DB had:
rtpUrl = udp://239.1.1.9:1111

In Kubernetes, gstream pods can't receive multicast. We needed to update all TV channel URLs in the database to point to MediaMTX:

from live.models import Channels

channels = Channels.objects.filter(source='tv')
for ch in channels:
    channel_slug = ch.display_name.lower().replace(' ', '-')
    ch.rtpUrl = f'rtsp://mediamtx-service.staging-nimar-clipping.svc.cluster.local:8554/{channel_slug}'
    ch.save()
    print(f'Updated: {ch.display_name}')
Enter fullscreen mode Exit fullscreen mode

Key lesson:Before migrating, always audit where your app reads its configuration from — environment variables, files, or database. Database-stored config requires data migration alongside code migration.

Challenge 4: Celery Beat Duplicate Schedules

Celery Beat kept crashing with:

MultipleObjectsReturned: get() returned more than one IntervalSchedule -- it returned 6!

Root cause:The application code was using IntervalSchedule.objects.create() instead of get_or_create(). Every time the service restarted (deployments, rollouts), it created a new duplicate schedule entry in the database

Quick fix:

from django_celery_beat.models import IntervalSchedule

seen = {}
for obj in IntervalSchedule.objects.all():
    key = (obj.every, obj.period)
    if key in seen:
        obj.delete()
    else:
        seen[key] = obj.id
Enter fullscreen mode Exit fullscreen mode

Permanent fix (for developers):

# Wrong
IntervalSchedule.objects.create(every=5, period='minutes')

# Correct
schedule, created = IntervalSchedule.objects.get_or_create(
    every=5,
    period='minutes'
)
Enter fullscreen mode Exit fullscreen mode

Final Kubernetes Manifest Structure

staging-nimar-clipping/
├── namespace.yaml
├── secret.yaml          # all env vars
├── pv-pvc.yaml          # NFS storage
├── nginx-config.yaml    # ConfigMap
├── mediamtx-daemonset.yaml
├── gstream-deployment.yaml
├── radio-stream-deployment.yaml
├── clipping-backend-deployment.yaml
├── celery-worker-deployment.yaml
├── celery-beat-deployment.yaml
└── nginx-media-deployment.yaml
Enter fullscreen mode Exit fullscreen mode

Results

Service      Replicas   Status
MediaMTX      8 ✅ Running
gstream           1 ✅ Running
radio-stream      1 ✅ Running
clipping-backend  1 ✅ Running
celery-worker     1 ✅ Running
celery-beat   1 ✅ Running
nginx-media   2 ✅ Running
Enter fullscreen mode Exit fullscreen mode

Top comments (0)