Skip to content

Commit

Permalink
Merge pull request #331 from aligent/feature/DO-1342-prerender-fargate
Browse files Browse the repository at this point in the history
DO-1342: Prerender hosted in fargate
  • Loading branch information
TheOrangePuff authored Jul 14, 2022
2 parents d399608 + 9efc83c commit 9aa5d0b
Show file tree
Hide file tree
Showing 12 changed files with 1,191 additions and 423 deletions.
932 changes: 510 additions & 422 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"esbuild": "^0.12.15"
},
"dependencies": {
"aws-cdk-lib": "^2.26.0"
"aws-cdk-lib": "^2.26.0",
"constructs": "^10.0.0"
},
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions packages/prerender-fargate/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!lib/prerender/*
15 changes: 15 additions & 0 deletions packages/prerender-fargate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Prerender in Fargate
A construct to host [Prerender](https://github.com/prerender/prerender) in Fargate.

## Props
`prerenderName`: Name of the Prerender service
`domainName`: Domain name for Prerender
`vpcId`: VPC to host Prerender in
`bucketName`: Optional S3 bucket name
`expirationDays`: Optional days until items expire in bucket (default to 7 days)
`basicAuthList`: List of basic auth credentials to accept
`certificateArn`: Certificate arn to match the domain
`desiredInstanceCount`: Number of Prerender instances to run (default 1)
`maxInstanceCount`: Maximum number of Prerender instances to run (default 2)
`instanceCPU`: CPU to allocate to each instance (default 512)
`instanceMemory`: Amount of memory to allocate to each instance (default 1024)
3 changes: 3 additions & 0 deletions packages/prerender-fargate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { PrerenderFargate } from "./lib/prerender-fargate";

export { PrerenderFargate };
104 changes: 104 additions & 0 deletions packages/prerender-fargate/lib/prerender-fargate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Construct } from 'constructs';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsPatterns from 'aws-cdk-lib/aws-ecs-patterns';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
import { HostedZone } from 'aws-cdk-lib/aws-route53';
import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3'
import * as ecrAssets from 'aws-cdk-lib/aws-ecr-assets';
import { AccessKey, User } from 'aws-cdk-lib/aws-iam';
import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
import * as path from 'path';

export interface PrerenderOptions {
prerenderName: string,
domainName: string,
vpcId: string,
bucketName?: string,
expirationDays?: number,
basicAuthList: Array<string[]>,
certificateArn: string,
desiredInstanceCount?: number,
maxInstanceCount?: number,
instanceCPU?: number,
instanceMemory?: number
}

export class PrerenderFargate extends Construct {
constructor(scope: Construct, id: string, props: PrerenderOptions) {
super(scope, id);

// Create bucket for prerender storage
const bucket = new Bucket(this, `${props.prerenderName}-bucket`, {
bucketName: props.bucketName,
lifecycleRules: [{
enabled: true,
expiration: Duration.days(props.expirationDays || 7) // Default to 7 day expiration
}],
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});

// Configure access to the bucket for the container
const user = new User(this, 'PrerenderAccess');
bucket.grantReadWrite(user);

const accessKey = new AccessKey(this, 'PrerenderAccessKey', {
user: user,
serial: 1
});

const vpc = ec2.Vpc.fromLookup(this, "vpc", { vpcId: props.vpcId });

const cluster = new ecs.Cluster(this, `${props.prerenderName}-cluster`, { vpc: vpc });

const directory = path.join(__dirname, 'prerender');
const asset = new ecrAssets.DockerImageAsset(this, `${props.prerenderName}-image`, {
directory,
});

// Create a load-balanced Fargate service
const fargateService = new ecsPatterns.ApplicationLoadBalancedFargateService(
this,
`${props.prerenderName}-service`,
{
cluster,
serviceName: `${props.prerenderName}-service`,
desiredCount: props.desiredInstanceCount || 1,
cpu: props.instanceCPU || 512, // 0.5 vCPU default
memoryLimitMiB: props.instanceMemory || 1024, // 1 GB default to give Chrome enough memory
taskImageOptions: {
image: ecs.ContainerImage.fromDockerImageAsset(asset),
enableLogging: true,
containerPort: 3000,
environment: {
S3_BUCKET_NAME: bucket.bucketName,
AWS_ACCESS_KEY_ID: accessKey.accessKeyId,
AWS_SECRET_ACCESS_KEY: accessKey.secretAccessKey.toString(),
AWS_REGION: Stack.of(this).region,
BASIC_AUTH: props.basicAuthList.toString()
}
},
healthCheckGracePeriod: Duration.seconds(20),
publicLoadBalancer: true,
assignPublicIp: true,
listenerPort: 443,
redirectHTTP: true,
domainName: props.domainName,
domainZone: new HostedZone(this, 'hosted-zone', { zoneName: props.domainName }),
certificate: Certificate.fromCertificateArn(this, 'cert', props.certificateArn)
}
);

// Setup AutoScaling policy
const scaling = fargateService.service.autoScaleTaskCount({
maxCapacity: props.maxInstanceCount || 2,
});
scaling.scaleOnCpuUtilization(`${props.prerenderName}-scaling`, {
targetUtilizationPercent: 50,
scaleInCooldown: Duration.seconds(60),
scaleOutCooldown: Duration.seconds(60),
});
}
}
25 changes: 25 additions & 0 deletions packages/prerender-fargate/lib/prerender/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM node:16-alpine

ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/lib/chromium/
ENV MEMORY_CACHE=0

# install chromium, tini and clear cache
RUN apk add --update-cache chromium tini \
&& rm -rf /var/cache/apk/* /tmp/*

USER node
WORKDIR "/home/node"

COPY ./package.json .
COPY ./server.js .

# install npm packages
RUN npm install --no-package-lock

EXPOSE 3000

HEALTHCHECK CMD netstat -ltn | grep -c 3000

ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
13 changes: 13 additions & 0 deletions packages/prerender-fargate/lib/prerender/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "prerender-alpine",
"version": "6.5.0",
"description": "lightweight prerender container built on alpine linux",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"prerender": "5.20.0",
"prerender-aws-s3-cache": "1.0.1"
}
}
64 changes: 64 additions & 0 deletions packages/prerender-fargate/lib/prerender/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

const prerender = require('prerender');
const s3Cache = require('prerender-aws-s3-cache');

const server = prerender({
chromeFlags: ['--no-sandbox', '--headless', '--disable-gpu', '--remote-debugging-port=9222', '--hide-scrollbars', '--disable-dev-shm-usage'],
forwardHeaders: true,
chromeLocation: '/usr/bin/chromium-browser'
});

server.use(prerender.blacklist());
server.use(prerender.httpHeaders());
server.use(prerender.removeScriptTags());
server.use(s3Cache);

server.use({
requestReceived: (req, res, next) => {
let auth = req.headers.x-prerender-authorization;
if (!auth) return res.send(401);

// malformed
let parts = auth.split(' ');
if ('basic' != parts[0].toLowerCase()) return res.send(401);
if (!parts[1]) return res.send(401);
auth = parts[1];

// credentials
auth = new Buffer.from(auth, 'base64').toString();
auth = auth.match(/^([^:]+):(.+)$/);
if (!auth) return res.send(401);

// compare credentials in header to list of allowed credentials
let basicAuthAllowList = [];

const basicAuthEnvList = process.env.BASIC_AUTH.toString().split(',');

for (const [index, element] of basicAuthEnvList.entries()) {
const authIndex = (index - index % 2) / 2
if (index % 2 === 0) {
basicAuthAllowList [authIndex] = [element];
} else {
basicAuthAllowList[authIndex].push(element)
}
}

let authenticated = false;
for (const basicAuth of basicAuthAllowList) {
authenticated = auth[1] === basicAuth[0] && auth[2] === basicAuth[1]

if (authenticated) break;
}
if (!authenticated) return res.send(401);

req.prerender.authentication = {
name: auth[1],
password: auth[2]
};

return next();
}
});

server.start();
Loading

0 comments on commit 9aa5d0b

Please sign in to comment.