Skip to content

Instantly share code, notes, and snippets.

@statico
Last active May 2, 2022
Embed
What would you like to do?
Push-to-deploy architecture using AWS Fargate and GitHub Actions
# See:
# - https://github.com/aws-actions/amazon-ecs-deploy-task-definition
name: build-and-deploy
on:
push:
branches: main
# The idea here is to update whatever is already in place on AWS. If we need to
# modify the ECS task definition, we should do it on ECS. Let's save
# declarative state for something like TerraForm when we get to that point.
env:
AWS_REGION: us-east-2
ECR_REGISTRY: 000000000.dkr.ecr.us-east-2.amazonaws.com
ECR_REPOSITORY: app
ECS_CLUSTER: staging
ECS_SERVICE: app
ECS_TASK: app-staging
ECS_CONTAINER_NAME: app-container
IMAGE_TAG: ${{ github.sha }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Build Docker image
uses: docker/build-push-action@v2.7.0
with:
context: .
load: true
build-args: |
BUILD_ID=${{ github.sha }}
tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha,scope=v5
cache-to: type=gha,mode=max,scope=v5
- name: Push image to ECR
run: docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Download task definition
if: github.ref == 'refs/heads/main'
run: |
aws ecs describe-task-definition --task-definition $ECS_TASK --query taskDefinition > task-definition.json
- name: Fill in the new image ID in the Amazon ECS task definition
if: github.ref == 'refs/heads/main'
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: ${{ env.ECS_CONTAINER_NAME }}
image: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
- name: Deploy to Amazon ECS
if: github.ref == 'refs/heads/main'
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
cluster: ${{ env.ECS_CLUSTER }}
service: ${{ env.ECS_SERVICE }}
wait-for-service-stability: false
- name: Report Status
if: github.ref == 'refs/heads/main'
uses: ravsamhq/notify-slack-action@1.3.1
with:
status: ${{ job.status }}
notify_when: "success,failure,warnings"
notification_title: null
footer: null
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# ------------------------------
# * Dependencies
FROM node:16.13.0-alpine3.14 AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# ------------------------------
# * Builder
FROM node:16.13.0-alpine3.14 AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BUILD_ID=unknown
ENV NEXT_PUBLIC_BUILD_ID=$BUILD_ID
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn lint && yarn prettier:check
RUN NEXT_PUBLIC_BUILD_TIME="$(date -R)" NODE_OPTIONS="--max-old-space-size=8192" yarn build && yarn install --production --ignore-scripts --prefer-offline
# ------------------------------
# * Production
FROM node:16.13.0-alpine3.14 AS runner
RUN apk add --no-cache vips-tools
WORKDIR /app
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/knexfile.ts ./
COPY --from=builder /app/runtimeConfig.js ./
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/email-templates ./email-templates
COPY --from=builder /app/lib ./lib
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
ENV PORT=3000
ARG BUILD_ID=unknown
ENV NEXT_PUBLIC_BUILD_ID=$BUILD_ID
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
CMD ["yarn", "start"]
name: pr
on:
pull_request:
branches: main
env:
ECR_REGISTRY: 000000000.dkr.ecr.us-east-2.amazonaws.com
ECR_REPOSITORY: app
IMAGE_TAG: ${{ github.sha }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1.6.0
- name: Build Docker image
uses: docker/build-push-action@v2.7.0
with:
context: .
load: true
build-args: |
BUILD_ID=${{ github.sha }}
tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
cache-from: type=gha,scope=v5
cache-to: type=gha,mode=max,scope=v5
#!/usr/bin/env node
const { randomUUID } = require("crypto")
const { writeFileSync } = require("fs")
const { exec, tempdir } = require("shelljs")
const aws = async (cmd) => {
const output = exec(`aws --output json ${cmd}`, { silent: true, exit: true })
try {
return JSON.parse(output.stdout)
} catch (err) {
console.error(output.stdout + output.stderr)
process.exit(1)
}
}
const getStagingImage = async () => {
const service = await aws(
`ecs describe-services --cluster staging --service app`
)
const app = service.services.find((s) => s.serviceName === "app")
const arn = app.taskDefinition
console.log(`Staging task definition: ${arn}`)
const taskDef = await aws(
`ecs describe-task-definition --task-definition ${arn}`
)
const containers = taskDef.taskDefinition.containerDefinitions
const container = containers.find((c) => c.name === "app-container")
const image = container.image
console.log(`Image: ${image}\n`)
const hash = image.match(/(\w+)$/)[0]
exec(`git fetch`)
exec(`git log -n 1 ${hash}`)
return image
}
const promoteImageToProd = async (image) => {
const { taskDefinitionArns } = await aws(`ecs list-task-definitions`)
const latest = taskDefinitionArns
.filter((x) => /\/app-prod:\d+$/.test(x))
.sort((a, b) => {
const ax = Number(a.match(/:(\d+)$/)[1])
const bx = Number(b.match(/:(\d+)$/)[1])
return bx - ax
})[0]
const taskDef = await aws(
`ecs describe-task-definition --task-definition ${latest}`
)
const containers = taskDef.taskDefinition.containerDefinitions
const container = containers.find((c) => c.name === "app-container")
if (container.image === image) {
console.log("Image is already up to date. Making sure prod is updated.")
const name =
taskDef.taskDefinition.family + ":" + taskDef.taskDefinition.revision
await aws(
`ecs update-service --cluster prod --service app --task-definition ${name}`
)
return
}
container.image = image
delete taskDef.taskDefinition.compatibilities
delete taskDef.taskDefinition.registeredAt
delete taskDef.taskDefinition.registeredBy
delete taskDef.taskDefinition.requiresAttributes
delete taskDef.taskDefinition.revision
delete taskDef.taskDefinition.status
delete taskDef.taskDefinition.taskDefinitionArn
const path = tempdir() + "/" + randomUUID()
writeFileSync(path, JSON.stringify(taskDef.taskDefinition), "utf8")
const result = await aws(
`ecs register-task-definition --cli-input-json file://${path}`
)
const name =
result.taskDefinition.family + ":" + result.taskDefinition.revision
await aws(
`ecs update-service --cluster prod --service app --task-definition ${name}`
)
console.log(`Updated prod cluster to ${name}`)
}
const main = async () => {
const image = await getStagingImage()
await promoteImageToProd(image)
}
main().then(() => ({}))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment