Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
054a4e5
add ec2 instance with pgbouncer
hrodmn Dec 5, 2024
6d561ce
switch to a ubuntu image
hrodmn Dec 5, 2024
108e6f6
try new approach for startup script
hrodmn Dec 5, 2024
7282e61
do not write separate script, just execute it
hrodmn Dec 5, 2024
a6d4293
fix pgbouncer.ini
hrodmn Dec 5, 2024
1cb1e67
fix permissions
hrodmn Dec 5, 2024
a7def34
set up cloudwatch and healthcheck for pgbouncer
hrodmn Dec 6, 2024
7f2f4f6
switch to session pooling mode
hrodmn Dec 6, 2024
b2a5919
use hash to force instance replacement
hrodmn Dec 6, 2024
2f1dff3
run init script on each boot
hrodmn Dec 6, 2024
84e4c76
switch it back to transaction mode
hrodmn Dec 6, 2024
8017a38
move startup script to external file
hrodmn Dec 9, 2024
07e655c
upgrade to t3.micro
hrodmn Dec 9, 2024
d2989f0
don't use file descriptor
hrodmn Dec 9, 2024
d4709a0
create log file
hrodmn Dec 9, 2024
590f167
fine tune pgbouncer settings
hrodmn Dec 9, 2024
7b8cbff
wait for dpkg lock
hrodmn Dec 10, 2024
08273d9
make wget quieter
hrodmn Dec 10, 2024
b99dccb
simplify pgbouncer ingress rules
hrodmn Dec 10, 2024
74d14ae
Upgrade to eoapi-cdk==7.3.0
hrodmn Dec 10, 2024
5d21572
try granting permissions instead of an explicit security group
hrodmn Dec 10, 2024
893ff50
Set pgbouncer maxConnections dynamically based on the RDS instance size
hrodmn Dec 10, 2024
10b0c63
fix pgbouncer check, clean up setup script
hrodmn Dec 11, 2024
d95fe7c
re-instate cloudwatch write permissions
hrodmn Dec 11, 2024
e0d1dd2
make dbInstanceType a config parameter
hrodmn Dec 11, 2024
5485819
fix up default pgBouncerConfig
hrodmn Dec 11, 2024
7a3aff0
add DB_INSTANCE_TYPE to env in deploy.yml
hrodmn Dec 11, 2024
e874466
make sure /var/run/pgbouncer directory is created on each boot
hrodmn Dec 11, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
JWKS_URL: ${{ steps.import-stacks-vars-to-output.outputs.JWKS_URL }}
DATA_ACCESS_ROLE_ARN: ${{ steps.import-stacks-vars-to-output.outputs.DATA_ACCESS_ROLE_ARN }}
DB_ALLOCATED_STORAGE: ${{ vars.DB_ALLOCATED_STORAGE }}
DB_INSTANCE_TYPE: ${{ vars.DB_INSTANCE_TYPE }}
GIT_REPOSITORY: ${{ github.repository}}
COMMIT_SHA: ${{ github.sha }}
AUTHOR: ${{ github.actor }}
Expand Down
227 changes: 227 additions & 0 deletions cdk/PgBouncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import {
aws_ec2 as ec2,
aws_iam as iam,
aws_secretsmanager as secretsmanager,
Stack,
} from "aws-cdk-lib";
import { Construct } from "constructs";

import * as fs from "fs";
import * as path from "path";

// used to populate pgbouncer config:
// see https://www.pgbouncer.org/config.html for details
export interface PgBouncerConfigProps {
poolMode?: "transaction" | "session" | "statement";
maxClientConn?: number;
defaultPoolSize?: number;
minPoolSize?: number;
reservePoolSize?: number;
reservePoolTimeout?: number;
maxDbConnections?: number;
maxUserConnections?: number;
}

export interface PgBouncerProps {
/**
* Name for the pgbouncer instance
*/
instanceName: string;

/**
* VPC to deploy PgBouncer into
*/
vpc: ec2.IVpc;

/**
* The RDS instance to connect to
*/
database: {
instanceType: ec2.InstanceType;
connections: ec2.Connections;
secret: secretsmanager.ISecret;
};

/**
* Whether to deploy in public subnet
* @default false
*/
usePublicSubnet?: boolean;

/**
* Instance type for PgBouncer
* @default t3.micro
*/
instanceType?: ec2.InstanceType;

/**
* PgBouncer configuration options
*/
pgBouncerConfig?: PgBouncerConfigProps;
}

