Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Infra cost reduction (Move from ECS to EC2) #387

Merged
merged 7 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions infrastructure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,23 @@ The CLI can be downloaded at https://aws.amazon.com/cli/
Specifically for the ECS Exec command, the session manager is required: https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html

To authenticate with AWS, the AWS CLI can use named profiles (as used in this README): https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

# EC2 change

## Updating the certificate

To renew the certificate, the same command needs to be run:
```bash
sudo su
letsencrypt certonly --standalone -d api.littil.org -m <email> --agree-tos --no-eff-email
```

To use port 80 for verification, stop the nginx webserver (way faster than shutting down and starting the back-end itself) for a moment:
```bash
systemctl stop nginx
```

Restart again using:
```bash
systemctl start nginx
```
15 changes: 15 additions & 0 deletions infrastructure/bin/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { App, Fn, StackProps } from 'aws-cdk-lib';
import 'source-map-support/register';
import { ApiEc2Stack, ApiEc2StackProps } from '../lib/api-ec2-stack';
import { ApiElasticIpStack } from '../lib/api-elastic-ip-stack';
import { ApiStack, ApiStackProps } from '../lib/api-stack';
import { CertificateStack, CertificateStackProps } from '../lib/certificate-stack';
import { DatabaseStack, DatabaseStackProps } from '../lib/database-stack';
Expand Down Expand Up @@ -107,12 +108,26 @@ if (!littilEnvironment) {
};
new DatabaseStack(app, 'ApiDatabaseStack', databaseStackProps);

/* Separate stack for an Elastic IP so that the Elastic IP stack can be created first, then DNS updated, then the
EC2 stack can be created. If the EC2 stack is created immediately, certificate generation will fail. It must then
be retried manually */
const elasticIpStack = new ApiElasticIpStack(app, 'ApiElasticIpStack', {
env,
});

const apiEc2StackProps: ApiEc2StackProps = {
littil: littilEnvironmentSettings,
apiVpc: vpcStack.vpc,
env,
ecrRepository: {
awsAccount: sharedAccountId,
name: ecrApiRepositoryName,
},
elasticIp: elasticIpStack.elasticIp,
database: {
host: Fn.importValue(crossStackReferenceExportNames.databaseHost),
port: Fn.importValue(crossStackReferenceExportNames.databasePort),
name: Fn.importValue(crossStackReferenceExportNames.databaseName),
securityGroup: {
id: Fn.importValue(crossStackReferenceExportNames.databaseSecurityGroup),
},
Expand Down
146 changes: 98 additions & 48 deletions infrastructure/lib/api-ec2-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import {
AmazonLinuxCpuType,
AmazonLinuxGeneration,
AmazonLinuxImage,
CfnEIP, CfnEIPAssociation, CfnKeyPair,
CfnEIP,
CfnEIPAssociation,
Instance,
InstanceClass,
InstanceSize,
InstanceType, Peer, Port,
InstanceType,
KeyPair,
Peer,
Port,
SecurityGroup,
SubnetType, UserData,
SubnetType,
UserData,
Vpc
} from 'aws-cdk-lib/aws-ec2';
import { CfnAccessKey, Effect, Policy, PolicyStatement, Role, User } from 'aws-cdk-lib/aws-iam';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { LittilEnvironmentSettings } from './littil-environment-settings';
import { LoggingStack } from './logging-stack';
Expand All @@ -24,8 +28,15 @@ export interface ApiEc2StackProps extends StackProps {
littil: LittilEnvironmentSettings;

apiVpc: Vpc;
elasticIp: CfnEIP;
ecrRepository: {
awsAccount: string;
name: string;
};
database: {
host: string;
port: string;
name: string;
securityGroup: {
id: string;
};
Expand All @@ -43,61 +54,94 @@ export class ApiEc2Stack extends Stack {
});
ec2SecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443), 'HTTPS over IPv4');
ec2SecurityGroup.addIngressRule(Peer.anyIpv6(), Port.tcp(443), 'HTTPS over IPv6');
ec2SecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(80), 'HTTP over IPv4');
ec2SecurityGroup.addIngressRule(Peer.anyIpv6(), Port.tcp(80), 'HTTP over IPv6');
ec2SecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(22), 'SSH access');
ec2SecurityGroup.addIngressRule(Peer.anyIpv6(), Port.tcp(22), 'SSH access');

const elasticIp = new CfnEIP(this, 'ApiIP', {
tags: [
{
key: 'Name',
value: 'API EC2',
}
]
});

