Created
August 17, 2021 07:43
-
-
Save jeznag/623132d2a98eff5392acd702702f1ef7 to your computer and use it in GitHub Desktop.
CDK for Ghost CMS on Fargate with EFS for persistent attachments
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Dictionary } from './utility/Dictionary' | |
import { Secret } from '@aws-cdk/aws-secretsmanager' | |
import { LogDriver } from '@aws-cdk/aws-ecs' | |
import { LogGroup } from './constructs/LogGroup' | |
import { Alarm } from '@aws-cdk/aws-cloudwatch' | |
import { AwsAccount } from './OkraInfra' | |
import cdk = require('@aws-cdk/core') | |
import ec2 = require('@aws-cdk/aws-ec2') | |
import ecs = require('@aws-cdk/aws-ecs') | |
import ecs_patterns = require('@aws-cdk/aws-ecs-patterns') | |
import rds = require('@aws-cdk/aws-rds') | |
import s3 = require('@aws-cdk/aws-s3') | |
import route53 = require('@aws-cdk/aws-route53') | |
import certificateManager = require('@aws-cdk/aws-certificatemanager') | |
import { | |
FileSystem, | |
LifecyclePolicy, | |
PerformanceMode, | |
ThroughputMode | |
} from '@aws-cdk/aws-efs' | |
export type GhostCmsConfig = { | |
snapshotIdentifier: string | |
taskCPU: number | |
taskMemoryLimit: number | |
maxTasks: number | |
minTasks: number | |
} | |
interface Props extends cdk.StackProps, GhostCmsConfig { | |
vpc: ec2.Vpc | |
ecsCluster: ecs.Cluster | |
certificateHarvestSubdomain: certificateManager.ICertificate | |
hostedZone: route53.IHostedZone | |
numAvailabilityZones: number | |
harvestDomainName: string | |
environmentName: AwsAccount | |
} | |
class GhostCmsSecrets extends cdk.NestedStack { | |
public readonly sesUser: Secret | |
public readonly sesPassword: Secret | |
public readonly databasePassword: Secret | |
constructor (scope: cdk.Construct) { | |
super(scope, 'GhostCmsSecrets') | |
this.sesUser = new Secret(this, 'ghostCmsSesUser') | |
this.sesPassword = new Secret(this, 'ghostCmsSesPassword') | |
this.databasePassword = new Secret(this, 'ghostCmsDatabasePassword', { | |
generateSecretString: { passwordLength: 16, excludeCharacters: '/ @"' } | |
}) | |
} | |
} | |
class GhostCmsDatabase extends cdk.NestedStack { | |
public readonly database: rds.CfnDBCluster | |
public readonly dbName = 'ghost' | |
public readonly dbUser = 'admin' | |
constructor (scope: cdk.Construct, databasePassword: Secret, props: Props) { | |
super(scope, 'GhostCmsDatabase') | |
this.database = this.setupDB(databasePassword, props) | |
} | |
private setupDB (databasePassword: Secret, props: Props): rds.CfnDBCluster { | |
const privateSubnets = props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE }) | |
const dbSubnetGroupName = 'ghostcms-database-subnet-group' | |
const dbSubnetGroup = new rds.CfnDBSubnetGroup(this, 'rdsdbsubnets', { | |
dbSubnetGroupDescription: 'ghostcms rds db subnet group', | |
dbSubnetGroupName, | |
subnetIds: privateSubnets.subnetIds | |
}) | |
const dbSecurityGroup = new ec2.CfnSecurityGroup(this, 'dbSecurityGroup', { | |
groupDescription: 'ghostcms db security group', | |
groupName: 'ghostcms-database-sg', | |
vpcId: props.vpc.vpcId, | |
securityGroupIngress: [ | |
{ | |
cidrIp: props.vpc.vpcCidrBlock, | |
fromPort: 3306, | |
ipProtocol: 'tcp', | |
toPort: 3306 | |
} | |
], | |
securityGroupEgress: [ | |
{ | |
cidrIp: '0.0.0.0/0', | |
ipProtocol: '-1' | |
} | |
] | |
}) | |
const database = new rds.CfnDBCluster(this, 'ghostcmsdb', { | |
availabilityZones: props.vpc.availabilityZones.slice(0, props.numAvailabilityZones), | |
backupRetentionPeriod: 7, | |
dbSubnetGroupName: dbSubnetGroup.dbSubnetGroupName, | |
engine: 'aurora-mysql', | |
port: 3306, | |
databaseName: this.dbName, | |
// NOTE: This is inherited from the snapshotIdentifier. Only use if we are creating a fresh DB from no snapshot | |
// masterUsername: this.dbUser, | |
snapshotIdentifier: props.snapshotIdentifier, | |
vpcSecurityGroupIds: [ | |
dbSecurityGroup.ref | |
], | |
storageEncrypted: true, | |
engineMode: 'serverless', | |
masterUserPassword: databasePassword.secretValue.toString(), | |
scalingConfiguration: { | |
autoPause: true, | |
minCapacity: 1 | |
}, | |
deletionProtection: true | |
}) | |
return database | |
} | |
} | |
class GhostCms extends cdk.NestedStack { | |
public readonly service: ecs_patterns.ApplicationLoadBalancedFargateService | |
public readonly ghostDns: string | |
public readonly logGroup: LogGroup | |
private readonly cmsAlbAccessLogsBucket: s3.IBucket | |
constructor (scope: cdk.Construct, database: rds.CfnDBCluster, dbName: string, dbUser: string, sesUser: Secret, sesPass: Secret, props: Props) { | |
super(scope, 'GhostCms') | |
this.ghostDns = `cms.${props.harvestDomainName}` | |
this.cmsAlbAccessLogsBucket = new s3.Bucket(this, `ghost-alb-access-logs-${props.environmentName}`) | |
this.logGroup = new LogGroup(this, 'GhostCmsLogs') | |
this.service = this.setupGhostCms(database, dbName, dbUser, sesUser, sesPass, this.logGroup.logDriver, props) | |
this.attachEFSVolumeToGhostCms(this.service, props) | |
} | |
private setupGhostCms (database: rds.CfnDBCluster, dbName: string, dbUser: string, sesUser: Secret, sesPass: Secret, logDriver: LogDriver, props: Props): ecs_patterns.ApplicationLoadBalancedFargateService { | |
const secrets: Dictionary<ecs.Secret> = {} | |
const environment: Dictionary<string> = { | |
url: `https://${this.ghostDns}`, | |
database__client: 'mysql', | |
database__connection__database: dbName, | |
database__connection__host: database.attrEndpointAddress, | |
database__connection__user: dbUser, | |
database__connection__password: String(database.masterUserPassword) | |
} | |
/** | |
* Only configure email for production | |
* These SES credentials were manually created in the AWS SES Console | |
* I wasn't sure how to generate them dynamically using CDK... | |
*/ | |
if (props.environmentName === AwsAccount.PROD) { | |
environment.mail__transport = 'SMTP' | |
environment.mail__from = "'Harvest CMS' <harvest@okrasolar.com>" | |
environment.mail__options__service = 'SES' | |
environment.mail__options__port = '465' | |
environment.mail__options__host = 'email-smtp.ap-southeast-1.amazonaws.com' | |
secrets.mail__options__auth__user = ecs.Secret.fromSecretsManager(sesUser) | |
secrets.mail__options__auth__pass = ecs.Secret.fromSecretsManager(sesPass) | |
} | |
const ghostCms = new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'GhostCms', { | |
assignPublicIp: false, | |
cluster: props.ecsCluster, | |
cpu: props.taskCPU, | |
memoryLimitMiB: props.taskMemoryLimit, | |
desiredCount: 1, | |
domainZone: props.hostedZone, | |
domainName: this.ghostDns, | |
certificate: props.certificateHarvestSubdomain, | |
publicLoadBalancer: true, | |
redirectHTTP: true, | |
healthCheckGracePeriod: cdk.Duration.seconds(300), | |
taskImageOptions: { | |
image: ecs.ContainerImage.fromRegistry('ghost:4.11.0'), | |
logDriver: logDriver, | |
containerPort: 2368, | |
environment: environment, | |
secrets: secrets | |
} | |
}) | |
ghostCms.targetGroup.configureHealthCheck({ | |
path: '/ghost/api/v3/admin/site/', | |
interval: cdk.Duration.seconds(120), | |
unhealthyThresholdCount: 5, | |
healthyHttpCodes: '200,301' | |
}) | |
const scaling = ghostCms.service.autoScaleTaskCount({ | |
minCapacity: props.minTasks, | |
maxCapacity: props.maxTasks | |
}) | |
scaling.scaleOnCpuUtilization('CpuScaling', { | |
targetUtilizationPercent: 50, | |
scaleInCooldown: cdk.Duration.seconds(300), | |
scaleOutCooldown: cdk.Duration.seconds(30) | |
}) | |
new Alarm(this, 'slow-ghostcms-response-time-alarm', { | |
metric: ghostCms.loadBalancer.metricTargetResponseTime(), | |
threshold: 15, | |
evaluationPeriods: 2 | |
}) | |
ghostCms.loadBalancer.logAccessLogs(this.cmsAlbAccessLogsBucket) | |
return ghostCms | |
} | |
private attachEFSVolumeToGhostCms (ghostCms: ecs_patterns.ApplicationLoadBalancedFargateService, props: Props): void { | |
/* | |
Notes about EFS backups: | |
It doesn't look like we'd be able to handle backups in CDK. | |
There are some EFS backup options (https://docs.aws.amazon.com/efs/latest/ug/efs-backup-solutions.html) but it seems like that would require manual action to restore data. | |
(We'd have to do the same with RDS as well if we wanted to restore to a point in time) | |
The default deletion policy for EFS is retain so we would not lose data if we destroyed the stack. https://github.com/aws/aws-cdk/pull/8593/files | |
However, after destroying the stack, we would have to either: | |
a. Change CDK to import the existing EFS volume (referencing it by ARN) | |
b. Use CDK to generate a new volume and then use the AWS CLI to copy data from the old volume over | |
*/ | |
const fileSystem = new FileSystem(this, 'ghostEFS', { | |
vpc: props.ecsCluster.vpc, | |
lifecyclePolicy: LifecyclePolicy.AFTER_14_DAYS, | |
performanceMode: PerformanceMode.GENERAL_PURPOSE, | |
throughputMode: ThroughputMode.BURSTING | |
}) | |
fileSystem.connections.allowDefaultPortFrom(ghostCms.service) | |
fileSystem.connections.allowDefaultPortTo(ghostCms.service) | |
ghostCms.taskDefinition.addVolume({ | |
name: 'ghost-volume', | |
efsVolumeConfiguration: { | |
fileSystemId: fileSystem.fileSystemId, | |
transitEncryption: 'ENABLED' | |
} | |
}) | |
ghostCms.taskDefinition.defaultContainer?.addMountPoints({ | |
sourceVolume: 'ghost-volume', | |
containerPath: '/var/lib/ghost/content/images', | |
readOnly: false | |
}) | |
} | |
} | |
export class GhostCmsStack extends cdk.Stack { | |
public readonly logGroup: LogGroup | |
constructor (scope: cdk.Construct, id: string, props: Props) { | |
super(scope, id, props) | |
const secrets = new GhostCmsSecrets(this) | |
const rds = new GhostCmsDatabase(this, secrets.databasePassword, props) | |
const cms = new GhostCms(this, rds.database, rds.dbName, rds.dbUser, secrets.sesUser, secrets.sesPassword, props) | |
this.logGroup = cms.logGroup | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment