jeanphix

How to configure a Gitlab / ECS continuous deployment pipeline

14 Jun 2017

Here will see how to build a Gitlab CI/CD pipeline to deploy new codebase to ECS.

The pipeline will be split in three steps (stages):

Setting up the AWS deployment key pair

In order to let Gitlab CI to deploy new docker images to ECR repository then update the CloudFormation stack to use the image we need to prepare an IAM key pair with appropriate credentials to update the CloudFormation stack and resources.

It looks like:

from troposphere import (
    AWS_ACCOUNT_ID,
    AWS_REGION,
    AWS_STACK_NAME,
    GetAtt,
    Join,
    Output,
    Ref,
)

from troposphere.iam import (
    AccessKey,
    PolicyType,
    User,
)

from .cluster import template


user = Ref(User(
    "DeploymentUser",
    template=template,
))


key = AccessKey(
    "DeploymentKey",
    template=template,
    UserName=user,
)


PolicyType(
    "DeploymentStackPolicy",
    template=template,
    PolicyName="DeploymentStackPolicy",
    Users=[user],
    PolicyDocument=dict(
        Statement=[dict(
            Effect="Allow",
            Action=[
                "cloudformation:UpdateStack",
                "cloudformation:DescribeStackEvents",
                "cloudformation:DescribeStacks",
            ],
            Resource=Join("", [
                "arn:aws:cloudformation:",
                Ref(AWS_REGION),
                ":",
                Ref(AWS_ACCOUNT_ID),
                ":stack/",
                Ref(AWS_STACK_NAME),
                "/*",
            ]),
        )],
    )
)


PolicyType(
    "DeploymentECRTokenPolicy",
    template=template,
    PolicyName="DeploymentECRTokenPolicy",
    Users=[user],
    PolicyDocument=dict(
        Statement=[dict(
            Effect="Allow",
            Action=[
                "ecr:GetAuthorizationToken",
            ],
            Resource="*",
        )],
    )
)


PolicyType(
    "DeploymentUpgradePolicy",
    template=template,
    PolicyName="DeploymentUpgradePolicy",
    Users=[user],
    PolicyDocument=dict(
        Statement=[dict(
            Effect="Allow",
            Action=[
                "ec2:Describe*",
                "ecs:*",
                "iam:PassRole",
                "rds:Describe*",
                "cloudfront:GetDistribution",
                "elasticloadbalancing:Describe*",
                "logs:DescribeLogGroups",
            ],
            Resource="*",
        )],
    )
)


template.add_output(Output(
    "DeploymentAccessKeyID",
    Description="The deployment access key",
    Value=Ref(key),
))


template.add_output(Output(
    "DeploymentSecretKey",
    Description="The deployment secret key",
    Value=GetAtt(key, "SecretAccessKey"),
))

The ECR repository also need to allow the new deployment user to manage images:

from troposphere import (
    AWS_ACCOUNT_ID,
    AWS_REGION,
    Join,
    Ref,
    Output,
)
from troposphere.ecr import Repository
from awacs.aws import (
    Allow,
    Policy,
    AWSPrincipal,
    Statement,
)
import awacs.ecr as ecr

from .template import template
from .deployment import user


# Create an `ECR` docker repository
repository = Repository(
    "ApplicationRepository",
    template=template,
    RepositoryPolicyText=Policy(
        Version="2008-10-17",
        Statement=[
            Statement(
                Sid="AllowPushPull",
                Effect=Allow,
                Principal=AWSPrincipal([
                    Join("", [
                        "arn:aws:iam::",
                        Ref(AWS_ACCOUNT_ID),
                        ":user/",
                        user,
                    ]),
                ]),
                Action=[
                    ecr.GetDownloadUrlForLayer,
                    ecr.BatchGetImage,
                    ecr.BatchCheckLayerAvailability,
                    ecr.PutImage,
                    ecr.InitiateLayerUpload,
                    ecr.UploadLayerPart,
                    ecr.CompleteLayerUpload,
                ],
            ),
        ]
    ),
)


