LinuxTcp keepalive configuration guide

TCP Keepalive - Configuration Guide

A comprehensive guide for configuring TCP keepalive and load balancer timeouts in AWS and Azure with detailed NGINX Ingress configurations.

Overview

TCP keepalive is an OS-level mechanism that sends zero-byte probe packets on idle TCP connections to verify the remote peer is still reachable. Without it, firewalls, NAT gateways, and cloud load balancers silently drop idle connections — causing connection reset by peer errors that are difficult to trace.

This document covers the full stack: OS configuration, packet capture and analysis, Go client implementation, cloud load balancer alignment, NGINX Ingress, and database proxy patterns.


1. Architecture

Multi-Layer Connection Stack

Key insight: TCP keepalive only affects the connection where it is configured. NGINX terminates and re-creates connections — Connection B and Connection C are independent TCP sessions.


2. Core Concepts

How TCP Keepalive Works

What Makes a Keepalive Packet

PropertyValueReason
Payload length0 bytesPure probe — no application data
TCP FlagsACK only [.]Not SYN, FIN, RST, or PSH
SEQ numberlast_ack − 1One below expected — deliberate probe signature
DirectionClient → Server, then Server → ClientProbe + ACK pair

With vs Without Keepalive at Load Balancer


3. OS Sysctl Parameters

Read Current Values

sysctl net.ipv4.tcp_keepalive_time \
       net.ipv4.tcp_keepalive_intvl \
       net.ipv4.tcp_keepalive_probes

Parameter Reference

ParameterDefaultDescription
tcp_keepalive_time7200s (2h)Seconds of idle before the first probe is sent
tcp_keepalive_intvl75sSeconds between subsequent probes
tcp_keepalive_probes9Max unacknowledged probes before connection is dropped

Total dead-connection detection time = tcp_keepalive_time + (tcp_keepalive_intvl × tcp_keepalive_probes) Example: 120 + (10 × 9) = 210s

Lower for Testing

sudo sysctl -w net.ipv4.tcp_keepalive_time=10
sudo sysctl -w net.ipv4.tcp_keepalive_intvl=5
sudo sysctl -w net.ipv4.tcp_keepalive_probes=3

To persist across reboots, add to /etc/sysctl.conf:

net.ipv4.tcp_keepalive_time = 120
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.tcp_keepalive_probes = 9

4. Packet Capture — tcpdump

Always capture the full TCP stream including the handshake. Without stream context, tshark cannot label keepalive probes correctly.

sudo tcpdump -i eth0 -w keepalive_3306.pcap 'tcp port 3306'

Live Capture — Keepalives Only

Uses a BPF expression to compute TCP payload length inline and match zero-byte ACKs:

sudo tcpdump -i eth0 -nn \
  'tcp port 3306 and (tcp[tcpflags] & tcp-ack != 0) and \
  (ip[2:2] - ((ip[0] & 0xf)<<2) - ((tcp[12] & 0xf0)>>2)) == 0'

Read a Saved pcap

tcpdump -nn -r keepalive_3306.pcap

Reading tcpdump Output

14:23:01.123456 IP 10.0.0.1.54321 > 10.0.0.2.3306: Flags [.], seq 999:999, ack 1000, win 512, length 0
14:23:01.124100 IP 10.0.0.2.3306 > 10.0.0.1.54321: Flags [.], seq 1000:1000, ack 1000, win 512, length 0
FieldValueMeaning
Flags [.]. = ACK onlyNo SYN, FIN, RST — pure ACK
seq 999:999Start = EndZero bytes of data — probe signature
length 00Confirms zero payload
Line 2Direction reversedServer ACK — connection confirmed alive

Note: Replace eth0 with your actual interface. Run ip link or tcpdump -D to list available interfaces.


5. Packet Analysis — tshark

Read pcap — Keepalives Only

tshark -r keepalive_3306.pcap \
  -Y 'tcp.analysis.keep_alive or tcp.analysis.keep_alive_ack' \
  -T fields \
  -e frame.time \
  -e ip.src \
  -e ip.dst \
  -e tcp.srcport \
  -e tcp.dstport \
  -e tcp.seq \
  -e tcp.ack \
  -E header=y \
  -E separator=$'\t'

