Skip to content

Infrastructure as Code

This section is a BIG one! In this section your coding skill are put on a test when you face the wrath of AWS CDK + ECS + Fargate + Cloudformation!

You will use CDK to code the following: * Create ECS Cluster * Create ECS Policies & Roles * Create Security Groups * Create ECS Task Definitions * Create Log groups * Create container definitions * Create ECS Fargate * Create External ALB


Preparation

Configuring AWS Account

For CDK to work properly, you need to configure credentials to access AWS account.

Follow these steps:

  1. If you don't have aws cli v2, follow instructions how to install it: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html
  2. Run aws configure
  3. Enter your AWS_ACCESS_KEY_ID & AWS_SECRET_ACCESS_KEY provided by your instructor
  4. Verify your setup by running aws sts get-caller-identity This should show something like this:
    {
        "UserId": "AIDAR3C4LGTKR5WUAWEFG",
        "Account": "126895201313",
        "Arn": "arn:aws:iam::126895201313:user/dude.perfect@fakemail.com"
    }
    

Installing CDK

Allthough we will be using CI/CD pipeline to run IaC into AWS, we can use CDK package to initialize the IaC folder structure and help us to debug our code.

  1. Install CDK client: npm install -g aws-cdk
  2. Create directory iac to your repository
  3. Run cdk init --language python --app iac. If you want to use other language like TypeScript, JavaScript, Java, C#, you can but Answers/Examples are done \ with Python to make things more readable
  4. Create python3 virtual environment, if you like, python3 -m venv .env
  5. Activate Python Virtual environment source .env/bin/activate
  6. Install Python requirements pip install -r requirements.txt
  7. Install additional package pip install aws_cdk.aws_ec2 aws_cdk.aws_ecs aws_cdk.aws_ecs_patterns
  8. Your iac/iac/iac_stack.py files imports should look like:
from aws_cdk import (
    core,
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_iam as iam,
    aws_ecr as ecr,
    aws_elasticloadbalancingv2 as elbv2,
    aws_logs as logs,
)

IaC Code

Stack Configuration

  1. Create environment configuration core.Environment :
    • Region must be eu-west-1
    • Account must be XXXXXXXX, the one your instructor provided
  2. Pass it to the Iac class
  3. Change the IacStack ID iac to <username>

Help

File: iac/app.py

#!/usr/bin/env python3

from aws_cdk import core

from iac.iac.iac_stack import IacStack


app = core.App()
myEnv = core.Environment(region="eu-west-1", account="<AWS Account ID")
IacStack(app, "<username>", env=myEnv)

app.synth()

VPC & Cluster Configuration

  1. VPC configuration needs to be retrieved (ec2.Vpc.from_lookup()), save it to vpc variable
    • VPC ID can be obtained from your instructor
  2. Create new cluster ecs.Cluster()
    • Name it according to your username, i.e. apprentice-0
    • Assign correct VPC object
  3. Create new namespace <cluster object>.add_default_cloud_map_namespace(name="<username>")

Help
# VPC and Cluster Configuration
vpc = ec2.Vpc.from_lookup(self, id="VPC", vpc_id="<VPC ID>")

cluster = ecs.Cluster(self, "<username>", vpc=vpc)
cluster.add_default_cloud_map_namespace(name="<username>")

ECS Policies and Roles

