Skip to content

Commit b8c1b60

Browse files
committed
feat: add pgbouncer
Add option to set up a pgbouncer server that can manage traffic to the actual database
1 parent 9224d0a commit b8c1b60

File tree

4 files changed

+586
-12
lines changed

4 files changed

+586
-12
lines changed

integration_tests/cdk/app.py

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(
7979
),
8080
allocated_storage=app_config.db_allocated_storage,
8181
instance_type=aws_ec2.InstanceType(app_config.db_instance_type),
82+
add_pgbouncer=True,
8283
removal_policy=RemovalPolicy.DESTROY,
8384
)
8485

lib/database/PgBouncer.ts

+270
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import {
2+
aws_ec2 as ec2,
3+
aws_iam as iam,
4+
aws_lambda as lambda,
5+
aws_secretsmanager as secretsmanager,
6+
CustomResource,
7+
Stack,
8+
} from "aws-cdk-lib";
9+
import { Construct } from "constructs";
10+
11+
import * as fs from "fs";
12+
import * as path from "path";
13+
14+
// used to populate pgbouncer config:
15+
// see https://www.pgbouncer.org/config.html for details
16+
export interface PgBouncerConfigProps {
17+
poolMode?: "transaction" | "session" | "statement";
18+
maxClientConn?: number;
19+
defaultPoolSize?: number;
20+
minPoolSize?: number;
21+
reservePoolSize?: number;
22+
reservePoolTimeout?: number;
23+
maxDbConnections?: number;
24+
maxUserConnections?: number;
25+
}
26+
27+
export interface PgBouncerProps {
28+
/**
29+
* Name for the pgbouncer instance
30+
*/
31+
instanceName: string;
32+
33+
/**
34+
* VPC to deploy PgBouncer into
35+
*/
36+
vpc: ec2.IVpc;
37+
38+
/**
39+
* The RDS instance to connect to
40+
*/
41+
database: {
42+
connections: ec2.Connections;
43+
secret: secretsmanager.ISecret;
44+
};
45+
46+
/**
47+
* Maximum connections setting for the database
48+
*/
49+
dbMaxConnections: number;
50+
51+
/**
52+
* Whether to deploy in public subnet
53+
* @default false
54+
*/
55+
usePublicSubnet?: boolean;
56+
57+
/**
58+
* Instance type for PgBouncer
59+
* @default t3.micro
60+
*/
61+
instanceType?: ec2.InstanceType;
62+
63+
/**
64+
* PgBouncer configuration options
65+
*/
66+
pgBouncerConfig?: PgBouncerConfigProps;
67+
}
68+
69+
export class PgBouncer extends Construct {
70+
public readonly instance: ec2.Instance;
71+
public readonly pgbouncerSecret: secretsmanager.Secret;
72+
73+
// The max_connections parameter in PgBouncer determines the maximum number of
74+
// connections to open on the actual database instance. We want that number to
75+
// be slightly smaller than the actual max_connections value on the RDS instance
76+
// so we perform this calculation.
77+
78+
private getDefaultConfig(
79+
dbMaxConnections: number
80+
): Required<PgBouncerConfigProps> {
81+
// maxDbConnections (and maxUserConnections) are the only settings that need
82+
// to be responsive to the database size/max_connections setting
83+
return {
84+
poolMode: "transaction",
85+
maxClientConn: 1000,
86+
defaultPoolSize: 5,
87+
minPoolSize: 0,
88+
reservePoolSize: 5,
89+
reservePoolTimeout: 5,
90+
maxDbConnections: dbMaxConnections - 10,
91+
maxUserConnections: dbMaxConnections - 10,
92+
};
93+
}
94+
95+
constructor(scope: Construct, id: string, props: PgBouncerProps) {
96+
super(scope, id);
97+
98+
// Set defaults for optional props
99+
const defaultInstanceType = ec2.InstanceType.of(
100+
ec2.InstanceClass.T3,
101+
ec2.InstanceSize.MICRO
102+
);
103+
104+
const instanceType = props.instanceType ?? defaultInstanceType;
105+
const defaultConfig = this.getDefaultConfig(props.dbMaxConnections);
106+
107+
// Merge provided config with defaults
108+
const pgBouncerConfig: Required<PgBouncerConfigProps> = {
109+
...defaultConfig,
110+
...props.pgBouncerConfig,
111+
};
112+
113+
// Create role for PgBouncer instance to enable writing to CloudWatch
114+
const role = new iam.Role(this, "InstanceRole", {
115+
description:
116+
"pgbouncer instance role with Systems Manager + CloudWatch permissions",
117+
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
118+
managedPolicies: [
119+
iam.ManagedPolicy.fromAwsManagedPolicyName(
120+
"AmazonSSMManagedInstanceCore"
121+
),
122+
iam.ManagedPolicy.fromAwsManagedPolicyName(
123+
"CloudWatchAgentServerPolicy"
124+
),
125+
],
126+
});
127+
128+
// Add policy to allow reading RDS credentials from Secrets Manager
129+
role.addToPolicy(
130+
new iam.PolicyStatement({
131+
actions: ["secretsmanager:GetSecretValue"],
132+
resources: [props.database.secret.secretArn],
133+
})
134+
);
135+
136+
// Create PgBouncer instance
137+
this.instance = new ec2.Instance(this, "Instance", {
138+
vpc: props.vpc,
139+
vpcSubnets: {
140+
subnetType: props.usePublicSubnet
141+
? ec2.SubnetType.PUBLIC
142+
: ec2.SubnetType.PRIVATE_WITH_EGRESS,
143+
},
144+
instanceType,
145+
instanceName: props.instanceName,
146+
machineImage: ec2.MachineImage.fromSsmParameter(
147+
"/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id",
148+
{ os: ec2.OperatingSystemType.LINUX }
149+
),
150+
role,
151+
blockDevices: [
152+
{
153+
deviceName: "/dev/xvda",
154+
volume: ec2.BlockDeviceVolume.ebs(20, {
155+
volumeType: ec2.EbsDeviceVolumeType.GP3,
156+
encrypted: true,
157+
deleteOnTermination: true,
158+
}),
159+
},
160+
],
161+
userData: this.loadUserDataScript(pgBouncerConfig, props.database),
162+
userDataCausesReplacement: true,
163+
});
164+
165+
// Allow PgBouncer to connect to RDS
166+
props.database.connections.allowFrom(
167+
this.instance,
168+
ec2.Port.tcp(5432),
169+
"Allow PgBouncer to connect to RDS"
170+
);
171+
172+
// Create a new secret for pgbouncer connection credentials
173+
this.pgbouncerSecret = new secretsmanager.Secret(this, "PgBouncerSecret", {
174+
description: `Connection information for PgBouncer instance ${props.instanceName}`,
175+
generateSecretString: {
176+
generateStringKey: "dummy",
177+
secretStringTemplate: "{}",
178+
},
179+
});
180+
181+
// Grant the role permission to read the new secret
182+
this.pgbouncerSecret.grantRead(role);
183+
184+
// Update pgbouncerSecret to contain pgstacSecret values but with new value for host
185+
const secretUpdaterFn = new lambda.Function(this, "SecretUpdaterFunction", {
186+
runtime: lambda.Runtime.NODEJS_20_X,
187+
handler: "index.handler",
188+
code: lambda.Code.fromInline(`
189+
const AWS = require('aws-sdk');
190+
const sm = new AWS.SecretsManager();
191+
192+
exports.handler = async (event) => {
193+
console.log('Event:', JSON.stringify(event, null, 2));
194+
195+
try {
196+
const instanceIp = event.ResourceProperties.instanceIp;
197+
198+
// Get the original secret value
199+
const originalSecret = await sm.getSecretValue({
200+
SecretId: '${props.database.secret.secretArn}'
201+
}).promise();
202+
203+
// Parse the secret string
204+
const secretData = JSON.parse(originalSecret.SecretString);
205+
206+
// Update the host value with the PgBouncer instance IP
207+
secretData.host = instanceIp;
208+
209+
// Put the modified secret value
210+
await sm.putSecretValue({
211+
SecretId: '${this.pgbouncerSecret.secretArn}',
212+
SecretString: JSON.stringify(secretData)
213+
}).promise();
214+
215+
return {
216+
PhysicalResourceId: '${this.pgbouncerSecret.secretArn}',
217+
Data: {
218+
SecretArn: '${this.pgbouncerSecret.secretArn}'
219+
}
220+
};
221+
} catch (error) {
222+
console.error('Error:', error);
223+
throw error;
224+
}
225+
};
226+
`),
227+
});
228+
229+
props.database.secret.grantRead(secretUpdaterFn);
230+
this.pgbouncerSecret.grantWrite(secretUpdaterFn);
231+
232+
new CustomResource(this, "pgbouncerSecretBootstrapper", {
233+
serviceToken: secretUpdaterFn.functionArn,
234+
properties: {
235+
instanceIp: this.instance.instancePrivateIp,
236+
},
237+
});
238+
}
239+
240+
private loadUserDataScript(
241+
pgBouncerConfig: Required<NonNullable<PgBouncerProps["pgBouncerConfig"]>>,
242+
database: { secret: secretsmanager.ISecret }
243+
): ec2.UserData {
244+
const userDataScript = ec2.UserData.forLinux();
245+
246+
// Set environment variables with configuration parameters
247+
userDataScript.addCommands(
248+
'export SECRET_ARN="' + database.secret.secretArn + '"',
249+
'export REGION="' + Stack.of(this).region + '"',
250+
'export POOL_MODE="' + pgBouncerConfig.poolMode + '"',
251+
'export MAX_CLIENT_CONN="' + pgBouncerConfig.maxClientConn + '"',
252+
'export DEFAULT_POOL_SIZE="' + pgBouncerConfig.defaultPoolSize + '"',
253+
'export MIN_POOL_SIZE="' + pgBouncerConfig.minPoolSize + '"',
254+
'export RESERVE_POOL_SIZE="' + pgBouncerConfig.reservePoolSize + '"',
255+
'export RESERVE_POOL_TIMEOUT="' +
256+
pgBouncerConfig.reservePoolTimeout +
257+
'"',
258+
'export MAX_DB_CONNECTIONS="' + pgBouncerConfig.maxDbConnections + '"',
259+
'export MAX_USER_CONNECTIONS="' + pgBouncerConfig.maxUserConnections + '"'
260+
);
261+
262+
// Load the startup script
263+
const scriptPath = path.join(__dirname, "./pgbouncer-setup.sh");
264+
let script = fs.readFileSync(scriptPath, "utf8");
265+
266+
userDataScript.addCommands(script);
267+
268+
return userDataScript;
269+
}
270+
}

0 commit comments

Comments
 (0)