const keypair = new CfnKeyPair(this, 'ApiEc2Keypair', {
keyName: 'EC2 Keypair',
publicKeyMaterial: '---- BEGIN SSH2 PUBLIC KEY ----\n' +
'Comment: "rsa-key-20230917"\n' +
'AAAAB3NzaC1yc2EAAAADAQABAAABAQCbNh2l09RvvYyDtIXjtqnJG/nFYtV44Gwx\n' +
'TjYteFvwyK3wSFlgA0qFIjoUxrh5KLGsVYzoz7JmcD3thg7YdbcCy3agZ8EIK8ds\n' +
'8ly37dGn5D1u7re5AU+7Y+LPsw31lxjusZCFPJEElKexryuhIP043EAe/pWXDfM6\n' +
'6urIhgXGKRhxu3prw43xX8STTGGUaropESaEnudAxMlgHu/nNI8DauQhf5LZWboT\n' +
'YjnB2X8lpDY9Vsab6e0OINUcXgHvH9A9r/twBPt1Hx8MXWjmDTEiU5+vuDOcws+g\n' +
'4/TsVUxuJ/MqhBkJj/ou2cVkCcBuzbreQdd9zlc5J5CJSfReg4dl\n' +
'---- END SSH2 PUBLIC KEY ----\n',
const keypair = new KeyPair(this, 'ApiEc2Keypair', {
keyPairName: 'EC2 Keypair',
publicKeyMaterial: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN5OhXLM34h3omM+5AoaYgUktcBMUBrC5awrPoItmf3S [email protected]',
});

const apiDomain = 'api.' + (props.littil.environment === 'production' ? '' : props.littil.environment + '.') + 'littil.org';

const littilServerConf = fs.readFileSync('lib/nginx/serverconfiguration')
.toString('utf-8')
.replaceAll('%ENVIRONMENT%', props.littil.environment);
.replaceAll('%API_DOMAIN%', apiDomain);

/* Logging. */
const logGroupName = 'BackendApiEc2Logs';
const apiEc2LoggingStack = new LoggingStack(this, 'ApiEc2LoggingStack', {
littil: props.littil,
logGroupName,
});

const dockerTag = '1.3.1';
const dockerImage = props.ecrRepository.awsAccount + '.dkr.ecr.eu-west-1.amazonaws.com/' + props.ecrRepository.name + ':' + dockerTag;

const littilOidcSecretName = 'littil/backend/' + props.littil.environment + '/oidc';
const littilSmtpSecretName = 'littil/backend/' + props.littil.environment + '/smtp';
// TODO: Find by tags
const littilBackendDatabaseSecretName = 'ApiDatabaseStackLittilApiDa-ia57olJcscCP';

const userData = UserData.forLinux();
userData.addCommands(
'sudo su',
'yum update',
'yum update -y',

'yum install docker -y',
'systemctl start docker.service',
'docker run -p 8081:80 -P -d nginxdemos/hello',
'aws ecr get-login-password --region ' + this.region + ' | docker login --username AWS --password-stdin ' + this.account + '.dkr.ecr.' + this.region + '.amazonaws.com',
'docker pull ' + this.account + '.dkr.ecr.eu-west-1.amazonaws.com/littil-backend:latest',

'aws ecr get-login-password --region ' + this.region + ' | docker login --username AWS --password-stdin ' + props.ecrRepository.awsAccount + '.dkr.ecr.' + this.region + '.amazonaws.com',
'docker pull ' + dockerImage,

'echo DATASOURCE_HOST="' + props.database.host + '" >> littil.env',
'echo DATASOURCE_PORT=' + props.database.port + ' >> littil.env',
'echo DATASOURCE_DATABASE=' + props.database.name + ' >> littil.env',
'echo QUARKUS_HTTP_CORS_ORIGINS="' + props.littil.httpCorsOrigin + '" >> littil.env',
'echo QUARKUS_LOG_CLOUDWATCH_ACCESS_KEY_ID="' + apiEc2LoggingStack.loggingAccessKey.ref + '" >> littil.env',
'echo QUARKUS_LOG_CLOUDWATCH_ACCESS_KEY_SECRET="' + apiEc2LoggingStack.loggingAccessKey.attrSecretAccessKey + '" >> littil.env',
'echo QUARKUS_LOG_CLOUDWATCH_LOG_GROUP="' + apiEc2LoggingStack.cloudwatchLogGroup.logGroupName + '" >> littil.env',
'echo QUARKUS_LOG_CLOUDWATCH_REGION=' + this.region + ' >> littil.env',
'echo QUARKUS_LOG_CLOUDWATCH_LOG_STREAM_NAME=' + logGroupName + ' >> littil.env',
'echo [email protected] >> littil.env',

'yum install jq -y',
// TODO: Pass these secret values to the docker process in a more secure way than storing them in a file
'echo DATASOURCE_USERNAME=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilBackendDatabaseSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .username) >> littil.env',
'echo DATASOURCE_PASSWORD=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilBackendDatabaseSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .password) >> littil.env',
'echo OIDC_CLIENT_ID=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilOidcSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .oidcClientId) >> littil.env',
'echo OIDC_CLIENT_SECRET=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilOidcSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .oidcClientSecret) >> littil.env',
'echo OIDC_TENANT=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilOidcSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .oidcTenant) >> littil.env',
'echo M2M_CLIENT_ID=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilOidcSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .m2mClientId) >> littil.env',
'echo M2M_CLIENT_SECRET=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilOidcSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .m2mClientSecret) >> littil.env',
'echo SMTP_HOST=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilSmtpSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .smtpHost) >> littil.env',
'echo SMTP_USERNAME=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilSmtpSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .smtpUsername) >> littil.env',
'echo SMTP_PASSWORD=$(aws secretsmanager get-secret-value --region ' + this.region + ' --secret-id ' + littilSmtpSecretName + ' | jq --raw-output \'.SecretString\' | jq -r .smtpPassword) >> littil.env',

'docker run -p 8080:8080 --env-file littil.env -d ' + dockerImage,
// TODO: Test whether we can immediately delete the file
// 'rm littil.env',

/* Install Nginx. */
'amazon-linux-extras install nginx1',
'systemctl stop nginx',

/* Nginx configuration. */
'echo test > /etc/nginx/conf.d/test',
'echo ' + Buffer.from(littilServerConf).toString('base64') + ' > /etc/nginx/conf.d/test2',
'echo ' + Buffer.from(littilServerConf).toString('base64') + ' | base64 -d > /etc/nginx/conf.d/api.' + props.littil.environment + '.littil.org.conf',
'echo ' + Buffer.from(littilServerConf).toString('base64') + ' | base64 -d > /etc/nginx/conf.d/' + apiDomain + '.conf',

/* Certbot. */
'amazon-linux-extras install epel',
'yum update',
'yum update -y',
'yum install certbot -y',
'letsencrypt certonly --standalone -d api.' + props.littil.environment + '.littil.org -m [email protected] --agree-tos --no-eff-email',
// Will fail when there's no DNS A record pointing to the above created Elastic IP. Should be interactively waiting or perhaps put the Elastic IP in a separate stack that needs to be created first.
'letsencrypt certonly --standalone -d ' + apiDomain + ' -m [email protected] --agree-tos --no-eff-email',

'systemctl enable docker',
'systemctl enable nginx',
'systemctl start nginx',
);

const pullPolicyStatement = new PolicyStatement({
Expand All @@ -116,41 +160,47 @@ export class ApiEc2Stack extends Stack {
'*',
]
});
const pullPolicy = new Policy(this, 'PullPolicy');
pullPolicy.addStatements(
const readSecretsPolicyStatment = new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'secretsmanager:GetSecretValue',
],
resources: [
'*',
]
});
const littilApiPolicy = new Policy(this, 'PullPolicy');
littilApiPolicy.addStatements(
pullPolicyStatement,
readSecretsPolicyStatment,
);