In order for ECS Fargate to be able to retrieve ECR images and log itself to CloudWatch

  1. Create ServicePrincipal iam.ServicePrincipal('ecs-tasks.amazonaws.com) This will be used as a service account mapping for the ECS Service
  2. Create Task Role iam.Role(self, 'ecsTaskExecutionRole-<username>, assumed_by=<service_principal_object>
  3. Get AWS Managed IAM Policy object iam.ManagedPolicy.from_aws_managed_policy_name('service-role/AmazonECSTaskExecutionRolePolicy') This is the role ECS will use to Retrieve ECR images and write to CloudWatch
  4. Add managed policy role to your task role <role_object>.add_managed_policy(<iam_managed_policy_object>)

Help
service_principal = iam.ServicePrincipal('ecs-tasks.amazonaws.com')
task_role = iam.Role(self, 'ecsTaskExecutionRole-<username>', assumed_by=service_principal)
ecs_managed_policy = iam.ManagedPolicy.from_aws_managed_policy_name(
    'service-role/AmazonECSTaskExecutionRolePolicy'
)
task_role.add_managed_policy(ecs_managed_policy)

Create ECR Repository objects

ECR objects are needed to configure images for the containers 1. Create fronend ECR object ecr.Repository.from_repository_name(self, id="frontend-<username>", repository_name="<username>-frontend") 1. Create backend ECR object backend_ecr_repo = ecr.Repository.from_repository_name(self, id="backend-<username>", repository_name="<username>-backend")


Help
frontend_ecr_repo = ecr.Repository.from_repository_name(
    self,
    id="frontend-<username>",
    repository_name="<username>-frontend"
)
backend_ecr_repo = ecr.Repository.from_repository_name(
    self,
    id="backend-<username>",
    repository_name="<username>-backend",
)

Create Security Groups

Security groups are needed to gain access to the containers

  1. Create three security groups for frontend | backend | database with ec2.SecurityGroup
    • ID = <fronend|backend|database>-sg-<username>
    • allow_all_outbound = True
    • security_group_name = <FrontendSG|BackendSG|DatabaseSG>-<username>
    • vpc = vpc
  2. Allow Incoming traffic with <security_group_object>.connections.allow_from_any_ipv4()
    • Frontend: ec2.Port.tcp(5000)
    • Backend: ec2.Port.tcp(8080)
    • Database: ec2.Port.tcp(6379)

Help
frontend_security_group = ec2.SecurityGroup(
    self,
    'frontend-sg-<username>',
    allow_all_outbound=True,
    security_group_name="FrontendSG-<username>",
    vpc=vpc
)
backend_security_group = ec2.SecurityGroup(
    self,
    'backend-sg-<username>',
    allow_all_outbound=True,
    security_group_name="BackendSG-<username>",
    vpc=vpc
)

database_security_group = ec2.SecurityGroup(
    self,
    'database-sg-<username>',
    allow_all_outbound=True,
    security_group_name="DatabaseSG-<username>",
    vpc=vpc
)

frontend_security_group.connections.allow_from_any_ipv4(
    ec2.Port.tcp(5000),
)
backend_security_group.connections.allow_from_any_ipv4(
    ec2.Port.tcp(8080),
)
database_security_group.connections.allow_from_any_ipv4(
    ec2.Port.tcp(6379)
)

Create Fargate TaskDefinitions

Task Definitions you set the CPU & Memory limits the Fargate task is allowed to consume. \ You will also configure what Service Role ECS Fargate uses.

  1. Create Frontend Task Definition ecs.FargateTaskDefinition
    • ID = frontendTaskDefinition-<username>
    • CPU = 256
    • Memory limit = 512
    • Task Role = <task_role_object>
  2. Create Backend Task Definition ecs.FargateTaskDefinition
    • ID = backendTaskDefinition-<username>
    • CPU = 256
    • Memory limit = 512
    • Task Role = <task_role_object>
  3. Create Database Task Definition ecs.FargateTaskDefinition
    • ID = databaseTaskDefinition-<username>
    • CPU = 256
    • Memory limit = 512
    • Task Role = task_role_object

Help
# Frontend TaskDefinition
frontend_task_definition = ecs.FargateTaskDefinition(
    self,
    'frontendTaskDefinition-<username>',
    cpu=256,
    memory_limit_mib=512,
    task_role=task_role,
)

# Backend TaskDefinition
backend_task_definition = ecs.FargateTaskDefinition(
    self,
    'backendTaskDefinition-<username>',
    cpu=256,
    memory_limit_mib=512,
    task_role=task_role
)

# Database TaskDefinition
database_task_definition = ecs.FargateTaskDefinition(
    self,
    'databaseTaskDefinition-<username>',
    cpu=256,
    memory_limit_mib=1024,
    task_role=task_role
)

Logging

Log groups are needed for ECS to log into correct logging groups You will need three log groups for frontend | backend | database

  1. Create each log group with logs.LogGroup()
    • ID = <fronendLogGroup|backendLogGroup|databaseLogGroup>-<username>
    • log_group_name = /ecs/<fronend|backend|database>LogGroup-<username>
    • removal_policy = core.RemovalPolicy.DESTROY
  2. Create Log Driver for each fronend|backend|database with ecs.AwsLogDriver(), this will be used by the container for logging.
    • log_group = <log group object>
    • stream_prefix = <fronend|backend|database>

Help
# Logging
frontend_log_group = logs.LogGroup(
    self,
    "frontendLogGroup-<username>",
    log_group_name="/ecs/frontendLogGroup-<username>",
    removal_policy=core.RemovalPolicy.DESTROY
)

backend_log_group = logs.LogGroup(
    self,
    "backendLogGroup-<username>",
    log_group_name="/ecs/backendLogGroup-<username>",
    removal_policy=core.RemovalPolicy.DESTROY
)

database_log_group = logs.LogGroup(
    self,
    "databaseLogGroup-<username>",
    log_group_name="/ecs/databaseLogGroup-<username>",
    removal_policy=core.RemovalPolicy.DESTROY
)

frontend_log_driver = ecs.AwsLogDriver(
    log_group=frontend_log_group,
    stream_prefix="frontend"
)

backend_log_driver = ecs.AwsLogDriver(
    log_group=backend_log_group,
    stream_prefix="backend",
)

database_log_driver = ecs.AwsLogDriver(
    log_group=database_log_group,
    stream_prefix="database",
)

Container Definitions

Container definitions define image that the container uses, you can also assign Environment variables, hostname etc

  1. Create frontend container definition <frontend_task_definition_object.add_container()
    • ID = frontend-<username>
    • image = ecs.ContainerImage.from_ecr_repository(<frontend_ecr_repo_object>, tag=<tag_from_CI>)
      • Hint! Get the correct tag with os.environ["CI_COMMIT_SHORT_SHA"]
    • Environment:
      • "COLORS_BACKEND_ADDRESS": "backend.<username>:8080",
    • logging: <frontend log driver object>
  2. Add port mapping to the frontend container <frontend_container_object>.add_port_mappings(ecs.PortMapping(container_port=<frontend_containerPort>))

  3. Create backend container definition <backend_task_definition_object>.add_container()

    • ID = backend-<username>
    • image = ecs.ContainerImage.from_ecr_repository(<backend_ecr_repo_object>)
    • Environment:
      • "COLORS_DATABASE_ADDRESS": "database.<username>:6379",
    • logging: <backend log driver object>
  4. Add port mapping to the backend container <backend_container_object>.add_port_mappings(ecs.PortMapping(container_port=<backend_containerPort>))

  5. Create database container definition <database_task_definition_object>.add_container()

    • ID = database-<username>
    • image = ecs.ContainerImage.from_registry("redis:6.0-alpine")
    • logging: <database log driver object>

Help
# Frontend container
frontend_container = frontend_task_definition.add_container(
    'frontend-<username>',
    image=ecs.ContainerImage.from_ecr_repository(frontend_ecr_repo, tag=os.environ["CI_COMMIT_SHORT_SHA"]),
    logging=frontend_log_driver,
    environment={
        "COLORS_BACKEND_ADDRESS": "backend.<username>:8080",
    },
    memory_limit_mib=512,
)
frontend_container.add_port_mappings(ecs.PortMapping(container_port=5000))
# Backend container
backend_container = backend_task_definition.add_container(
    'backend-<username>',
    image=ecs.ContainerImage.from_ecr_repository(backend_ecr_repo, tag=os.environ["CI_COMMIT_SHORT_SHA"]),
    logging=backend_log_driver,
    environment={
        "COLORS_DATABASE_ADDRESS": "database.<username>:6379",
    },
    memory_limit_mib=512,
)
backend_container.add_port_mappings(ecs.PortMapping(container_port=8080))

# Database container
database_container = database_task_definition.add_container(
    'database-<username>',
    image=ecs.ContainerImage.from_registry("redis:6.0-alpine"),
    logging=database_log_driver,
)
database_container.add_port_mappings(ecs.PortMapping(container_port=6379))

ECS Fargate Definitions

  1. Create Frontend ECS Fargate Definition ecs.FargateService()
    • ID = FrontendFargateService-<username>
    • cluster = <cluster_object>
    • task_definition = <frontend_task_definition_object>
    • security_group = <frontend_security_group_object>
    • desired_count = 1
    • cloud_map_options = ecs.CloudMapOptions(name="frontend")
    • service_name = frontend
  2. Create Backend ECS Fargate Definition ecs.FargateService()
    • ID = BackendFargateService-<username>
    • cluster = <cluster_object>
    • task_definition = <backend_task_definition_object>
    • security_group = <backend_security_group_object>
    • desired_count = 1
    • cloud_map_options = ecs.CloudMapOptions(name="backend")
    • service_name = backend
  3. Create Database ECS Fargate Definition ecs.FargateService()
    • ID = DatabaseFargateService-<username>
    • cluster = <cluster_object>
    • task_definition = <database_task_definition_object>
    • security_group = <database_security_group_object>
    • desired_count = 1
    • cloud_map_options = ecs.CloudMapOptions(name="database")
    • service_name = database

Help
frontend_service = ecs.FargateService(
    self,
    "Frontend-<username>",
    cluster=cluster,            # Required
    task_definition=frontend_task_definition,
    security_group=frontend_security_group,
    desired_count=1,
    assign_public_ip=True,
    cloud_map_options=ecs.CloudMapOptions(name="frontend"),
    service_name="frontend"
)

ecs.FargateService(
    self,
    "Backend-<username>",
    cluster=cluster,            # Required
    task_definition=backend_task_definition,
    security_group=backend_security_group,
    assign_public_ip=True,
    desired_count=1,
    cloud_map_options=ecs.CloudMapOptions(name="backend"),
    service_name="backend"
)

ecs.FargateService(
    self,
    "Database-<username>",
    cluster=cluster,            # Required
    task_definition=database_task_definition,
    security_group=database_security_group,
    desired_count=1,
    cloud_map_options=ecs.CloudMapOptions(name="database"),
    service_name="database"
)

Create ALB

ALB (Application Load Balancer) will serve the frontend application to the world

  1. Create ALB elbv2.ApplicationLoadBalancer
    • ID = external-<username>
    • vpc = <vpc_objec>
    • internet_facing = True
  2. Add listener <ALB-object>.add_listener()
    • ID = <alb-listener-<username>
    • port = 80
  3. Create Health check elbv2.HealthCheck(path='/')
  4. Add Target to the listener <listener-object>.add_targets()
    • ID = tg-<username>
    • port = 80
    • health_check = <health-check-object>
    • targets = [<frontend-Fargete-service-object]
  5. Print out the ALB Address core.CfnOutput(self, 'ALB DNS: ', value=<alb-object>.load_balancer_dns_name)

Help
alb = elbv2.ApplicationLoadBalancer(
    self,
    "external-<username>",
    vpc=vpc,
    internet_facing=True
)

alb_listener = alb.add_listener(
    'alb-listener-<username>',
    port=80
)

alb_health_check = elbv2.HealthCheck(path='/')

alb_listener.add_targets(
    'tg-<username>',
    port=80,
    health_check=alb_health_check,
    targets=[frontend_service]
)

core.CfnOutput(self, 'ALB DNS: ', value=alb.load_balancer_dns_name)

Check that the configuration is correct

Now that the code is done we need to check that there isn't any mistakes. You can dry run the CDK in the command line cdk synth. \ This will print out the YAML of the build.

Lastly save requirements: pip freeze > requirements.txt


CI/CD

IaC should be also part of CI/CD pipeline. We first add IaC to the Test part of our pipeline then execute CDK deploy, when images have been created.

Template:

image: <image>

variables:
  AWS_DEFAULT_REGION: eu-west-1

stages:
  - <stage>

<stage>:<name>:
  stage: <stage>
  before_script:
    - <items>
  script:
    - <items>

Test

  1. Create .gitlab-ci-iac-test.yml to iac folder and include it in the main .gitlab-ci.yml file
  2. Use image node:14.4.0-alpine
  3. Name the stage as test
  4. Before script
    • Install awslint as Global NPM package
    • Change working directory to iac
  5. Script
    • Run awslint

Help
image: node:14.4.0-alpine

variables:
  AWS_DEFAULT_REGION: eu-west-1

stages:
  - test

test:iac:
  stage: test
  before_script:
    - npm install -g awslint
    - cd iac
  script:
    - awslint

Deploy

  1. Create .gitlab-ci-iac.yml to iac folder and include it in the main .gitlab-ci.yml file
  2. Use image node:14.4.0-alpine
  3. Name the stage as deploy
  4. Before script
    • Install aws-cdkas a Global NPM package
    • Install Python3
    • Change working directory to iac
    • Install requirements pip3 install -r requirements.txt
  5. Script
    • Run cdk deploy --require-approval never

Help
image: node:14.4.0-alpine

variables:
  AWS_DEFAULT_REGION: eu-west-1

stages:
  - deploy

deploy:iac:
  stage: deploy
  before_script:
    - npm install -g aws-cdk
    - apk add python3
    - cd iac
    - pip3 install -r requirements.txt
  script:
    - cdk deploy --require-approval never


Now commit everything and see how it goes!! Good luck!


Here is the whole gitlab-ci.yml file
stages:
  - test
  - build
  - deploy

test:frontend:
  stage: test
  trigger:
    include:
      - local: /frontend/.gitlab-ci-frontend-test.yml
    strategy: depend

test:backend:
  stage: test
  trigger:
    include:
      - local: /backend/.gitlab-ci-backend-test.yml
    strategy: depend

test:iac:
  stage: test
  trigger:
    include:
      - local: /iac/.gitlab-ci-iac-test.yml
    strategy: depend

build:frontend:
  stage: build
  trigger:
    include:
      - local: /frontend/.gitlab-ci-frontend-build.yml
    strategy: depend

build:backend:
  stage: build
  trigger:
    include:
      - local: /backend/.gitlab-ci-backend-build.yml
    strategy: depend

deploy:iac:
  stage: deploy
  trigger:
    include:
      - local: /iac/.gitlab-ci-iac.yml
    strategy: depend