Live Capture with tshark

tshark -i eth0 \
  -Y 'tcp.analysis.keep_alive or tcp.analysis.keep_alive_ack' \
  -T fields \
  -e frame.time -e ip.src -e ip.dst \
  -e tcp.srcport -e tcp.dstport -e tcp.seq -e tcp.ack \
  -E header=y -E separator=$'\t'

Sample Output

frame.time              ip.src      ip.dst      srcport  dstport  seq   ack
Apr 29, 2026 14:23:01   10.0.0.1    10.0.0.2    54321    3306     999   1000
Apr 29, 2026 14:23:01   10.0.0.2    10.0.0.1    3306     54321    1000  1000
Apr 29, 2026 14:23:11   10.0.0.1    10.0.0.2    54321    3306     999   1000
FieldMeaning
tcp.seq = 999SEQ is one below the expected byte — probe signature
Row 1 → Row 2Probe then ACK — peer alive
Time delta between row 1 and row 3Should match tcp_keepalive_intvl

Why the pcap must include the handshake: tcp.analysis.keep_alive requires tshark to track the stream from the SYN. Pre-filtering with tcpdump BPF before saving strips the handshake — tshark returns no rows even though the packets are correct.


6. Connection Monitoring — ss

Check Keepalive Timer

ss -tnop | grep 3306 | grep timer

Watch Live

watch -n 1 'ss -tnop | grep 3306 | grep timer'

Sample Output

ESTAB 0 0 10.0.0.1:54321 10.0.0.2:3306 users:(("app",pid=1234,fd=7)) timer:(keepalive,58s,0)

Timer Field Reference

timer:(  keepalive,   58s,   0  )
         ^            ^      ^
         type         |      unacknowledged probe count
                      countdown to next probe
Timer ValueConnection State
timer:(keepalive,Xs,0)Idle — timer counting down to next probe
timer:(on,Xms,0)Active — retransmit timer, data in flight
timer:(keepalive,0s,0)Probe being sent right now
No timer: fieldEstablished, no pending timer

When the unacknowledged probe count reaches tcp_keepalive_probes, the OS drops the connection and the application receives an error on the next read or write.


7. Go Client Implementation

Production-ready client supporting both JDBC URL format and direct DSN format to test keep alive probe packet flow with its kernel settings.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"net"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/go-sql-driver/mysql"
)

func main() {
	jdbcURL := os.Getenv("DB_URL")
	directDSN := os.Getenv("DB_DSN")

	var dsn string
	var err error

	switch {
	case jdbcURL != "":
		fmt.Println("[CLIENT] Input format: JDBC URL")
		dsn, err = jdbcToMySQLDSN(jdbcURL)
		if err != nil {
			log.Fatalf("Failed to parse JDBC URL: %v", err)
		}

	case directDSN != "":
		fmt.Println("[CLIENT] Input format: Direct DSN")
		dsn = directDSN

	default:
		log.Fatal("Set either DB_URL (JDBC) or DB_DSN (direct) environment variable")
	}

	registerKeepaliveDialer()
	dsn = strings.Replace(dsn, "@tcp(", "@tcp-keepalive(", 1)

	fmt.Printf("[CLIENT] DSN: %s
", maskPassword(dsn))

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatalf("Failed to open database: %v", err)
	}
	defer db.Close()

	db.SetMaxOpenConns(1)
	db.SetMaxIdleConns(1)
	db.SetConnMaxLifetime(0)

	if err := db.Ping(); err != nil {
		log.Fatalf("Failed to connect: %v", err)
	}

	fmt.Println("[CLIENT] Connected. Connection is now IDLE.")
	fmt.Println("[CLIENT] OS will send keepalive probes using sysctl values.")
	fmt.Println(strings.Repeat("=", 60))

	startTime := time.Now()
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		fmt.Printf("[CLIENT] Idle for %ds — run: ss -tnop | grep 3306 | grep timer
",
			int(time.Since(startTime).Seconds()))
	}
}

