Disaster Recovery 101: Architecting Auto-Failovers for PostgreSQL and Ruby Deployments on Google Cloud
Leveraging Google Cloud’s Managed PostgreSQL for Resilient Deployments
For mission-critical applications, a robust disaster recovery strategy is paramount. When architecting for high availability and automated failover, leveraging managed database services significantly reduces operational overhead and complexity. Google Cloud’s Cloud SQL for PostgreSQL offers a compelling solution, providing built-in high availability (HA) configurations and automated failover capabilities that are essential for our Ruby on Rails deployments.
A typical Cloud SQL HA configuration involves a primary instance and a standby instance in a different zone within the same region. Cloud SQL automatically manages replication between these instances. In the event of a primary instance failure (e.g., due to zone outage, hardware failure, or maintenance), Cloud SQL automatically promotes the standby instance to become the new primary. This process is transparent to applications, provided they are configured to connect to the instance using its IP address or a stable DNS name that can be updated post-failover.
Configuring Cloud SQL for PostgreSQL High Availability
Enabling HA for Cloud SQL for PostgreSQL is straightforward via the Google Cloud Console or the `gcloud` command-line tool. For a production environment, it’s crucial to select the appropriate machine type and storage for both the primary and standby instances to ensure performance parity and sufficient capacity.
Using `gcloud`:
gcloud sql instances patch my-postgres-instance \ --availability-type=REGIONAL \ --region=us-central1 \ --backup-start-time=03:00 \ --project=your-gcp-project-id
This command configures an existing instance named `my-postgres-instance` for regional availability, meaning it will have a standby instance in a different zone within `us-central1`. The `–backup-start-time` is also specified, which is a prerequisite for enabling HA. Ensure your instance is already configured with automated backups.
Automating Application Failover with Ruby on Rails
While Cloud SQL handles the database failover, our application needs to adapt to the new primary instance’s IP address. The most common scenario is that the IP address of the Cloud SQL instance remains the same after a failover. However, if a new instance is provisioned or if there are specific network configurations that lead to an IP change, the application’s database connection string must be updated. A robust strategy involves using a stable endpoint (like a DNS name) and automating its update.
For Ruby on Rails applications, the database configuration is typically managed in `config/database.yml`. When using Cloud SQL, it’s best practice to use environment variables to define connection parameters.
default: &default adapter: postgresql encoding: unicode pool: 5 host: <%= ENV['DB_HOST'] %> port: <%= ENV['DB_PORT'] %> username: <%= ENV['DB_USER'] %> password: <%= ENV['DB_PASSWORD'] %> database: <%= ENV['DB_NAME'] %> development: <<: *default database: myapp_development production: <<: *default host: <%= ENV['DB_HOST'] %> username: <%= ENV['DB_PROD_USER'] %> password: <%= ENV['DB_PROD_PASSWORD'] %> database: <%= ENV['DB_PROD_NAME'] %>
In a typical Cloud SQL setup, `DB_HOST` would be the IP address of the Cloud SQL instance. Cloud SQL’s HA feature ensures this IP address remains constant even after a failover. If, for some reason, the IP address were to change (e.g., migrating to a new instance or a complex network setup), we would need an automated mechanism to update the `DB_HOST` environment variable across our application instances.
Implementing a DNS-Based Failover Mechanism
A more resilient approach, especially if IP address changes are a concern or if you need more control over the failover process, is to use a DNS record that points to the Cloud SQL instance’s IP address. This DNS record can then be updated programmatically when a failover is detected.
We can leverage Google Cloud DNS and a small, resilient service (e.g., a Cloud Function or a dedicated VM) to monitor the health of the primary database and update the DNS record upon failure.
Monitoring Database Health
A simple health check can involve attempting to establish a connection to the database and execute a trivial query, such as `SELECT 1;`. This check should be performed periodically.
import os
import psycopg2
import google.cloud.dns
# Database connection details (from environment variables)
DB_HOST = os.environ.get("DB_HOST")
DB_PORT = os.environ.get("DB_PORT", 5432)
DB_USER = os.environ.get("DB_USER")
DB_PASSWORD = os.environ.get("DB_PASSWORD")
DB_NAME = os.environ.get("DB_NAME")
# Google Cloud DNS details
PROJECT_ID = os.environ.get("GCP_PROJECT_ID")
ZONE_NAME = "your-dns-zone-name" # e.g., "my-app-zone"
RECORD_NAME = "db.yourdomain.com." # The DNS record for your database
def check_db_health():
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
dbname=DB_NAME
)
cur = conn.cursor()
cur.execute("SELECT 1;")
result = cur.fetchone()
cur.close()
conn.close()
return result == (1,)
except Exception as e:
print(f"Database health check failed: {e}")
return False
def get_current_db_ip():
try:
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
dbname=DB_NAME
)
# This is a bit of a hack; in a real scenario, you'd get the IP from Cloud SQL API
# or rely on the fact that the DNS record should point to the correct IP.
# For simplicity, we'll assume the current DB_HOST is the IP we need to resolve.
# A better approach would be to query Cloud SQL API for the primary instance IP.
return DB_HOST
except Exception:
return None
def update_dns_record(new_ip):
client = google.cloud.dns.Client(project=PROJECT_ID)
zone = client.zone(ZONE_NAME)
# Fetch the current record set
try:
record_set = zone.list_resource_record_sets().next() # This is simplified, needs proper filtering
# In a real scenario, you'd iterate and find the specific record_set by name and type
# For example:
# for rs in zone.list_resource_record_sets():
# if rs.name == RECORD_NAME and rs.record_type == 'A':
# record_set = rs
# break
# else:
# raise ValueError("Record set not found")
# For this example, let's assume we found the record_set
# A more robust solution would involve fetching the specific record set
# and ensuring it's an 'A' record.
# Placeholder for actual record set retrieval
print(f"Attempting to update DNS record {RECORD_NAME} to {new_ip}")
# Create a new record set with the updated IP
new_record_set = zone.resource_record_set(
new_ip,
'A',
ttl=300 # Short TTL for faster propagation
)
# Create a change object to update the record
changes = zone.changes()
# If the record exists, delete the old one and add the new one
# This requires knowing the old record's data, which is complex.
# A simpler approach for this example is to assume we are creating/replacing.
# In production, you'd fetch the existing record and use changes.delete() and changes.add()
# or changes.add(new_record_set) if it's a new record.
# For demonstration, let's simulate the update.
# In a real implementation, you'd use:
# changes.add(new_record_set)
# changes.create()
print("DNS record update simulated. In production, use Google Cloud DNS API to update.")
return True
except Exception as e:
print(f"Failed to update DNS record: {e}")
return False
if __name__ == "__main__":
if not check_db_health():
print("Database is unhealthy. Initiating failover procedures.")
# In a real failover, you'd first identify the new primary IP from Cloud SQL API
# For this example, we'll assume a hypothetical new IP or rely on DNS update.
# A robust solution would query Cloud SQL API to get the IP of the promoted instance.
# For now, we'll simulate updating DNS to a new IP.
# Let's assume the new IP is obtained from a reliable source.
# For demonstration, we'll just use a placeholder.
hypothetical_new_ip = "10.0.0.5" # Replace with actual new IP from Cloud SQL API
if update_dns_record(hypothetical_new_ip):
print(f"DNS record {RECORD_NAME} updated to point to the new primary database.")
print("Application instances should pick up the change shortly due to low TTL.")
else:
print("Failed to update DNS record. Manual intervention may be required.")
else:
print("Database is healthy.")
This Python script, when run on a Google Cloud Compute Engine instance or as a Cloud Function, can monitor the database. If `check_db_health()` returns `False`, it signifies a potential failure. The `update_dns_record` function would then use the Google Cloud DNS API to update the A record for `db.yourdomain.com` to point to the IP address of the newly promoted primary database instance. It’s crucial to set a low TTL (Time To Live) on this DNS record (e.g., 60-300 seconds) to ensure that application instances quickly resolve the new IP address.
Integrating with Google Cloud Operations Suite
To make this process truly automated and observable, integrate with Google Cloud Operations Suite (formerly Stackdriver). Configure Cloud SQL to send logs and metrics to Cloud Logging and Cloud Monitoring. Set up alerting policies in Cloud Monitoring that trigger when database health checks fail or when Cloud SQL reports an HA failover event.
These alerts can then trigger the execution of the DNS update script. For instance, an alert could trigger a Pub/Sub message, which a Cloud Function subscribed to that topic can process to perform the DNS update.
Example Alerting Configuration (Conceptual)
In Cloud Monitoring, you would create a metric-based alert. A suitable metric might be `cloudsql.googleapis.com/database/cpu/utilization` or a custom health check metric if you implement one. More directly, you could monitor Cloud SQL’s HA status.
# Example Cloud Monitoring Alert Policy (Conceptual JSON)
{
"displayName": "Cloud SQL PostgreSQL HA Failover Alert",
"combiner": "OR",
"conditions": [
{
"displayName": "Primary Instance Unreachable",
"conditionThreshold": {
"filter": "metric.type=\"cloudsql.googleapis.com/database/instance/status\" AND metric.labels.instance_id=\"my-postgres-instance\" AND metric.labels.status=\"UNREACHABLE\"",
"duration": "60s",
"comparison": "COMPARISON_GT",
"thresholdValue": 0
}
},
{
"displayName": "Standby Instance Promoted",
"conditionThreshold": {
"filter": "metric.type=\"cloudsql.googleapis.com/database/instance/ha_status\" AND metric.labels.instance_id=\"my-postgres-instance\" AND metric.labels.ha_status=\"PRIMARY\"",
"duration": "60s",
"comparison": "COMPARISON_GT",
"thresholdValue": 0
}
}
],
"notificationChannels": [
"projects/your-gcp-project-id/notificationChannels/your-pubsub-channel-id"
]
}
The Pub/Sub topic associated with `your-pubsub-channel-id` would then be configured to trigger the Python script (deployed as a Cloud Function or a service on GKE/Compute Engine) that performs the DNS update.
Application-Level Considerations
Even with automated database failover, application resilience is key. Ensure your Ruby on Rails application is designed to handle transient connection errors gracefully. Implement retry mechanisms with exponential backoff for database connections and queries. This is especially important during the brief period when the database is unavailable or the DNS record is propagating.
Libraries like `retries` in Ruby can be invaluable:
require 'retries'
# Example of retrying a database connection
db_connection = nil
retry_strategy = {
max_tries: 5,
base_sleep_seconds: 2,
max_sleep_seconds: 30,
interval: lambda { |tries, max_tries, max_sleep| [max_sleep, base_sleep_seconds * (2**(tries - 1))].min }
}
begin
db_connection = with_retries(retry_strategy) do |attempt|
puts "Attempting to connect to database (attempt #{attempt})..."
# Replace with your actual database connection logic
connect_to_database!
end
puts "Successfully connected to database."
rescue => e
puts "Failed to connect to database after multiple retries: #{e.message}"
# Handle critical failure, e.g., alert, graceful shutdown
end
# Example of retrying a query
if db_connection
begin
query_result = with_retries(retry_strategy) do |attempt|
puts "Executing query (attempt #{attempt})..."
db_connection.execute("SELECT * FROM users LIMIT 1;")
end
puts "Query executed successfully."
rescue => e
puts "Failed to execute query after multiple retries: #{e.message}"
end
end
Furthermore, consider implementing connection pooling at the application level. Libraries like `PgBouncer` (though external to Rails) or built-in pooling mechanisms can help manage connections efficiently and reduce the overhead of establishing new connections during recovery.
Conclusion: A Multi-Layered Approach
Architecting for automated failover for PostgreSQL and Ruby deployments on Google Cloud requires a multi-layered approach. Cloud SQL for PostgreSQL provides the foundational HA capabilities. Augmenting this with a DNS-based failover mechanism, managed by Google Cloud DNS and automated via Cloud Monitoring alerts and Cloud Functions, ensures resilience against IP address changes or more complex failover scenarios. Finally, robust application-level retry logic and graceful error handling in your Ruby on Rails application are critical to navigating the brief transition periods during a failover event. This comprehensive strategy minimizes downtime and ensures business continuity.