/**/
const ec2Instance = new Instance(this, 'ApiInstance', {
vpc: props.apiVpc,
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.NANO),
instanceType: InstanceType.of(InstanceClass.T3A, InstanceSize.MICRO),
machineImage: new AmazonLinuxImage({
generation: AmazonLinuxGeneration.AMAZON_LINUX_2,
cpuType: AmazonLinuxCpuType.ARM_64,
cpuType: AmazonLinuxCpuType.X86_64,
}),
vpcSubnets: {subnetType: SubnetType.PUBLIC},
securityGroup: ec2SecurityGroup,
keyName: keypair.keyName,
keyPair: keypair,
userData,
});

pullPolicy.attachToRole(ec2Instance.role);
littilApiPolicy.attachToRole(ec2Instance.role);

new CfnEIPAssociation(this, 'Ec2EipAssociation', {
eip: elasticIp.ref,
eip: props.elasticIp.ref,
instanceId: ec2Instance.instanceId,
});
/**/

/* Logging. */
const apiEc2LoggingStack = new LoggingStack(this, 'ApiEc2LoggingStack', {
littil: props.littil,
logGroupName: 'BackendApiEc2Logs',
});

/* Database access. */
const databaseSecurityGroup = SecurityGroup.fromSecurityGroupId(this, 'DatabaseSecurityGroup', props.database.securityGroup.id);
databaseSecurityGroup.connections.allowFrom(ec2SecurityGroup, Port.tcp(parseInt(props.database.port)));
// TODO: Uncomment, test and remove the allTcp rule
// databaseSecurityGroup.connections.allowFrom(ec2SecurityGroup, Port.tcp(parseInt(props.database.port)));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to keep this as comment ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes and no. It should be as limited as possible, but I don't have time to test it right now. So for now at least I'll add a TODO.