// registerKeepaliveDialer enables SO_KEEPALIVE on every new MySQL connection.
//
// KeepAlive: -1 enables the socket option without overriding OS sysctl values.
// The kernel applies tcp_keepalive_time, tcp_keepalive_intvl, and tcp_keepalive_probes
// automatically — the application does not need to read or pass those values.
func registerKeepaliveDialer() {
	mysql.RegisterDialContext("tcp-keepalive", func(ctx context.Context, addr string) (net.Conn, error) {
		dialer := &net.Dialer{
			Timeout: 10 * time.Second,
			// -1 enables SO_KEEPALIVE and delegates all timing to OS sysctl.
			// Do not set a positive duration here — that overrides sysctl values
			// and prevents operators from tuning keepalive without a code change.
			KeepAlive: -1,
		}

		conn, err := dialer.DialContext(ctx, "tcp", addr)
		if err != nil {
			return nil, err
		}

		fmt.Println("[CLIENT] SO_KEEPALIVE enabled — OS sysctl values apply automatically")
		return conn, nil
	})
}

// jdbcToMySQLDSN converts a JDBC connection URL to a Go MySQL DSN.
//
// Input:
//
//	jdbc:mysql://host:port/database?param1=value1&param2=value2
//
// Output:
//
//	user:password@tcp(host:port)/database?param1=value1
//
// Credentials are read from DB_USER and DB_PASSWORD environment variables.
func jdbcToMySQLDSN(jdbcURL string) (string, error) {
	if !strings.HasPrefix(jdbcURL, "jdbc:mysql://") {
		return "", fmt.Errorf("invalid JDBC URL: must start with jdbc:mysql://")
	}

	raw := strings.TrimPrefix(jdbcURL, "jdbc:mysql://")
	parts := strings.SplitN(raw, "/", 2)
	if len(parts) != 2 {
		return "", fmt.Errorf("invalid JDBC URL: missing database path")
	}

	hostPort := parts[0]
	dbParts := strings.SplitN(parts[1], "?", 2)
	database := dbParts[0]

	params := url.Values{}
	params.Set("parseTime", "true")

	if len(dbParts) > 1 {
		for _, pair := range strings.Split(dbParts[1], "&") {
			kv := strings.SplitN(pair, "=", 2)
			if len(kv) != 2 {
				continue
			}
			key, value := kv[0], kv[1]

			switch key {
			case "useSSL":
				if value == "true" {
					params.Set("tls", "true")
				}
			case "requireSSL":
				if value == "true" {
					params.Set("tls", "skip-verify")
				}
			case "characterEncoding":
				params.Set("charset", value)
			case "connectTimeout":
				if ms, err := strconv.Atoi(value); err == nil {
					params.Set("timeout", fmt.Sprintf("%dms", ms))
				}
			case "socketTimeout":
				if ms, err := strconv.Atoi(value); err == nil {
					params.Set("readTimeout", fmt.Sprintf("%dms", ms))
					params.Set("writeTimeout", fmt.Sprintf("%dms", ms))
				}
			// Intentionally ignored — handled by registerKeepaliveDialer or not applicable in Go:
			// tcpKeepAlive, autoReconnect, cacheServerConfiguration, sessionVariables,
			// zeroDateTimeBehavior, scrollTolerantForwardOnly, jdbcCompliantTruncation
			}
		}
	}

	user := getEnv("DB_USER", "root")
	password := getEnv("DB_PASSWORD", "")

	return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", user, password, hostPort, database, params.Encode()), nil
}

func getEnv(key, defaultValue string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return defaultValue
}

func maskPassword(dsn string) string {
	at := strings.Index(dsn, "@")
	if at == -1 {
		return dsn
	}
	userPass := dsn[:at]
	colon := strings.Index(userPass, ":")
	if colon == -1 {
		return dsn
	}
	return userPass[:colon] + ":****@" + dsn[at+1:]
}

Environment Variables

VariableFormatExample
DB_URLJDBC URLjdbc:mysql://host:3306/db?useSSL=true&tcpKeepAlive=true
DB_DSNGo MySQL DSNuser:pass@tcp(host:3306)/db?tls=skip-verify
DB_USERPlain stringappuser
DB_PASSWORDPlain stringsecret

DSN Format Reference

# Direct DSN (DB_DSN)
user:password@tcp(host:port)/database?param=value

# With TLS + charset
appuser:secret@tcp(mysql.internal:3306)/mydb?tls=skip-verify&parseTime=true&charset=utf8

