Problem Description
During rapid auto‑scaling of a real‑time video analytics pipeline, newly launched EC2 instances intermittently crash the AI inference workers and drop video frames. The failure manifests as binding errors from both the TensorFlow Serving containers and the WebSocket ingestion daemons:
EADDRINUSE: address already in use :::8080
BindException: Address already in use (Bind failed) // TensorFlow Serving
docker: Error response from daemon: driver failed programming external connectivity on endpoint ...: Bind for 0.0.0.0:5000 failed: port is already allocated.
WebSocket server error: listen EADDRINUSE 0.0.0.0:3000
These errors appear in docker logs, journalctl -u inference.service, and the application console. The symptoms are:
- Worker process termination after
docker runreturns a non‑zero exit code. - Missing frames in the downstream video stream, leading to degraded model accuracy.
- Auto Scaling events trigger the issue more frequently, indicating a per‑instance port exhaustion or collision.
Root Cause Analysis
The pipeline runs multiple containerized microservices on the same EC2 host:
- One or more model‑serving containers (TensorFlow Serving or TorchServe) exposing gRPC/REST on a static port (e.g., 5000 or 8080).
- A WebSocket ingestion service that opens a listener for RTSP/RTMP streams on a static port (e.g., 3000).
- Optional side‑car data collectors that also bind to fixed ports for metrics export.
When the Auto Scaling group launches a new instance, the launch script (often a UserData or Lifecycle Hook script) starts the same Docker‑Compose file on every node. Because the compose file maps container ports directly to 0.0.0.0:PORT, each instance attempts to bind the same host ports. The EC2 network stack, as described in the AWS EC2 User Guide – Network Interfaces and ENIs, permits only one socket per IP:PORT tuple per instance. Consequently, the second container that tries to bind the same port fails with EADDRINUSE.
Two additional factors exacerbate the problem:
- Limited ephemeral port range: The ingestion daemon reuses a static range (3000‑3100) for each video source. Under high concurrency the host runs out of free ports, as documented in the DeepStream‑AWS‑Scale incident.
- Security Group overlap: The VPC security group permits inbound traffic on the static ports, but overlapping inbound rules do not resolve the binding conflict; they only allow traffic once the socket is successfully created. This is highlighted in the Amazon VPC Documentation – Security Groups and Inbound/Outbound Rules.
Investigation and Debugging
1. Identify conflicting sockets
sudo ss -tlnp | grep -E '5000|8080|3000'
# Expected output on a healthy node:
# LISTEN 0 128 0.0.0.0:5000 0.0.0.0:* users:(("docker-proxy",pid=1234,fd=4))
# LISTEN 0 128 0.0.0.0:3000 0.0.0.0:* users:(("docker-proxy",pid=1235,fd=4))
On a failing node you will see duplicate entries or a “Address already in use” message in the Docker daemon logs.
2. Correlate Docker compose with host ports
cat docker-compose.yml
services:
tf_serving:
image: tensorflow/serving
ports:
- "5000:8500"
ws_ingest:
image: custom/ws-ingest
ports:
- "3000:3000"
The static mapping "5000:8500" forces every instance to claim host port 5000.
3. Verify Auto Scaling lifecycle hook execution
aws autoscaling describe-lifecycle-hooks --auto-scaling-group-name video-pipeline-asg
# Look for a hook that runs a post‑launch script (e.g., configure‑ports.sh)
4. Capture a packet trace (optional)
sudo tcpdump -i eth0 port 5000 -c 5 -w /tmp/port5000.pcap
# No traffic will be seen if the socket never bound.
5. Review EC2 ENI limits
The EC2 User Guide notes that each ENI can support a finite number of concurrent connections. When many containers bind to the same port, the ENI’s connection table fills, causing additional bind attempts to fail.
Resolution
Strategy Overview
Replace static host‑port mappings with dynamic, per‑instance offsets and ensure each container discovers a free port at runtime. The solution consists of three parts:
- Modify
docker-compose.ymlto expose container ports only internally (expose) and publish them on a random host port. - Add a startup script (executed via an Auto Scaling lifecycle hook or
UserData) that queries the OS for an unused port range and injects the values as environment variables. - Update the inference and ingestion services to read the port from the environment variable instead of hard‑coding it.
Before – Static Port Mapping
services:
tf_serving:
image: tensorflow/serving
ports:
- "5000:8500"
ws_ingest:
image: custom/ws-ingest
ports:
- "3000:3000"
After – Dynamic Port Assignment
services:
tf_serving:
image: tensorflow/serving
expose:
- "8500"
environment:
- TF_SERVING_PORT=${TF_SERVING_PORT}
command: >
bash -c "python /serve.py --port ${TF_SERVING_PORT}"
ws_ingest:
image: custom/ws-ingest
expose:
- "3000"
environment:
- WS_INGEST_PORT=${WS_INGEST_PORT}
command: >
bash -c "node server.js --port ${WS_INGEST_PORT}"
Port Allocation Script (configure‑ports.sh)
#!/usr/bin/env bash
set -euo pipefail
# Find two free ports in the 5000‑6000 range
TF_PORT=$(comm -23 <(seq 5000 6000) <(ss -tlnp | awk '{print $5}' | cut -d: -f2 | sort -u) | head -n 1)
WS_PORT=$(comm -23 <(seq 3000 4000) <(ss -tlnp | awk '{print $5}' | cut -d: -f2 | sort -u) | head -n 1)
# Export for Docker Compose
export TF_SERVING_PORT=$TF_PORT
export WS_INGEST_PORT=$WS_PORT
# Persist for later runs (e.g., systemd service)
echo "TF_SERVING_PORT=$TF_PORT" >> /etc/environment
echo "WS_INGEST_PORT=$WS_PORT" >> /etc/environment
echo "Allocated ports: TF_SERVING=$TF_PORT, WS_INGEST=$WS_PORT"
Run this script from a lifecycle hook:
aws autoscaling complete-lifecycle-action \
--lifecycle-hook-name configure-ports-hook \
--auto-scaling-group-name video-pipeline-asg \
--lifecycle-action-result CONTINUE \
--instance-id $(curl -s http://169.254.169.254/latest/meta-data/instance-id)
Service Code Adjustments
TensorFlow Serving wrapper (Python example):
import os, argparse, subprocess
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=int(os.getenv('TF_SERVING_PORT', '8500')))
args = parser.parse_args()
subprocess.run([
'tensorflow_model_server',
'--rest_api_port={}'.format(args.port),
'--model_name=my_model',
'--model_base_path=/models/my_model'
])
WebSocket server (Node.js example):
const http = require('http');
const ws = require('ws');
const port = process.env.WS_INGEST_PORT || 3000;
const server = http.createServer();
const wss = new ws.Server({ server });
wss.on('connection', socket => {
// handle streaming data
});
server.listen(port, () => {
console.log(`WebSocket ingest listening on ${port}`);
});
Verification
- Port allocation check – after the instance boots, run:
echo $TF_SERVING_PORT $WS_INGEST_PORT # Example output: 5123 3125 - Socket binding verification – confirm no
EADDRINUSEerrors:sudo ss -tlnp | grep -E '5123|3125' # Expected output: # LISTEN 0 128 0.0.0.0:5123 *:* users:(("docker-proxy",pid=2345,fd=4)) # LISTEN 0 128 0.0.0.0:3125 *:* users:(("docker-proxy",pid=2346,fd=4)) - Health endpoint – each container now exposes a
/healthzthat reports the bound port:curl http://localhost:5123/v1/models/my_model/metadata # 200 OK indicates TensorFlow Serving is reachable. - End‑to‑end video flow – monitor the ingest pipeline (e.g., via CloudWatch metrics
IncomingFrames) and confirm that frame‑drop rates return to baseline (< 0.1%).
Prevention and Best Practices
- Never hard‑code host ports in Docker Compose for services that run on shared EC2 instances. Use
exposeand let the host assign a free port, or compute an offset per instance. - Leverage Auto Scaling lifecycle hooks to run a deterministic port‑allocation script before the service starts. The AWS Auto Scaling – Lifecycle Hooks and Instance Warm‑up guide provides the exact hook registration steps.
- Reserve a dedicated ENI per microservice when possible. This isolates port namespaces and avoids ENI connection‑table exhaustion.
- Monitor port binding failures with a CloudWatch metric filter on
EADDRINUSEin/var/log/docker.log. Trigger an alarm to catch regressions early. - Document the dynamic port range in your security groups. For example, allow inbound traffic on
5000‑6000(model serving) and3000‑4000(WebSocket) only from trusted sources. - Implement graceful shutdown in each container so that on instance termination the sockets are released, preventing “zombie” bindings on replacement instances.
Related Topic Hub: Cloud Infrastructure Troubleshooting Hub
FAQ
- Why does the error appear only after an Auto Scaling event?
Because the static port mapping is duplicated on the newly launched instance, which shares the same host network namespace as the existing containers. The first instance already occupies the port, so the second fails. - Can I keep using static ports if I assign each container to a different ENI?
Yes, assigning each microservice to its own ENI creates separate network namespaces, but it adds operational overhead and cost. Dynamic port assignment is usually simpler and scales better. - How do I expose the dynamically assigned ports to external clients?
Publish the allocated ports via a service registry (e.g., Consul, AWS Cloud Map) or update an Application Load Balancer target group with the instance IP and allocated port using theregister-targetsCLI. - What if the script cannot find a free port?
Increase the searched range or reduce the number of concurrent containers per instance. You can also configure the OSnet.ipv4.ip_local_port_rangeto enlarge the ephemeral pool. - Do security groups need to be changed when using dynamic ports?
Yes, the security group must allow the entire range from which ports are allocated (e.g., 3000‑4000 and 5000‑6000). Overly restrictive rules will cause connection attempts to be blocked even if the bind succeeds.