databaseSecurityGroup.connections.allowFrom(ec2SecurityGroup, Port.allTcp());
}
}
22 changes: 22 additions & 0 deletions infrastructure/lib/api-elastic-ip-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Stack, StackProps } from 'aws-cdk-lib';
import { CfnEIP } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class ApiElasticIpStack extends Stack {
public readonly elasticIp: CfnEIP;

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

this.elasticIp = new CfnEIP(this, 'ApiIP', {
tags: [
{
key: 'Name',
value: 'API EC2',
}
]
});
}
}
32 changes: 16 additions & 16 deletions infrastructure/lib/database-stack.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { CfnOutput, Stack, StackProps } from 'aws-cdk-lib';
import { InstanceClass, InstanceSize, InstanceType, Vpc } from 'aws-cdk-lib/aws-ec2';
import { InstanceClass, InstanceSize, InstanceType, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2';
import {
Credentials,
DatabaseInstance,
DatabaseInstanceEngine,
DatabaseInstanceFromSnapshot,
MariaDbEngineVersion,
ParameterGroup
ParameterGroup,
SnapshotCredentials
} from 'aws-cdk-lib/aws-rds';
import { DatabaseInstanceProps } from 'aws-cdk-lib/aws-rds/lib/instance';
import { DatabaseInstanceFromSnapshotProps } from 'aws-cdk-lib/aws-rds/lib/instance';
import { Construct } from 'constructs';
import { LittilEnvironmentSettings } from './littil-environment-settings';

Expand All @@ -31,7 +31,7 @@ export class DatabaseStack extends Stack {
const databaseName = 'LittilDatabase';

const rdsEngine = DatabaseInstanceEngine.mariaDb({
version: MariaDbEngineVersion.VER_10_6_8,
version: MariaDbEngineVersion.VER_10_6_17,
});

const rdsParameterGroup = new ParameterGroup(this, 'littil-rds-parametergroup', {
Expand All @@ -41,27 +41,27 @@ export class DatabaseStack extends Stack {
}
});

const credentials = Credentials.fromGeneratedSecret(
'littil_' + props.littil.environment.substring(0, 7),
{
secretName: 'littil/backend/' + props.littil.environment + '/database',
}
);
const dbUserName = 'littil_' + props.littil.environment.substring(0, 7);
// TODO: As soon as supported, use 'secretName' property. Will make looking this secret up easier.
const snapshotCredentials = SnapshotCredentials.fromGeneratedSecret(dbUserName);

const databaseProperties: DatabaseInstanceProps = {
databaseName,
credentials,
const databaseProperties: DatabaseInstanceFromSnapshotProps = {
credentials: snapshotCredentials,
publiclyAccessible: false,
vpc: props.apiVpc,
vpcSubnets: {
subnetType: SubnetType.PRIVATE_ISOLATED,
},
engine: rdsEngine,
parameterGroup: rdsParameterGroup,
instanceType: InstanceType.of(
InstanceClass.T4G,
InstanceSize.MICRO,
),
snapshotIdentifier: 'apidatabasestack-snapshot-littilapidatabase74782804-gy2ioxtzgpgy',
};

const database = new DatabaseInstance(this, 'LittilApiDatabase', databaseProperties);
const database = new DatabaseInstanceFromSnapshot(this, 'LittilApiDatabase', databaseProperties);

new CfnOutput(this, 'databaseHost', {
value: database.instanceEndpoint.hostname,
Expand Down
6 changes: 3 additions & 3 deletions infrastructure/lib/nginx/serverconfiguration
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ server {
listen 443 ssl;
server_name api.littil.org;

ssl_certificate /etc/letsencrypt/live/api.%ENVIRONMENT%.littil.org/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/api.%ENVIRONMENT%.littil.org/privkey.pem;
ssl_certificate /etc/letsencrypt/live/%API_DOMAIN%/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/%API_DOMAIN%/privkey.pem;

location / {
proxy_pass http://localhost:8081;
proxy_pass http://localhost:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Expand Down
Loading
Loading