# JDBC URL (DB_URL) — converted automatically by jdbcToMySQLDSN
jdbc:mysql://mysql.internal:3306/mydb?useSSL=true&requireSSL=true&characterEncoding=utf8&tcpKeepAlive=true

JDBC Parameter Mapping

JDBC ParameterGo DSN EquivalentNotes
useSSL=truetls=true
requireSSL=truetls=skip-verifyCertificate not verified
characterEncoding=utf8charset=utf8
connectTimeout=5000timeout=5000msMilliseconds
socketTimeout=30000readTimeout=30000ms + writeTimeout=30000ms
tcpKeepAlive=trueHandled by custom dialerNot a DSN parameter
autoReconnect=trueNot mappedHandled by connection pool
sessionVariables=...Not mappedJDBC-specific

8. Cloud Load Balancer Alignment

How Modern Apps Enable Keepalive

Applications set only SO_KEEPALIVE on the socket. The kernel applies all three sysctl values automatically — the app never reads or sets them directly.

Do not set TCP_KEEPIDLE, TCP_KEEPINTVL, or TCP_KEEPCNT in application code unless you have a per-connection requirement that must differ from the system default. Hardcoding these prevents operators from tuning keepalive via sysctl without a code deployment.

Timeout Formula

tcp_keepalive_time  =  LB_timeout × 0.5     (50% safety margin)
tcp_keepalive_intvl =  10 – 30s             (based on network latency)
tcp_keepalive_probes=  6 – 9                (balance detection speed vs tolerance)

Cloud Provider Timeout Matrix

ProviderLB TypeLB Idle TimeoutRecommended keepalive_timeConfigurable?
AWSALB60s (default)30sYes — 1 to 4000s
AWSNLB350s (fixed)180sYes - 60-6000 seconds
AzureStandard LB240s (default)120sYes — 4 to 30 min

AWS ALB — Configure Idle Timeout

ALB idle timeout is not configurable via Kubernetes Service annotation. Use AWS CLI or Terraform:

ALB_ARN=$(aws elbv2 describe-load-balancers \
  --query 'LoadBalancers[?contains(LoadBalancerName, `k8s`)].LoadBalancerArn' \
  --output text)

aws elbv2 modify-load-balancer-attributes \
  --load-balancer-arn $ALB_ARN \
  --attributes Key=idle_timeout.timeout_seconds,Value=120

Azure Standard LB — Configure via Annotation

apiVersion: v1
kind: Service
metadata:
  annotations:
    service.beta.kubernetes.io/azure-load-balancer-sku: "Standard"
    service.beta.kubernetes.io/azure-load-balancer-tcp-idle-timeout: "10"  # minutes, range: 4–30

9. Kubernetes & NGINX Ingress

Set Sysctl via Pod Security Context

spec:
  securityContext:
    sysctls:
    - name: net.ipv4.tcp_keepalive_time
      value: "120"
    - name: net.ipv4.tcp_keepalive_intvl
      value: "15"
    - name: net.ipv4.tcp_keepalive_probes
      value: "9"

NGINX Ingress ConfigMap — Key Settings

apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  # Client-side: keep idle client connections open (must be < LB timeout)
  keep-alive: "120"
  keep-alive-requests: "1000"

  # Upstream: connection pool to backend pods
  upstream-keepalive-connections: "64"
  upstream-keepalive-timeout: "60"
  upstream-keepalive-requests: "1000"

  # Proxy timeouts
  proxy-connect-timeout: "10"
  proxy-read-timeout: "120"
  proxy-send-timeout: "120"

Per-Route Timeout Overrides

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "120"
    nginx.ingress.kubernetes.io/proxy-connect-timeout: "10"
    nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "60"

WebSocket / Long-Lived Connections

metadata:
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/websocket-services: "websocket-svc"

Environment Quick-Reference

EnvironmentLB Timeoutkeepalive_timeNGINX keep-alive
AWS ALB default60s30s50s
AWS NLB (fixed)350s180s300s
Azure default240s120s120s

10. Database Proxy Patterns

NGINX Stream Proxy for MySQL / PostgreSQL

