Skip to content

Commit 5952858

Browse files
authored
feat: add pgbouncer (#114)
* feat: Add option to set up a pgbouncer server that can manage traffic to the actual database * chore: increase timeout value to 90 minutes for deployment action * deps: update to pyyaml 6.0.2 to fix build on ubuntu-latest
1 parent 9224d0a commit 5952858

File tree

17 files changed

+2267
-345
lines changed

17 files changed

+2267
-345
lines changed

.github/workflows/build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
cache: "npm"
2727

2828
- name: Install Dependencies
29-
run: npm ci
29+
run: npm run install:all
3030

3131
- name: Compile project
3232
run: npm run build

.github/workflows/deploy.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ jobs:
77
build_package_and_deploy:
88
name: Build, package and deploy
99
runs-on: ubuntu-latest
10-
timeout-minutes: 60
10+
timeout-minutes: 90
1111
env:
1212
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION_DEPLOY }}
1313
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEPLOY }}
@@ -21,8 +21,8 @@ jobs:
2121
node-version: 18
2222
cache: "npm"
2323

24-
- name: Install Dependencies
25-
run: npm ci
24+
- name: Install All Dependencies
25+
run: npm run install:all
2626

2727
- name: Compile project
2828
run: npm run build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ __pycache__
1111
tests/*.egg*
1212
tests/*venv*
1313
tests/__pycache__
14+
integration_tests/cdk/cdk.out

integration_tests/cdk/app.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,25 @@ 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

85-
pgstac_db.db.connections.allow_default_port_from_any_ipv4()
86+
assert pgstac_db.security_group
87+
88+
pgstac_db.security_group.add_ingress_rule(
89+
aws_ec2.Peer.any_ipv4(), aws_ec2.Port.tcp(5432)
90+
)
8691

8792
PgStacApiLambda(
8893
self,
8994
"pgstac-api",
95+
db=pgstac_db.connection_target,
96+
db_secret=pgstac_db.pgstac_secret,
9097
api_env={
9198
"NAME": app_config.build_service_name("STAC API"),
9299
"description": f"{app_config.stage} STAC API",
93100
},
94-
db=pgstac_db.db,
95-
db_secret=pgstac_db.pgstac_secret,
96101
)
97102

98103
TitilerPgstacApiLambda(
@@ -102,7 +107,7 @@ def __init__(
102107
"NAME": app_config.build_service_name("titiler pgSTAC API"),
103108
"description": f"{app_config.stage} titiler pgstac API",
104109
},
105-
db=pgstac_db.db,
110+
db=pgstac_db.connection_target,
106111
db_secret=pgstac_db.pgstac_secret,
107112
buckets=[],
108113
lambda_function_options={
@@ -113,7 +118,7 @@ def __init__(
113118
TiPgApiLambda(
114119
self,
115120
"tipg-api",
116-
db=pgstac_db.db,
121+
db=pgstac_db.connection_target,
117122
db_secret=pgstac_db.pgstac_secret,
118123
api_env={
119124
"NAME": app_config.build_service_name("tipg API"),

integration_tests/cdk/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ constructs==10.3.0
33
pydantic==2.0.2
44
pydantic-settings==2.0.1
55
python-dotenv==1.0.0
6-
pyyaml==6.0
6+
pyyaml==6.0.2
77
types-PyYAML==6.0.12.10

lib/database/PgBouncer.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
* PgBouncer will use 10 fewer than this value.
49+
*/
50+
dbMaxConnections: number;
51+
52+
/**
53+
* Whether to deploy in public subnet
54+
* @default false
55+
*/
56+
usePublicSubnet?: boolean;
57+
58+
/**
59+
* Instance type for PgBouncer
60+
* @default t3.micro
61+
*/
62+
instanceType?: ec2.InstanceType;
63+
64+
/**
65+
* PgBouncer configuration options
66+
*/
67+
pgBouncerConfig?: PgBouncerConfigProps;
68+
}
69+
70+
export class PgBouncer extends Construct {
71+
public readonly instance: ec2.Instance;
72+
public readonly pgbouncerSecret: secretsmanager.Secret;
73+
public readonly securityGroup: ec2.SecurityGroup;
74+
75+
// The max_connections parameter in PgBouncer determines the maximum number of
76+
// connections to open on the actual database instance. We want that number to
77+
// be slightly smaller than the actual max_connections value on the RDS instance
78+
// so we perform this calculation.
79+
80+
private getDefaultConfig(
81+
dbMaxConnections: number
82+
): Required<PgBouncerConfigProps> {
83+
// maxDbConnections (and maxUserConnections) are the only settings that need
84+
// to be responsive to the database size/max_connections setting
85+
return {
86+
poolMode: "transaction",
87+
maxClientConn: 1000,
88+
defaultPoolSize: 5,
89+
minPoolSize: 0,
90+
reservePoolSize: 5,
91+
reservePoolTimeout: 5,
92+
maxDbConnections: dbMaxConnections - 10,
93+
maxUserConnections: dbMaxConnections - 10,
94+
};
95+
}
96+
97+
constructor(scope: Construct, id: string, props: PgBouncerProps) {
98+
super(scope, id);
99+
100+
// Set defaults for optional props
101+
const defaultInstanceType = ec2.InstanceType.of(
102+
ec2.InstanceClass.T3,
103+
ec2.InstanceSize.MICRO
104+
);
105+
106+
const instanceType = props.instanceType ?? defaultInstanceType;
107+
const defaultConfig = this.getDefaultConfig(props.dbMaxConnections);
108+
109+
// Merge provided config with defaults
110+
const pgBouncerConfig: Required<PgBouncerConfigProps> = {
111+
...defaultConfig,
112+
...props.pgBouncerConfig,
113+
};
114+
115+
// Create role for PgBouncer instance to enable writing to CloudWatch
116+
const role = new iam.Role(this, "InstanceRole", {
117+
description:
118+
"pgbouncer instance role with Systems Manager + CloudWatch permissions",
119+
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
120+
managedPolicies: [
121+
iam.ManagedPolicy.fromAwsManagedPolicyName(
122+
"AmazonSSMManagedInstanceCore"
123+
),
124+
iam.ManagedPolicy.fromAwsManagedPolicyName(
125+
"CloudWatchAgentServerPolicy"
126+
),
127+
],
128+
});
129+
130+
// Add policy to allow reading RDS credentials from Secrets Manager
131+
role.addToPolicy(
132+
new iam.PolicyStatement({
133+
actions: ["secretsmanager:GetSecretValue"],
134+
resources: [props.database.secret.secretArn],
135+
})
136+
);
137+
138+
// Create a security group and allow connections from the Lambda IP ranges for this region
139+
this.securityGroup = new ec2.SecurityGroup(this, "PgBouncerSecurityGroup", {
140+
vpc: props.vpc,
141+
description: "Security group for PgBouncer instance",
142+
allowAllOutbound: true,
143+
});
144+
145+
// Create PgBouncer instance
146+
this.instance = new ec2.Instance(this, "Instance", {
147+
vpc: props.vpc,
148+
vpcSubnets: {
149+
subnetType: props.usePublicSubnet
150+
? ec2.SubnetType.PUBLIC
151+
: ec2.SubnetType.PRIVATE_WITH_EGRESS,
152+
},
153+
securityGroup: this.securityGroup,
154+
instanceType,
155+
instanceName: props.instanceName,
156+
machineImage: ec2.MachineImage.fromSsmParameter(
157+
"/aws/service/canonical/ubuntu/server/jammy/stable/current/amd64/hvm/ebs-gp2/ami-id",
158+
{ os: ec2.OperatingSystemType.LINUX }
159+
),
160+
role,
161+
blockDevices: [
162+
{
163+
deviceName: "/dev/xvda",
164+
volume: ec2.BlockDeviceVolume.ebs(20, {
165+
volumeType: ec2.EbsDeviceVolumeType.GP3,
166+
encrypted: true,
167+
deleteOnTermination: true,
168+
}),
169+
},
170+
],
171+
userData: this.loadUserDataScript(pgBouncerConfig, props.database),
172+
userDataCausesReplacement: true,
173+
associatePublicIpAddress: props.usePublicSubnet,
174+
});
175+
176+
// Allow PgBouncer to connect to RDS
177+
props.database.connections.allowFrom(
178+
this.instance,
179+
ec2.Port.tcp(5432),
180+
"Allow PgBouncer to connect to RDS"
181+
);
182+
183+
// Create a new secret for pgbouncer connection credentials
184+
this.pgbouncerSecret = new secretsmanager.Secret(this, "PgBouncerSecret", {
185+
description: `Connection information for PgBouncer instance ${props.instanceName}`,
186+
generateSecretString: {
187+
generateStringKey: "dummy",
188+
secretStringTemplate: "{}",
189+
},
190+
});
191+
192+
// Grant the role permission to read the new secret
193+
this.pgbouncerSecret.grantRead(role);
194+
195+
// Update pgbouncerSecret to contain pgstacSecret values but with new value for host
196+
const secretUpdaterFn = new lambda.Function(this, "SecretUpdaterFunction", {
197+
runtime: lambda.Runtime.NODEJS_20_X,
198+
handler: "index.handler",
199+
code: lambda.Code.fromAsset(
200+
path.join(__dirname, "lambda/pgbouncer-secret-updater")
201+
),
202+
environment: {
203+
SOURCE_SECRET_ARN: props.database.secret.secretArn,
204+
TARGET_SECRET_ARN: this.pgbouncerSecret.secretArn,
205+
},
206+
});
207+
208+
props.database.secret.grantRead(secretUpdaterFn);
209+
this.pgbouncerSecret.grantWrite(secretUpdaterFn);
210+
211+
new CustomResource(this, "pgbouncerSecretBootstrapper", {
212+
serviceToken: secretUpdaterFn.functionArn,
213+
properties: {
214+
instanceIp: props.usePublicSubnet
215+
? this.instance.instancePublicIp
216+
: this.instance.instancePrivateIp,
217+
},
218+
});
219+
}
220+
221+
private loadUserDataScript(
222+
pgBouncerConfig: Required<NonNullable<PgBouncerProps["pgBouncerConfig"]>>,
223+
database: { secret: secretsmanager.ISecret }
224+
): ec2.UserData {
225+
const userDataScript = ec2.UserData.forLinux();
226+
227+
// Set environment variables with configuration parameters
228+
userDataScript.addCommands(
229+
'export SECRET_ARN="' + database.secret.secretArn + '"',
230+
'export REGION="' + Stack.of(this).region + '"',
231+
'export POOL_MODE="' + pgBouncerConfig.poolMode + '"',
232+
'export MAX_CLIENT_CONN="' + pgBouncerConfig.maxClientConn + '"',
233+
'export DEFAULT_POOL_SIZE="' + pgBouncerConfig.defaultPoolSize + '"',
234+
'export MIN_POOL_SIZE="' + pgBouncerConfig.minPoolSize + '"',
235+
'export RESERVE_POOL_SIZE="' + pgBouncerConfig.reservePoolSize + '"',
236+
'export RESERVE_POOL_TIMEOUT="' +
237+
pgBouncerConfig.reservePoolTimeout +
238+
'"',
239+
'export MAX_DB_CONNECTIONS="' + pgBouncerConfig.maxDbConnections + '"',
240+
'export MAX_USER_CONNECTIONS="' + pgBouncerConfig.maxUserConnections + '"'
241+
);
242+
243+
// Load the startup script
244+
const scriptPath = path.join(__dirname, "./pgbouncer-setup.sh");
245+
let script = fs.readFileSync(scriptPath, "utf8");
246+
247+
userDataScript.addCommands(script);
248+
249+
return userDataScript;
250+
}
251+
}

0 commit comments

Comments
 (0)