Server Monitoring Best Practices: Keeping Your C App and PostgreSQL Clusters Alive on AWS
Proactive C Application Health Checks on EC2
Maintaining the health of a C application running on EC2 instances requires more than just basic CPU and memory monitoring. For critical applications, we need to implement application-level health checks that can be queried by external monitoring systems. This involves exposing an HTTP endpoint within the C application that reports its internal state.
A common approach is to use a lightweight HTTP server library within the C application itself. For this example, we’ll assume a simple structure where a dedicated thread listens on a specific port (e.g., 8080) and responds to requests on a `/health` path. The response should indicate the application’s operational status.
Implementing a Basic HTTP Health Endpoint in C
We’ll use a simplified example demonstrating the core logic. In a production environment, you’d integrate this with a robust HTTP server library (like `libmicrohttpd` or `mongoose`) and add more sophisticated checks (e.g., database connection status, queue depth, critical component health).
The health endpoint should return a 200 OK status code for healthy instances and a non-200 status code (e.g., 503 Service Unavailable) for unhealthy ones. The response body can contain JSON detailing specific metrics or error messages.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
// Assume a simplified HTTP server implementation for demonstration
// In production, use a robust library like libmicrohttpd or mongoose
// Global flag to indicate application health
volatile int is_app_healthy = 1;
// Function to simulate a critical resource check
void check_critical_resource() {
// In a real app, this would check database connections, external services, etc.
// For demonstration, we'll randomly make it unhealthy.
if (rand() % 100 < 5) { // 5% chance of becoming unhealthy
is_app_healthy = 0;
fprintf(stderr, "Simulating critical resource failure.\n");
} else {
is_app_healthy = 1;
}
}
// HTTP request handler (simplified)
void handle_http_request(int client_socket) {
char buffer[1024] = {0};
read(client_socket, buffer, 1024);
// Basic check for GET /health
if (strstr(buffer, "GET /health") != NULL) {
check_critical_resource(); // Perform health check
const char* response_header;
const char* response_body;
int status_code;
if (is_app_healthy) {
response_header = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n";
response_body = "{\"status\": \"healthy\", \"message\": \"Application is operational.\"}\n";
status_code = 200;
} else {
response_header = "HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n";
response_body = "{\"status\": \"unhealthy\", \"message\": \"Critical resource failure detected.\"}\n";
status_code = 503;
}
send(client_socket, response_header, strlen(response_header), 0);
send(client_socket, response_body, strlen(response_body), 0);
} else {
// Handle other requests or return 404
const char* not_found_response = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nNot Found\n";
send(client_socket, not_found_response, strlen(not_found_response), 0);
}
close(client_socket);
}
// HTTP server thread function
void* http_server_thread(void* arg) {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
int port = 8080;
// Creating socket file descriptor
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
pthread_exit(NULL);
}
// Forcefully attaching socket to the port 8080
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
pthread_exit(NULL);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(port);
// Forcefully attaching socket to the port 8080
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
pthread_exit(NULL);
}
if (listen(server_fd, 3) < 0) {
perror("listen");
pthread_exit(NULL);
}
printf("HTTP health server listening on port %d\n", port);
while (1) {
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
continue;
}
// Handle request in a simple loop for this example
// In a real app, use threads or an async model for concurrent requests
handle_http_request(new_socket);
}
close(server_fd);
pthread_exit(NULL);
}
// Main application logic
int main() {
pthread_t http_thread;
// Initialize random seed
srand(time(NULL));
// Start the HTTP health server in a separate thread
if (pthread_create(&http_thread, NULL, http_server_thread, NULL) != 0) {
fprintf(stderr, "Failed to create HTTP server thread.\n");
return 1;
}
printf("C application running...\n");
// Main application loop
while (1) {
// Simulate application work
sleep(5);
// In a real app, perform core business logic here.
// The health check thread will independently monitor internal state.
}
// This part is unlikely to be reached in this example
pthread_join(http_thread, NULL);
return 0;
}
AWS Configuration for Health Checks
To leverage this application-level health check, we need to configure AWS services appropriately. This involves:
- Security Groups: Allow inbound traffic on port 8080 from your monitoring tools or load balancer.
- Application Load Balancer (ALB) / Network Load Balancer (NLB): Configure a target group with a health check pointing to the EC2 instance’s IP address (or DNS name) on port 8080 and path `/health`.
- CloudWatch Alarms: Set up alarms based on the ALB/NLB health check status or by polling the `/health` endpoint directly from a separate monitoring instance using a script.
ALB Target Group Health Check Configuration
When setting up your ALB target group, configure the health check as follows:
- Protocol: HTTP
- Port: 8080
- Path: /health
- Healthy threshold: 2 (or more, depending on desired responsiveness)
- Unhealthy threshold: 2 (or more)
- Timeout: 5 seconds
- Interval: 30 seconds
This ensures that the ALB will only route traffic to instances that are actively reporting a healthy status via the application’s endpoint.
PostgreSQL Cluster Monitoring on AWS RDS/EC2
Monitoring PostgreSQL clusters, whether managed by AWS RDS or self-hosted on EC2, requires a multi-faceted approach. We need to track instance-level metrics, PostgreSQL-specific performance counters, and query performance.
Leveraging AWS RDS Metrics
For RDS instances, AWS provides a rich set of CloudWatch metrics out-of-the-box. Key metrics to monitor include:
- CPUUtilization: High CPU can indicate inefficient queries or insufficient instance size.
- DatabaseConnections: Spikes or consistently high connection counts can strain the database.
- ReadIOPS / WriteIOPS: Monitor disk I/O to identify potential bottlenecks.
- ReadLatency / WriteLatency: High latency directly impacts query performance.
- FreeStorageSpace: Crucial to prevent outages due to full disks.
- NetworkReceiveThroughput / NetworkTransmitThroughput: Monitor network traffic.
- Aurora-specific metrics (if applicable): Such as `AuroraConnections`, `AuroraReplicationLag`, `DBClusterCPUUtilization`.
Setting Up CloudWatch Alarms for RDS
Configure CloudWatch alarms to proactively notify your team of potential issues. Here are some example alarm configurations:
Alarm 1: High CPU Utilization
- Metric: `CPUUtilization`
- Namespace: `AWS/RDS`
- Statistic: Average
- Period: 5 minutes
- Threshold type: Static
- Upper threshold: 85%
- Datapoints to alarm: 3 out of 3 (meaning for 15 consecutive minutes)
- Actions: Send notification to SNS topic (e.g., for email, Slack integration).
Alarm 2: Low Free Storage Space
- Metric: `FreeStorageSpace`
- Namespace: `AWS/RDS`
- Statistic: Minimum
- Period: 1 hour
- Threshold type: Static
- Lower threshold: 20 GB (or a percentage like 10%)
- Datapoints to alarm: 1 out of 1
- Actions: Send notification to SNS topic.
Alarm 3: High Database Connections
- Metric: `DatabaseConnections`
- Namespace: `AWS/RDS`
- Statistic: Maximum
- Period: 5 minutes
- Threshold type: Static
- Upper threshold: 80% of instance’s max connections (e.g., if max is 500, set to 400)
- Datapoints to alarm: 3 out of 3
- Actions: Send notification to SNS topic.
Deep Dive: PostgreSQL Performance Metrics via `pg_stat_statements` and `pg_stat_activity`
While CloudWatch provides infrastructure-level insights, understanding PostgreSQL’s internal performance requires querying its system catalogs. The `pg_stat_statements` and `pg_stat_activity` views are invaluable.
First, ensure `pg_stat_statements` is enabled. This typically requires modifying `postgresql.conf` (or `rds.conf.json` for RDS) and restarting the server. Add `pg_stat_statements` to `shared_preload_libraries` and set `pg_stat_statements.track = all`.
-- Example: Enabling pg_stat_statements (requires restart) -- In postgresql.conf: -- shared_preload_libraries = 'pg_stat_statements' -- pg_stat_statements.track = all -- pg_stat_statements.max = 10000 -- pg_stat_statements.track_utility = off
Once enabled, you can query it to find the most resource-intensive queries:
SELECT
query,
calls,
total_exec_time,
rows,
mean_exec_time,
stddev_exec_time
FROM
pg_stat_statements
ORDER BY
total_exec_time DESC
LIMIT 10;
This query reveals the top 10 queries by total execution time. Pay close attention to `mean_exec_time` and `stddev_exec_time` for queries with high variance, which might indicate intermittent performance issues or locking problems.
To monitor currently running queries and identify long-running or blocked processes, use `pg_stat_activity`:
SELECT
pid,
datname,
usename,
client_addr,
backend_start,
state,
wait_event_type,
wait_event,
query_start,
now() - query_start AS duration,
query
FROM
pg_stat_activity
WHERE
state <> 'idle'
AND pid <> pg_backend_pid() -- Exclude the monitoring query itself
ORDER BY
duration DESC
LIMIT 20;
This query is crucial for diagnosing real-time performance problems. Look for queries stuck in `active` state with long `duration`, especially those with `wait_event_type` like `Lock` or `LWLock`.
Monitoring PostgreSQL on EC2 (Self-Hosted)
For PostgreSQL instances running on EC2, you combine OS-level monitoring with PostgreSQL-specific tools. This involves:
- OS Metrics: Use the CloudWatch Agent to collect CPU, memory, disk I/O, and network metrics from the EC2 instance.
- PostgreSQL Logs: Configure PostgreSQL to log slow queries (`log_min_duration_statement`) and errors. Ship these logs to CloudWatch Logs for analysis and alarming.
- Custom Metrics: Use scripts to periodically query `pg_stat_statements` and `pg_stat_activity` and push custom metrics to CloudWatch using the AWS SDK or the CloudWatch Agent’s custom metrics feature.
- Replication Monitoring: For replication setups, monitor `pg_stat_replication` on the primary and `pg_last_xact_replay_timestamp` or `pg_last_wal_receive_lsn`/`pg_last_wal_replay_lsn` on replicas to track lag.
Automating Custom Metric Collection
A Python script using `psycopg2` and `boto3` can be scheduled via `cron` to collect and push custom metrics.
import psycopg2
import boto3
import os
import time
from datetime import datetime, timezone
# --- Configuration ---
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = os.environ.get("DB_PORT", "5432")
DB_NAME = os.environ.get("DB_NAME", "postgres")
DB_USER = os.environ.get("DB_USER", "postgres")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "") # Consider using Secrets Manager
# AWS Configuration
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
CLOUDWATCH_NAMESPACE = "PostgreSQL/Custom"
EC2_INSTANCE_ID = os.environ.get("EC2_INSTANCE_ID", None) # Auto-discover if running on EC2
# --- Helper Functions ---
def get_ec2_instance_id():
if EC2_INSTANCE_ID:
return EC2_INSTANCE_ID
try:
# Attempt to get instance ID if running on EC2
# This requires appropriate IAM role permissions
import requests
response = requests.get('http://169.254.169.254/latest/meta-data/instance-id', timeout=1)
if response.status_code == 200:
return response.text
except Exception as e:
print(f"Could not auto-discover EC2 instance ID: {e}")
return None
def push_metric(cloudwatch_client, metric_name, value, unit, dimensions=None):
if dimensions is None:
dimensions = []
if EC2_INSTANCE_ID:
dimensions.append({'Name': 'InstanceId', 'Value': EC2_INSTANCE_ID})
try:
cloudwatch_client.put_metric_data(
Namespace=CLOUDWATCH_NAMESPACE,
MetricData=[
{
'MetricName': metric_name,
'Dimensions': dimensions,
'Timestamp': datetime.now(timezone.utc),
'Value': value,
'Unit': unit
},
]
)
print(f"Pushed metric: {metric_name}={value} {unit}")
except Exception as e:
print(f"Error pushing metric {metric_name}: {e}")
# --- Main Logic ---
def monitor_postgres():
cloudwatch = boto3.client('cloudwatch', region_name=AWS_REGION)
instance_id = get_ec2_instance_id()
dimensions = []
if instance_id:
dimensions.append({'Name': 'InstanceId', 'Value': instance_id})
else:
print("Warning: EC2 Instance ID not found. Metrics will not be tagged with InstanceId.")
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
dbname=DB_NAME,
user=DB_USER,
password=DB_PASSWORD
)
cur = conn.cursor()
# 1. Monitor pg_stat_statements (Top 5 queries by total time)
cur.execute("""
SELECT
calls,
total_exec_time,
mean_exec_time,
stddev_exec_time
FROM
pg_stat_statements
ORDER BY
total_exec_time DESC
LIMIT 5;
""")
stats = cur.fetchall()
for i, stat in enumerate(stats):
calls, total_time, mean_time, stddev_time = stat
query_prefix = f"Query_{i+1}_"
push_metric(cloudwatch, query_prefix + "Calls", calls, "Count", dimensions)
push_metric(cloudwatch, query_prefix + "TotalExecTime", total_time, "Milliseconds", dimensions)
push_metric(cloudwatch, query_prefix + "MeanExecTime", mean_time, "Milliseconds", dimensions)
push_metric(cloudwatch, query_prefix + "StdDevExecTime", stddev_time, "Milliseconds", dimensions)
# 2. Monitor pg_stat_activity (Number of active connections, long running queries)
cur.execute("""
SELECT
state,
wait_event_type,
now() - query_start AS duration
FROM
pg_stat_activity
WHERE
state <> 'idle'
AND pid <> pg_backend_pid();
""")
activities = cur.fetchall()
active_connections = 0
long_running_threshold_sec = 60 # Define what's considered long-running
long_running_count = 0
for state, wait_event_type, duration in activities:
active_connections += 1
if duration.total_seconds() > long_running_threshold_sec:
long_running_count += 1
push_metric(cloudwatch, "ActiveConnections", active_connections, "Count", dimensions)
push_metric(cloudwatch, f"LongRunningQueries_gt_{long_running_threshold_sec}s", long_running_count, "Count", dimensions)
# 3. Monitor Replication Lag (Example for replica)
# This query should be run on the replica
try:
cur.execute("""
SELECT
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replication_lag_bytes
FROM
pg_stat_replication
WHERE
application_name = 'your_primary_app_name'; -- Replace with actual application_name
""")
# If no replication is found or this is the primary, this will return empty
replication_data = cur.fetchone()
if replication_data:
lag_bytes = replication_data[0]
push_metric(cloudwatch, "ReplicationLagBytes", lag_bytes, "Bytes", dimensions)
else:
# If this is the primary, or no replication is active, push 0 lag
push_metric(cloudwatch, "ReplicationLagBytes", 0, "Bytes", dimensions)
except psycopg2.ProgrammingError:
# Handle cases where pg_stat_replication might not be available (e.g., on primary)
print("pg_stat_replication not available or no replication found. Pushing 0 lag.")
push_metric(cloudwatch, "ReplicationLagBytes", 0, "Bytes", dimensions)
cur.close()
conn.close()
except psycopg2.OperationalError as e:
print(f"Database connection error: {e}")
# Push a metric indicating connection failure
push_metric(cloudwatch, "DBConnectionErrors", 1, "Count", dimensions)
except Exception as e:
print(f"An unexpected error occurred: {e}")
push_metric(cloudwatch, "ScriptErrors", 1, "Count", dimensions)
if __name__ == "__main__":
# Example of how to run this script periodically
# In a real scenario, use cron or a systemd timer
# For demonstration, run once:
monitor_postgres()
# To run continuously with a delay:
# while True:
# monitor_postgres()
# time.sleep(300) # Run every 5 minutes
This script collects key metrics from `pg_stat_statements` and `pg_stat_activity`, pushes them to CloudWatch under a custom namespace, and includes basic error handling and replication lag monitoring. Ensure the EC2 instance has an IAM role with `cloudwatch:PutMetricData` permissions.
Orchestrating Monitoring with Prometheus and Grafana
For a more unified and powerful monitoring stack, consider deploying Prometheus and Grafana. This is particularly effective for self-hosted PostgreSQL on EC2 and can also scrape metrics from RDS if configured correctly.
Prometheus Exporters
Prometheus uses “exporters” to collect metrics from various sources. For PostgreSQL, the `postgres_exporter` is the de facto standard.
1. Deploying `postgres_exporter`
- On EC2: Run the exporter as a Docker container or a systemd service on your PostgreSQL instances or a dedicated monitoring instance.
- On RDS: This is more complex. You can run the exporter on a separate EC2 instance that has network access to your RDS instance.
The exporter needs database credentials to connect and query PostgreSQL. It’s best to create a dedicated read-only user for monitoring.
# Example: Running postgres_exporter in Docker docker run --name postgres_exporter \ -p 9187:9187 \ -e DATA_SOURCE_NAME="postgresql://monitor_user:your_password@your_rds_endpoint.region.rds.amazonaws.com:5432/postgres?sslmode=require" \ prom/postgres-exporter:latest
Note the `sslmode=require` for RDS connections. You might need to configure your RDS instance’s Security Group to allow inbound traffic from the EC2 instance running the exporter on port 5432.
2. Prometheus Configuration
Configure Prometheus to scrape the `postgres_exporter` targets. Add a job to your `prometheus.yml`:
scrape_configs:
- job_name: 'postgres'
static_configs:
- targets: ['your_ec2_instance_ip:9187', 'another_ec2_instance_ip:9187'] # For self-hosted
# If using RDS and exporter on a separate EC2:
# - targets: ['exporter_ec2_ip:9187']
metrics_path: /metrics
3. Grafana Dashboards
Import pre-built Grafana dashboards for PostgreSQL (e.g., from Grafana.com/dashboards) or create your own. These dashboards will visualize metrics collected by `postgres_exporter` and potentially custom metrics pushed via the Python script.
Key PostgreSQL metrics to visualize in Grafana include:
- Replication Lag
- Connection Counts (Active, Idle)
- Transaction Rate (Commits, Rollbacks)
- Query Performance (Average Latency, Throughput)
- Cache Hit Ratios
- Lock Wait Times
- Disk Usage
Alerting Strategies
A robust alerting strategy is crucial. Combine AWS CloudWatch Alarms with Prometheus Alertmanager.
CloudWatch Alarms: Ideal for infrastructure-level issues (CPU, disk, network, RDS specific alarms) and for services not easily exposed to Prometheus (e.g., the C application’s health endpoint if not using ALB). Use SNS to route notifications to Slack, PagerDuty, or email.
Prometheus Alertmanager: Configure Alertmanager to receive alerts from Prometheus. It handles deduplication, grouping, silencing, and routing alerts to various receivers (Slack, email, OpsGenie, etc.).
Example Prometheus Alert Rule (for `prometheus.yml` or a separate rules file):
groups:
- name: postgresql_alerts
rules:
- alert: PostgreSQLHighReplicationLag
expr: postgres_replication_lag_bytes{job="postgres"} > 10485760 # 10MB lag
for: 5m
labels:
severity: warning
annotations:
summary: "High replication lag detected on {{ $labels.instance }}"
description: "PostgreSQL replication lag on {{ $labels.instance }} is {{ $value }} bytes, exceeding the threshold of 10MB for 5 minutes."
- alert: PostgreSQLTooManyConnections
expr: postgres_connections_active{job="postgres"} / postgres_max_connections{job="postgres"} * 100 > 85
for: 10m
labels:
severity: critical
annotations:
summary: "PostgreSQL connection usage high on {{ $labels.instance }}"
description: "Active PostgreSQL connections on {{ $labels.instance }} are at {{ $value | printf \"%.2f\" }}% of max connections for 10 minutes."
- alert: PostgreSQLQueryTooSlow
# This requires custom metrics or specific pg_stat_statements queries
# Example: Assuming a custom metric for average query time
expr: avg_over_time(postgres_query_mean_exec_time{job="postgres"}[5m]) > 500 # Avg query time > 500ms
for: 5m
labels:
severity: warning
annotations:
summary: "Slow PostgreSQL queries detected on {{ $labels.instance }}"
description: "Average PostgreSQL query execution time on {{ $labels.instance }} has been above 500ms for 5 minutes."
Integrate Alertmanager with your chosen notification channels. For Slack, this typically involves setting up an incoming webhook.
Conclusion
A comprehensive monitoring strategy for C applications and PostgreSQL clusters on AWS involves layering different tools and techniques. For C applications, application-level health checks are paramount. For PostgreSQL, combine AWS RDS metrics with deep dives into `pg_stat_statements` and `pg_stat_activity`, leveraging tools like Prometheus and Grafana for advanced visualization and alerting. Proactive monitoring, coupled with well-defined alerting, is key to maintaining high availability and performance.