# Output ECR repository URL
template.add_output(Output(
    "RepositoryURL",
    Description="The docker repository URL",
    Value=Join("", [
        Ref(AWS_ACCOUNT_ID),
        ".dkr.ecr.",
        Ref(AWS_REGION),
        ".amazonaws.com/",
        Ref(repository),
    ]),
))

Setting up the Gitlab CD/CI pipeline

First of all, as we don’t want to share it in our codebase, we need to setup target environment configs within Gitlab to have them exposed as env vars.

We simply go to settings > CI/CD Pipelines within the project web UI and then set them to something like:

Gitlab configs

Then we need to describe our continuous deployment pipeline within a .gitlab-ci.yml file:

image: docker:dind

stages:
    - build
    - test
    - staging

variables:
    DOCKER_DRIVER: overlay
    DOCKER_HOST: tcp://docker:2375

    # The commit image to push to Gitlab repository
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

    # The latest image in Gitlab repository
    LATEST_IMAGE: $CI_REGISTRY_IMAGE:latest

    # Postgres configuration
    POSTGRES_DB: app_test
    POSTGRES_USER: runner
    POSTGRES_PASSWORD: ""
    DATABASE_URL: postgresql+psycopg2://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:5432/app

    # The stack template
    STACK_TEMPLATE: stack.services.application.template

    # The AWS region
    STAGING_AWS_REGION: $STAGING_AWS_REGION

    # The AWS cloudformation stack name
    STAGING_AWS_STACKNAME: $STAGING_AWS_STACKNAME

    # The AWS ECR repository
    STAGING_ECR_REPOSITORY: $STAGING_ECR_REPOSITORY

    # The AWS deployement user access key
    STAGING_AWS_ACCESS_KEY_ID: $STAGING_AWS_ACCESS_KEY_ID
    STAGING_AWS_SECRET_ACCESS_KEY: $STAGING_AWS_SECRET_ACCESS_KEY

services:
    - docker:dind

build:
    stage: build
    script:
        - docker info

        # Login to Gitlab registry
        - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com

        # Pull latest image from Gitlab registry (if exists)
        # to re-use existing layers from previous build
        - set +e && docker pull $LATEST_IMAGE && set -e

        # Build commit image
        - docker build --cache-from $LATEST_IMAGE -t $IMAGE_TAG .

        # Push commit image
        - docker push $IMAGE_TAG

        # Push commit image as latest
        - docker tag $IMAGE_TAG $LATEST_IMAGE
        - docker push $LATEST_IMAGE

test:
    stage: test
    image: $IMAGE_TAG

    services:
        - docker:dind
        - postgres:latest

    script:
        # Run the test suite within commit image
        - entries/test.sh

staging:
    stage: staging
    type: deploy

    script:
        # Login to Gitlab registry
        - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.gitlab.com

        # Pull commit image
        - docker pull $IMAGE_TAG

        # Install python requirements
        - apk update
        - apk upgrade
        - apk add python python-dev py-pip

        # AWS configs
        - export AWS_REGION=$STAGING_AWS_REGION
        - export AWS_ACCESS_KEY_ID=$STAGING_AWS_ACCESS_KEY_ID
        - export AWS_SECRET_ACCESS_KEY=$STAGING_AWS_SECRET_ACCESS_KEY

        # The ECR image tag
        - export ECR_IMAGE_TAG=$STAGING_ECR_REPOSITORY:$CI_COMMIT_SHA

        # Push the commit image to ECR repository
        - pip install awscli
        - "`aws ecr get-login --region $AWS_REGION`"
        - docker tag $IMAGE_TAG $ECR_IMAGE_TAG
        - docker push $ECR_IMAGE_TAG

        # Update the CloudFormation stack to user new image
        - pip install troposphere-cli awacs
        - trop update $STAGING_AWS_STACKNAME -p WebAppRevision $CI_COMMIT_SHA --iam --tail

    only:
        # Only deploy staging branch
        - staging

    environment:
        name: staging

From there, each commit on staging branch will trigger a deployment pipeline to our ECS tasks:

Gitlab pipeline

Cheers,