stream {
    upstream mysql_backend {
        server mysql.internal:3306;
    }

    server {
        listen 3306;
        proxy_pass mysql_backend;

        # Must be shorter than: DB wait_timeout, Cloud NAT timeout, firewall timeout
        proxy_timeout 300s;
        proxy_connect_timeout 10s;

        # Enables SO_KEEPALIVE using system sysctl values
        proxy_socket_keepalive on;
    }
}

Database Idle Timeout Reference

Fix: Ensure proxy_timeout is shorter than the application's connection pool idle time, and enable proxy_socket_keepalive on.

DatabaseDefault Idle TimeoutRelevant Parameters
MySQL28800s (8h)wait_timeout, interactive_timeout
PostgreSQLInfinitetcp_keepalives_idle, tcp_keepalives_interval
MongoDBInfinitenet.maxIdleTimeMs
RedisInfinitetimeout

11. Reference Tables

Full Diagnostic Command Set

PurposeCommand
View sysctl valuessysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
Capture full stream to pcapsudo tcpdump -i eth0 -w keepalive_3306.pcap 'tcp port 3306'
Live keepalive-only capturesudo tcpdump -i eth0 -nn 'tcp port 3306 and (tcp[tcpflags] & tcp-ack != 0) and (ip[2:2] - ((ip[0] & 0xf)<<2) - ((tcp[12] & 0xf0)>>2)) == 0'
Read raw pcaptcpdump -nn -r keepalive_3306.pcap
Analyse pcap with tsharktshark -r keepalive_3306.pcap -Y 'tcp.analysis.keep_alive or tcp.analysis.keep_alive_ack' -T fields -e frame.time -e ip.src -e ip.dst -e tcp.srcport -e tcp.dstport -e tcp.seq -e tcp.ack -E header=y -E separator='\t'
Check socket keepalive timerss -tnop | grep 3306 | grep timer
Watch timer countdown livewatch -n 1 'ss -tnop | grep 3306 | grep timer'

Language Keepalive Equivalents

Language / StackHow SO_KEEPALIVE is enabled
Java / JDBCsocket.setKeepAlive(true) or tcpKeepAlive=true in connection URL
Go (net.Dialer)KeepAlive: -1
Pythonsocket.setsockopt(SOL_SOCKET, SO_KEEPALIVE, 1)
Node.jssocket.setKeepAlive(true)
Csetsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &val, sizeof(val))
NGINX Streamproxy_socket_keepalive on

All of the above result in the same syscall: setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, 1). The kernel does the rest.


12. Troubleshooting

Common Issues

SymptomCauseFix
tshark returns no rows from pcappcap captured without handshakeRecapture with 'tcp port 3306' — no BPF payload filter
ss shows no timer:(keepalive,...)SO_KEEPALIVE not setSet KeepAlive: -1 in dialer or tcpKeepAlive=true in JDBC URL
Probe interval much longer than sysctlApp sends periodic queriesRemove app-level heartbeat — keepalive only fires on idle connections
0 packets capturedWrong network interfaceRun ip link or tcpdump -D
connection reset by peer after idleLB or NAT timeout shorter than keepalive_timeLower tcp_keepalive_time to 50% of LB idle timeout
Keepalive fires but connection still dropsFirewall discarding probesFirewall may be stripping keepalive packets — escalate to network team

Full Diagnostic Sequence

# 1. Check OS values
sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes

# 2. Lower for testing
sudo sysctl -w net.ipv4.tcp_keepalive_time=10
sudo sysctl -w net.ipv4.tcp_keepalive_intvl=5
sudo sysctl -w net.ipv4.tcp_keepalive_probes=3

# 3. Start full stream capture
sudo tcpdump -i eth0 -w keepalive_3306.pcap 'tcp port 3306'

# 4. In another terminal — watch timer countdown
watch -n 1 'ss -tnop | grep 3306 | grep timer'

# 5. Analyse pcap with tshark
tshark -r keepalive_3306.pcap \
  -Y 'tcp.analysis.keep_alive or tcp.analysis.keep_alive_ack' \
  -T fields -e frame.time -e ip.src -e ip.dst \
  -e tcp.srcport -e tcp.dstport -e tcp.seq -e tcp.ack \
  -E header=y -E separator='\t'

# 6. Quick raw read
tcpdump -nn -r keepalive_3306.pcap

References