export class PgBouncer extends Construct {
public readonly instance: ec2.Instance;
public readonly endpoint: string;

// The max_connections parameter in PgBouncer determines the maximum number of
// connections to open on the actual database instance. We want that number to
// be slightly smaller than the actual max_connections value on the RDS instance
// so we perform this calculation.

// TODO: move this to eoapi-cdk where we already have a complete map of instance
// type and memory
private readonly instanceMemoryMapMb: Record<string, number> = {
"t3.micro": 1024,
"t3.small": 2048,
"t3.medium": 4096,
};

private calculateMaxConnections(dbInstanceType: ec2.InstanceType): number {
const memoryMb = this.instanceMemoryMapMb[dbInstanceType.toString()];
if (!memoryMb) {
throw new Error(
`Unsupported instance type: ${dbInstanceType.toString()}`,
);
}

// RDS calculates the available memory as the total instance memory minus some
// constant for OS overhead
const memoryInBytes = (memoryMb - 300) * 1024 ** 2;

// The default max_connections setting follows this formula:
return Math.min(Math.round(memoryInBytes / 9531392), 5000);
}

private getDefaultConfig(
dbInstanceType: ec2.InstanceType,
): Required<PgBouncerConfigProps> {
// calculate approximate max_connections setting for this RDS instance type
const maxConnections = this.calculateMaxConnections(dbInstanceType);

// maxDbConnections (and maxUserConnections) are the only settings that need
// to be responsive to the database size/max_connections setting
return {
poolMode: "transaction",
maxClientConn: 1000,
defaultPoolSize: 5,
minPoolSize: 0,
reservePoolSize: 5,
reservePoolTimeout: 5,
maxDbConnections: maxConnections - 10,
maxUserConnections: maxConnections - 10,
};
}

constructor(scope: Construct, id: string, props: PgBouncerProps) {
super(scope, id);

// Set defaults for optional props
const defaultInstanceType = ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO,
);

const instanceType = props.instanceType ?? defaultInstanceType;
const defaultConfig = this.getDefaultConfig(props.database.instanceType);

// Merge provided config with defaults
const pgBouncerConfig: Required<PgBouncerConfigProps> = {
...defaultConfig,
...props.pgBouncerConfig,
};

// Create role for PgBouncer instance to enable writing to CloudWatch
const role = new iam.Role(this, "InstanceRole", {
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"AmazonSSMManagedInstanceCore",
),
iam.ManagedPolicy.fromAwsManagedPolicyName(
"CloudWatchAgentServerPolicy",
),
],
});

// Add policy to allow reading RDS credentials from Secrets Manager
role.addToPolicy(
new iam.PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: [props.database.secret.secretArn],
}),
);

// Create PgBouncer instance
this.instance = new ec2.Instance(this, "Instance", {
vpc: props.vpc,
vpcSubnets: {
subnetType: props.usePublicSubnet
? ec2.SubnetType.PUBLIC
: ec2.SubnetType.PRIVATE_WITH_EGRESS,
},
instanceType,
instanceName: props.instanceName,
machineImage: ec2.MachineImage.fromSsmParameter(
"/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id",
{ os: ec2.OperatingSystemType.LINUX },
),
role,
blockDevices: [
{
deviceName: "/dev/xvda",
volume: ec2.BlockDeviceVolume.ebs(20, {
volumeType: ec2.EbsDeviceVolumeType.GP3,
encrypted: true,
deleteOnTermination: true,
}),
},
],
userData: this.loadUserDataScript(pgBouncerConfig, props.database),
userDataCausesReplacement: true,
});

// Allow PgBouncer to connect to RDS
props.database.connections.allowFrom(
this.instance,
ec2.Port.tcp(5432),
"Allow PgBouncer to connect to RDS",
);

// Set the endpoint
this.endpoint = this.instance.instancePrivateIp;
}

private loadUserDataScript(
pgBouncerConfig: Required<NonNullable<PgBouncerProps["pgBouncerConfig"]>>,
database: { secret: secretsmanager.ISecret },
): ec2.UserData {
const userDataScript = ec2.UserData.forLinux();

// Set environment variables with configuration parameters
userDataScript.addCommands(
'export SECRET_ARN="' + database.secret.secretArn + '"',
'export REGION="' + Stack.of(this).region + '"',
'export POOL_MODE="' + pgBouncerConfig.poolMode + '"',
'export MAX_CLIENT_CONN="' + pgBouncerConfig.maxClientConn + '"',
'export DEFAULT_POOL_SIZE="' + pgBouncerConfig.defaultPoolSize + '"',
'export MIN_POOL_SIZE="' + pgBouncerConfig.minPoolSize + '"',
'export RESERVE_POOL_SIZE="' + pgBouncerConfig.reservePoolSize + '"',
'export RESERVE_POOL_TIMEOUT="' +
pgBouncerConfig.reservePoolTimeout +
'"',
'export MAX_DB_CONNECTIONS="' + pgBouncerConfig.maxDbConnections + '"',
'export MAX_USER_CONNECTIONS="' +
pgBouncerConfig.maxUserConnections +
'"',
);

// Load the startup script
const scriptPath = path.join(__dirname, "./scripts/pgbouncer-setup.sh");
let script = fs.readFileSync(scriptPath, "utf8");

userDataScript.addCommands(script);

return userDataScript;
}
}
Loading