Skip to content

Instantly share code, notes, and snippets.

@jeznag
Created August 17, 2021 07:43
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jeznag/623132d2a98eff5392acd702702f1ef7 to your computer and use it in GitHub Desktop.
Save jeznag/623132d2a98eff5392acd702702f1ef7 to your computer and use it in GitHub Desktop.
CDK for Ghost CMS on Fargate with EFS for persistent attachments
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