From a9d8dd82c1db8feac98edefbd3a57a2f10817799 Mon Sep 17 00:00:00 2001 From: Darren Reid Date: Tue, 23 Feb 2021 14:27:36 +1100 Subject: [PATCH] Prep for replacing travis with GitHub Actions. --- .github/workflows/README.md | 144 +++++++++++++++++++++++++++ .github/workflows/build.yml | 23 +++++ .github/workflows/release.yml | 97 ++++++++++++++++++ .travis.yml | 17 ---- deploy-envs.sh | 13 --- deploy/nginx-proxy-compose.yml | 45 +++++++++ deploy/task-definition-template.json | 50 ++++++++++ scripts/build.sh | 9 -- scripts/deploy.sh | 44 -------- scripts/task-definition.json | 26 ----- src/Chat/Dockerfile | 4 + 11 files changed, 363 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/README.md create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .travis.yml delete mode 100644 deploy-envs.sh create mode 100644 deploy/nginx-proxy-compose.yml create mode 100644 deploy/task-definition-template.json delete mode 100644 scripts/build.sh delete mode 100644 scripts/deploy.sh delete mode 100644 scripts/task-definition.json create mode 100644 src/Chat/Dockerfile diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..7d87283 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,144 @@ +# ServiceStack mix GitHub Actions +`release.yml` generated from `x mix release-ecr-aws`, this template in designed to help with automating CI deployments to AWS ECS and dedicated AWS ECS cluster. +This is a cheap way to start without an AWS Application Load Balancer (ALB) and also be in a situation that will easier to add one once the web service needs additional scale or high availability. + +## Overview +`release.yml` is designed to work with a ServiceStack app templates deploying directly to a single server in a dedicated ECS cluster via templated GitHub Actions. + +## Setup +### Create unique ECS cluster +For this setup, it is best to create a separate cluster as cluster will only have the single instance in it running. +This pattern is to start from a good base with AWS ECS and automated CI deployment while avoiding the higher costs of needing to run an application load balancer (ALB). + +If/when you can justify the cost of an ALB for easier scaling and zero downtime deployment, the GitHub Action `release.yml` can be slightly modified to be used with a re-created or different ECS Service that is configured to be used with an Application Load Balancer and Target Group. + +### Elastic IP (optional) +The reason you might want to register this first is because we are only running one EC2 instance and hosting our own `nginx-proxy` on the same instance as the applications. +Since an `A` record will be pointing there, one advantage of not using an auto-assigned IP is that we can reassign the elastic IP if for what ever reason the instance goes down or is lost. + +## Launch to EC2 Instance +When launching the EC2 instance, you'll need to select an 'ECS optimized' AMI as the image used for your instance. +### Choose AMI +The easiest way to find the latest Amazon Linux 2 image for this is to go to the [AWS documentation for ECS-optimized AMIs and look up your region here](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-optimized_AMI.html#ecs-optimized-ami-linux). + +Using the AMI ID (starts with `ami-`) at the bottom, search in the 'Community AMIs' tab on the first step of the `Launch EC2 Instance` wizard. + +### Choose Instance Type +A t2.micro or larger will work fine, this pattern can be used to host multiple applications on the 1 server so if the number of applications gets larger, you might need a larger instance type. +> Note this pattern is suitable for testing prototypes or low traffic applications as it is cost effective and makes it easy to bundle multiple apps onto 1 EC2 instance. + +### Configure Instance +Under `IAM role`, use the `ecsInstanceRole`, if this is not available, see [AWS documentation for the process of checking if it exists and creating it if needed](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html). + +You will also want to add the following Userdata script (in the `Configure` step of the launch wizard) with your own `ECS_CLUSTER` value. This tells the ecs-agent running on the instance which ECS cluster the instance should join. + +```bash +#!/bin/bash +cat </etc/ecs/ecs.config +ECS_CLUSTER=chats +ECS_AVAILABLE_LOGGING_DRIVERS=["awslogs", "syslog"] +ECS_ENABLE_CONTAINER_METADATA=true +EOS +``` + +Note down your cluster name as it will need to be used to create the cluster in ECS before it is visible. +See [`ECS Container Agent Configuration`](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-agent-config.html) for more information. + +### Add Storage +The default of 30gb is fine but take into account how large/how many applications you'll have running. + +### Configure Security Groups +You'll want to expose at least ports 80 and 443. + +### Setup Docker-compose and nginx-proxy +To let your server handle multiple ServiceStack applications and automate the generation and management of TLS certificates, an additional docker-compose file is provided via the `x mix` template, `nginx-proxy-compose.yml`. This docker-compose file is ready to run and can be copied to the deployment server. +> This is done via docker-compose rather than via ECS for simplicity. + +For example, once copied to remote `~/nginx-proxy-compose.yml`, the following command can be run on the remote server. + +``` +docker-compose -f ~/nginx-proxy-compose.yml up -d +``` + +This will run an nginx reverse proxy along with a companion container that will watch for additional containers in the same docker network and attempt to initialize them with valid TLS certificates. + +## GitHub Repository setup +The `release.yml` assumes 6 secrets have been setup. + +- AWS_ACCESS_KEY_ID - AWS access key for programmatic access to AWS APIs. +- AWS_SECRET_ACCESS_KEY - AWS access secrets for programmatic access to AWS APIs. +- AWS_REGION - default region for AWS API calls. +- AWS_ECS_CLUSTER - Cluster name in ECS, this should match the value in your Userdata. +- HOST_DOMAIN - Domain/submain of your application, eg `chat.example.com` . +- LETSENCRYPT_EMAIL - Email address, required for Let's Encrypt automated TLS certificates. + +These secrets are used to populate variables within GitHub Actions and other configuration files. + +For the AWS access, a separate user specifically for deploying via GitHub Actions should be used. + +The policies required for the complete initial setup will be: +- `AmazonEC2ContainerRegistryFullAccess` +- `AmazonECS_FullAccess` + +Once the application is successfully deployed the first time, reduced access for both ECR and ECS can be used instead. For application updates, the GitHub Action can use the following policy. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "VisualEditor0", + "Effect": "Allow", + "Action": [ + "ecr:GetRegistryPolicy", + "ecr:PutImageTagMutability", + "ecr:GetDownloadUrlForLayer", + "ecr:DescribeRegistry", + "ecr:GetAuthorizationToken", + "ecr:ListTagsForResource", + "ecr:UploadLayerPart", + "ecr:ListImages", + "ecr:PutImage", + "ecr:UntagResource", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:DescribeImages", + "ecr:TagResource", + "ecr:DescribeRepositories", + "ecr:InitiateLayerUpload", + "ecr:BatchCheckLayerAvailability", + "ecr:ReplicateImage", + "ecr:GetRepositoryPolicy", + "ecs:SubmitTaskStateChange", + "ecs:UpdateContainerInstancesState", + "ecs:RegisterContainerInstance", + "ecs:DescribeTaskDefinition", + "ecs:DescribeClusters", + "ecs:ListServices", + "ecs:UpdateService", + "ecs:ListTasks", + "ecs:ListTaskDefinitionFamilies", + "ecs:RegisterTaskDefinition", + "ecs:SubmitContainerStateChange", + "ecs:StopTask", + "ecs:DescribeServices", + "ecs:ListContainerInstances", + "ecs:DescribeContainerInstances", + "ecs:DeregisterContainerInstance", + "ecs:TagResource", + "ecs:DescribeTasks", + "ecs:UntagResource", + "ecs:ListTaskDefinitions", + "ecs:ListClusters" + ], + "Resource": "*" + } + ] +} +``` +> Further permission reduction can be done by reducing what resources can be accessed. +> Application permissions can be controlled via `taskRoleArn`, see [AWS docs for details](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html). + +## What's the process of the `release.yml`? + +![](https://raw.githubusercontent.com/ServiceStack/docs/master/docs/images/mix/release-ecr-aws-diagram.png) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..48d0f74 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,23 @@ +name: Build + +on: + pull_request: {} + push: + branches: + - '**' # matches every branch + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - name: checkout + uses: actions/checkout@v2.0.0 + + - name: setup .net core + uses: actions/setup-dotnet@v1.7.2 + with: + dotnet-version: 5.0.100 + + - name: build + run: dotnet build + working-directory: . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1702a11 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release +on: + release: + types: [published] +jobs: + push_to_ecr: + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Run dotnet publish + run: dotnet publish ./src/Chat/ -o publish -c release + + - name: repository name fix + run: echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - 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: ${{ secrets.AWS_REGION }} + + - name: Login to Amazon ECR + id: login_ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Create ECR repo if not exists. + env: + ECR_REPOSITORY: ${{ env.image_repository_name }} + run: aws ecr describe-repositories --repository-names ${ECR_REPOSITORY} || aws ecr create-repository --repository-name ${ECR_REPOSITORY} + + - name: Build and push to ECR + id: push_image_to_ecr + uses: docker/build-push-action@v2.2.2 + with: + file: src/Chat/Dockerfile + context: . + push: true + tags: ${{ steps.login_ecr.outputs.registry }}/${{ env.image_repository_name }}:${{ github.event.release.tag_name }} + + deploy_ecs: + needs: push_to_ecr + runs-on: ubuntu-20.04 + steps: + - name: checkout + uses: actions/checkout@v2 + + - 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: ${{ secrets.AWS_REGION }} + + - name: Login to Amazon ECR + id: login_ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Repository name fix and env values setup + run: | + echo "image_repository_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + echo "domain=${{ secrets.HOST_DOMAIN }}" >> $GITHUB_ENV + echo "letsencrypt_email=${{ secrets.LETSENCRYPT_EMAIL }}" >> $GITHUB_ENV + echo "app_name=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]' | cut -d'/' -f2)" >> $GITHUB_ENV + echo "cluster_name=${{ secrets.AWS_ECS_CLUSTER }}" >> $GITHUB_ENV + echo "image_url=${{ steps.login_ecr.outputs.registry }}/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]'):${{ github.event.release.tag_name }}" >> $GITHUB_ENV + echo "aws_region=${{ secrets.AWS_REGION }}" >> $GITHUB_ENV + + - name: Populate task definition template + uses: danielr1996/envsubst-action@1.0.0 + env: + RELEASE_VERSION: ${{ github.event.release.tag_name }} + APP_NAME: ${{ env.app_name }} + IMAGE_URL: ${{ env.image_url }} + HOST_DOMAIN: ${{ env.domain }} + LETSENCRYPT_EMAIL: ${{ env.letsencrypt_email }} + AWS_REGION: ${{ env.aws_region }} + CLUSTER_NAME: ${{ env.cluster_name }} + with: + input: deploy/task-definition-template.json + output: deploy/task-definition.json + + - name: Create task definition if doesn't exist + run: aws ecs describe-task-definition --task-definition ${{ env.app_name }} || aws ecs register-task-definition --cli-input-json file://deploy/task-definition.json + + - name: Create ECS Service if not exists. + run: aws ecs describe-services --cluster ${{ env.cluster_name }} --services ${{ env.app_name }} | jq '.services[0]' -e || aws ecs create-service --cluster ${{ env.cluster_name }} --service-name ${{ env.app_name }} --task-definition ${{ env.app_name }} --desired-count 1 + + - name: Deploy new revision of the task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: deploy/task-definition.json + service: ${{ env.app_name }} + cluster: ${{ env.cluster_name }} + force-new-deployment: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 76a19c2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -dist: xenial -addons: - snaps: - - name: dotnet-sdk - classic: true - channel: latest/stable -sudo: required -language: csharp -mono: none -script: - - sudo snap alias dotnet-sdk.dotnet dotnet - - dotnet --version - - chmod +x ./deploy-envs.sh - - chmod +x ./scripts/build.sh - - chmod +x ./scripts/deploy.sh - - cd scripts && ./build.sh - - if [ "$TRAVIS_BRANCH" == "master" ]; then ./deploy.sh; fi \ No newline at end of file diff --git a/deploy-envs.sh b/deploy-envs.sh deleted file mode 100644 index 8e2208f..0000000 --- a/deploy-envs.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# set environment variables used in deploy.sh and AWS task-definition.json: -export IMAGE_NAME=netcoreapps-chat -export IMAGE_VERSION=latest - -export AWS_DEFAULT_REGION=us-east-1 -export AWS_ECS_CLUSTER_NAME=default -export AWS_VIRTUAL_HOST=chat.netcore.io - -# set any sensitive information in travis-ci encrypted project settings: -# required: AWS_ACCOUNT_NUMBER, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY -# optional: SERVICESTACK_LICENSE diff --git a/deploy/nginx-proxy-compose.yml b/deploy/nginx-proxy-compose.yml new file mode 100644 index 0000000..11ba1a9 --- /dev/null +++ b/deploy/nginx-proxy-compose.yml @@ -0,0 +1,45 @@ +version: '2' + +services: + nginx-proxy: + image: jwilder/nginx-proxy + container_name: nginx-proxy + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - conf:/etc/nginx/conf.d + - vhost:/etc/nginx/vhost.d + - html:/usr/share/nginx/html + - dhparam:/etc/nginx/dhparam + - certs:/etc/nginx/certs:ro + - /var/run/docker.sock:/tmp/docker.sock:ro + network_mode: bridge + + letsencrypt: + image: jrcs/letsencrypt-nginx-proxy-companion + container_name: nginx-proxy-le + restart: always + environment: + - DEFAULT_EMAIL=you@example.com + volumes_from: + - nginx-proxy + volumes: + - certs:/etc/nginx/certs:rw + - acme:/etc/acme.sh + - /var/run/docker.sock:/var/run/docker.sock:ro + network_mode: bridge + +volumes: + conf: + vhost: + html: + dhparam: + certs: + acme: + +networks: + default: + external: + name: webproxy \ No newline at end of file diff --git a/deploy/task-definition-template.json b/deploy/task-definition-template.json new file mode 100644 index 0000000..96cd90e --- /dev/null +++ b/deploy/task-definition-template.json @@ -0,0 +1,50 @@ +{ + "family": "${APP_NAME}", + "requiresCompatibilities": [ + "EC2" + ], + "networkMode": "bridge", + "containerDefinitions": [ + { + "portMappings": [ + { + "protocol": "tcp", + "containerPort": 80 + } + ], + "cpu": 0, + "environment": [ + { + "name": "VIRTUAL_HOST", + "value": "${HOST_DOMAIN}" + }, + { + "name": "LETSENCRYPT_HOST", + "value": "${HOST_DOMAIN}" + }, + { + "name": "LETSENCRYPT_EMAIL", + "value": "${LETSENCRYPT_EMAIL}" + }, + { + "name": "APP_VERSION", + "value": "${RELEASE_VERSION}" + } + ], + "mountPoints": [], + "memory": 128, + "volumesFrom": [], + "image": "${IMAGE_URL}", + "essential": true, + "name": "${APP_NAME}", + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "${CLUSTER_NAME}-${APP_NAME}", + "awslogs-region": "${AWS_REGION}", + "awslogs-create-group": "true" + } + } + } + ] +} \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index 0259d62..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash -source ../deploy-envs.sh - -#AWS_ACCOUNT_NUMBER={} set in private variable -export AWS_ECS_REPO_DOMAIN=$AWS_ACCOUNT_NUMBER.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com - -# Build process -docker build -t $IMAGE_NAME ../ -docker tag $IMAGE_NAME $AWS_ECS_REPO_DOMAIN/$IMAGE_NAME:$IMAGE_VERSION diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100644 index c8563b7..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -source ../deploy-envs.sh - -export AWS_ECS_REPO_DOMAIN=$AWS_ACCOUNT_NUMBER.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com -export ECS_SERVICE=$IMAGE_NAME-service -export ECS_TASK=$IMAGE_NAME-task - -# install dependencies -sudo apt-get install jq -y #install jq for json parsing -sudo apt-get install gettext -y -pip install --user awscli # install aws cli w/o sudo -export PATH=$PATH:$HOME/.local/bin # put aws in the path - -# replace environment variables in task-definition -envsubst < task-definition.json > new-task-definition.json - -eval $(aws ecr get-login --region $AWS_DEFAULT_REGION --no-include-email | sed 's|https://||') #needs AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY envvars - -if [ $(aws ecr describe-repositories | jq --arg x $IMAGE_NAME '[.repositories[] | .repositoryName == $x] | any') == "true" ]; then - echo "Found ECS Repository $IMAGE_NAME" -else - echo "ECS Repository doesn't exist, Creating $IMAGE_NAME ..." - aws ecr create-repository --repository-name $IMAGE_NAME -fi - -docker push $AWS_ECS_REPO_DOMAIN/$IMAGE_NAME:$IMAGE_VERSION - -aws ecs register-task-definition --cli-input-json file://new-task-definition.json --region $AWS_DEFAULT_REGION > /dev/null # Create a new task revision -TASK_REVISION=$(aws ecs describe-task-definition --task-definition $ECS_TASK --region $AWS_DEFAULT_REGION | jq '.taskDefinition.revision') #get latest revision -SERVICE_ARN="arn:aws:ecs:$AWS_DEFAULT_REGION:$AWS_ACCOUNT_NUMBER:service/$ECS_SERVICE" -ECS_SERVICE_EXISTS=$(aws ecs list-services --region $AWS_DEFAULT_REGION --cluster $AWS_ECS_CLUSTER_NAME | jq '.serviceArns' | jq 'contains(["'"$SERVICE_ARN"'"])') -if [ "$ECS_SERVICE_EXISTS" == "true" ]; then - echo "ECS Service already exists, Updating $ECS_SERVICE ..." - aws ecs update-service --cluster $AWS_ECS_CLUSTER_NAME --service $ECS_SERVICE --task-definition "$ECS_TASK:$TASK_REVISION" --desired-count 1 --region $AWS_DEFAULT_REGION > /dev/null #update service with latest task revision -else - echo "Creating ECS Service $ECS_SERVICE ..." - aws ecs create-service --cluster $AWS_ECS_CLUSTER_NAME --service-name $ECS_SERVICE --task-definition "$ECS_TASK:$TASK_REVISION" --desired-count 1 --region $AWS_DEFAULT_REGION > /dev/null #create service -fi -if [ "$(aws ecs list-tasks --service-name $ECS_SERVICE --region $AWS_DEFAULT_REGION | jq '.taskArns' | jq 'length')" -gt "0" ]; then - TEMP_ARN=$(aws ecs list-tasks --service-name $ECS_SERVICE --region $AWS_DEFAULT_REGION | jq '.taskArns[0]') # Get current running task ARN - TASK_ARN="${TEMP_ARN%\"}" # strip double quotes - TASK_ARN="${TASK_ARN#\"}" # strip double quotes - aws ecs stop-task --task $TASK_ARN --region $AWS_DEFAULT_REGION > /dev/null # Stop current task to force start of new task revision with new image -fi diff --git a/scripts/task-definition.json b/scripts/task-definition.json deleted file mode 100644 index c6f6ce6..0000000 --- a/scripts/task-definition.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "family": "${ECS_TASK}", - "networkMode": "bridge", - "containerDefinitions": [ - { - "image": "${AWS_ECS_REPO_DOMAIN}/${IMAGE_NAME}:${IMAGE_VERSION}", - "name": "${IMAGE_NAME}", - "cpu": 128, - "memory": 256, - "essential": true, - "portMappings": [ - { - "containerPort": 5000, - "hostPort": 0, - "protocol": "tcp" - } - ], - "environment": [ - { - "name": "VIRTUAL_HOST", - "value": "${AWS_VIRTUAL_HOST}" - } - ] - } - ] -} \ No newline at end of file diff --git a/src/Chat/Dockerfile b/src/Chat/Dockerfile new file mode 100644 index 0000000..861db0b --- /dev/null +++ b/src/Chat/Dockerfile @@ -0,0 +1,4 @@ +FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS runtime +WORKDIR /app +COPY publish/* ./ +ENTRYPOINT ["dotnet", "Chat.dll"]