February 16th, 2021
Deploying GitLab Runners on EC2
By Anthony Roberson

What is CICD?

If you work in the Information Technology field today, you have more than likely heard the term Continuous Integration, Continuous Deployment/Delivery; aka CICD. This process is a modern take on the application deployment paradigm in which frequent, small code changes are made to your application in an automated fashion, often including testing and QA. Nowadays, there are dozens of companies that have specialized tools for handling CICD. The process of selecting the correct tool for each component can be challenging with so many options available and adding tools from separate software companies can add extra complexity to your deployment processes. AWS offers many tools of this kind and can be set-up using all native AWS resources. However, many companies prefer to use more robust source code repositories. Enter GitLab.

GitLab

One example of a source code repository that is robust and feature-rich is GitLab. When using Gitlab for your source code repository, it is wise to take advantage of the continuous integration services offered. These services allow you to deploy your code and resources in an automated fashion, allowing you to focus more on your application code itself. Gitlab has these services and capabilities built into its functionality, but there are several steps involved in setting up your repository to take advantage of them. To do so, you will have to add a .gitlab-ci.yml file in your repository which will define your integration setup, and your project will need to be configured to use a Runner. This post will detail setting up your repository and Runners using Amazon Elastic Compute Cloud (EC2) service, which will be used to run your CICD jobs and deploy your application and resources into your AWS account(s).

This set-up can be parameterized in order to be used to deploy to multiple accounts, one per branch in your repo. Our .gitlab-ci file will be relatively simple, for more information on how to set-up the file for more complicated tasks, visit https://docs.gitlab.com/ee/ci/yaml/.

Origin Story

 What is a Gitlab Runner, you ask?

“GitLab Runner is the open-source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with GitLab CI/CD, the open-source continuous integration service included with GitLab that coordinates the jobs,” according to Gitlab Docs.

There are several ways these Gitlab runners can be created and managed. Visit https://docs.gitlab.com/runner/ for more information on all the options. We will be using CloudFormation and installing Docker to promote the use of infrastructure as code for our deployments—automating the build process for these runners.

Prerequisites

  1. A Gitlab account and source code repository
  2. An AWS account with console access and appropriate permissions to create roles/users
  3. A VPC with private subnet to deploy the runners into

Getting Started

In the AWS Console

First, we will create IAM resources: a user and several policies in the AWS console for Gitlab. This will allow the runner to be able to assume roles in the account we want to deploy our resources to. In the AWS console, navigate to the IAM service and select “Policies” on the left pane and select “Create Policy.” Use the ”JSON” tab and paste the following code, replacing the account number with yours (no braces).

 {
	"Version": "2012-10-17",
	"Statement": {
    	"Effect": "Allow",
    	"Action": [
        	"iam:GetRole",
        	"iam:PassRole",
        	"sts:AssumeRole"
    	],
    	"Resource": [
        	"arn:aws:iam::{YourAccountNumber}:role/gitlab-runner-role",
        	"arn:aws:iam::{YourAccountNumber}:role/gitlab-shared-runner-iam-role"
    	]
	}
}

Hit “Review policy” name it “assume-role-policy” and finish creating it.

Next select “Add User” and name it something like “gitlab” and check the “Programmatic Access” box, then hit “Next:Permissions.” On the Set Permissions section, select “Attach existing policies directly” and type “assume” into the filter policies search box. Check the box associated with the assume-role-policy we just created, then click through the rest of the steps and create the user.

This guide assumes you already have a VPC and Subnet created, so navigate to the VPC service and copy your VPC and Subnet ID for later.

We also need the ID for Amazon’s Linux 2 AMI. Navigate to the EC2 service and select “Launch Instance.” Copy the value highlighted below:

In Gitlab

Create a new repository specifically for your runner code; you can name it something like “shared-runners.” In your repo, you will be creating three files:

  1. .Gitlab-ci.yml file – Contains the integration code for deploying your runners to AWS
  2. shared-runner.json – this will be a simple JSON file used to pass in environment-specific variables at run-time
  3. shared-runner.template – CloudFormation template file for the actual EC2 and associated resources

Gitlab requires a token during runner set-up, which can be accessed in the Settings section of your repository. On the left-pane, navigate to Settings->CI/CD, and click “Expand” on the “Runners” tab. Copy the token value underlined in yellow in the “Set Up a Specific Runner manually” section:

Save this for later, as we will be including it in the user-data section of our CloudFormation template.

Once the Gitlab user is created in the console, copy the AWS Access Key ID and Secret Access Key. Navigate again on the left pane to “Settings->CICD” and expand the “Variables” section. Gitlab has sets of pre-defined variables and knows what the values are and how to use them. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are predefined, and Gitlab will use these values when running your jobs if they are specified.

Input these variables as the Key and paste your respective AWS keys in the Value section like so:

Selecting the “Mask Variable” box will keep unauthorized parties from viewing the values as seen above.

Creating the Files

CloudFormation

Now we will create the CloudFormation template for our GitLab Runners. Included will be all the resources Gitlab needs to be able to deploy and manage the runners. This includes an Auto-Scaling Group, Runner deploy role, Security group, and Instance Profile. Using your favorite IDE, create a new file named “shared-runner.template” and paste the following code:

AWSTemplateFormatVersion: "2010-09-09"
Description: "Shared gitlab runner"

# Parameters section - read from the shared-runner.json file
Parameters:
  Environment:
	Type: String
	Default: dev
  VpcId:
	Type: String
	Default: vpc-0xxxxxxxxxxxxxxx2
  PrivateSubnetId:
	Type: String
	Default: subnet-0xxxxxxxxxxxxxxx9
  InstanceSize:
	Type: String
	Default: t3.medium
  AmiId:
	Type: String
	Default: ami-0xxxxxxxxxxxxxxxc

# Creation of an autoscaling group for the shared runners
Resources:
  RunnerLaunchConfig:
	Type: AWS::AutoScaling::LaunchConfiguration
	Properties:
  	ImageId: !Ref AmiId
  	InstanceType: !Ref InstanceSize
  	IamInstanceProfile: !Ref ServerInstanceProfile
  	BlockDeviceMappings:
    	- DeviceName: "/dev/xvda"
      	Ebs: { "VolumeSize": "150" }
  	UserData:
    	Fn::Base64: !Sub |
      	#!/bin/bash -xe
      	exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1
      	sudo su -
      	yum update -y
      	amazon-linux-extras install docker
      	sleep 10s
      	service docker start
      	systemctl enable docker
      	sleep 10s
      	wget -O /usr/local/bin/gitlab-runner "https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64"
      	sleep 30s
      	chmod +x /usr/local/bin/gitlab-runner
      	useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash
      	cd /usr/local/bin
      	gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner
      	gitlab-runner start
      	gitlab-runner register --non-interactive --url "https://gitlab.com/" --registration-token "yourTokenGoesHere" --executor "docker" --docker-image "alpine:latest" --locked="false" --tag-list "shared-runner,docker,us-west-2,master" --description "gitlab-shared-runner"

  	SecurityGroups:
    	- !Ref InstanceSecurityGroup

  RunnerAutoScaling:
	Type: AWS::AutoScaling::AutoScalingGroup
	Properties:
  	AutoScalingGroupName: !Sub GitLab-Shared-Runner-${Environment}
  	AvailabilityZones: [us-west-2a]
  	DesiredCapacity: 1
  	LaunchConfigurationName: !Ref RunnerLaunchConfig
  	VPCZoneIdentifier:
    	- !Ref PrivateSubnetId
  	HealthCheckType: EC2
  	MaxSize: 1
  	MinSize: 1
  	Tags:
    	- Key: Name
      	PropagateAtLaunch: true
      	Value: !Sub gitlab-shared-runner-${Environment}

  SharedRunnerIamRole:
	Type: AWS::IAM::Role
	Properties:
  	RoleName: "gitlab-shared-runner-iam-role"
  	AssumeRolePolicyDocument:
    	Version: "2012-10-17"
    	Statement:
      	- Effect: "Allow"
        	Principal:
          	Service:
            	- "ec2.amazonaws.com"
        	Action:
          	- "sts:AssumeRole"
  	Policies:
    	- PolicyName: "SharedRunnerPipelineAccess"
      	PolicyDocument:
        	Version: "2012-10-17"
        	Statement:
          	- Sid: "GeneralServices"
            	Effect: "Allow"
            	Action:
              	- ssm:*
              	- dynamodb:*
              	- kms:*
              	- sns:*
              	- sqs:*
              	- cloudwatch:*
              	- apigateway:*
              	- cloudformation:*
              	- iam:getrole
              	- iam:createrole
              	- iam:updaterole
              	- iam:AttachRolePolicy
              	- iam:UpdateAssumeRolePolicy
              	- iam:CreateServiceLinkedRole
              	- iam:CreateInstanceProfile
              	- iam:DeleteInstanceProfile
              	- iam:AddRoleToInstanceProfile
              	- iam:RemoveRoleFromInstanceProfile
              	- iam:ListInstanceProfilesForRole
              	- iam:PutRolePolicy
              	- iam:DetachRolePolicy
              	- iam:DeleteRolePolicy
              	- iam:DeleteRole
              	- iam:PassRole
              	- iam:get*
              	- logs:*
              	- lambda:*
              	- s3:*
              	- ec2:*
              	- rds:*
              	- ecr:*
              	- eks:*
              	- ecs:*
              	- glue:*
              	- elasticloadbalancing:*
              	- route53:Get*
              	- route53:ChangeResourceRecordSets
              	- route53:List*
              	- kinesis:*
              	- firehose:*
              	- events:*
              	- lakeformation:*
              	- states:*
              	- secretsmanager:*
              	- appsync:*
              	- autoscaling:*
            	Resource: "*"
  	ManagedPolicyArns:
    	- "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
   	 
  ServerInstanceProfile:
	Type: AWS::IAM::InstanceProfile
	Properties:
  	Roles:
    	- !Ref SharedRunnerIamRole

  InstanceSecurityGroup:
	Type: AWS::EC2::SecurityGroup
	Properties:
  	GroupDescription: "Gitlab shared runner SG"
  	SecurityGroupIngress:
    	- IpProtocol: tcp
      	FromPort: 22
      	ToPort: 22
      	CidrIp: 10.0.0.0/16
  	VpcId: !Ref VpcId

Replace the Parameter values with the VPC, Subnet, and AMI IDs you copied in the AWS console. Input your token in the userdata “yourTokenGoesHere” section of the template on line 51:

gitlab-runner register --non-interactive --url "https://gitlab.com/" --registration-token "yourTokenGoesHere" --executor "docker" --docker-image "alpine:latest" --locked="false" --tag-list "shared-runner,docker,us-west-2,dev" --description "gitlab-shared-runner"

Also note the –tag-list parameter and associated values (shared-runner,docker,us-west-2,dev); this is how GitLab tags its runners to be chosen later in the .gitlab-ci.yml file. Based on the tags for this particular example, it will be a shared-runner, the runner will have the docker agent installed, be deployed in the us-west-2 region, and in the dev environment.

You can also add and remove any of the permissions listed in the SharedRunnerPipelineAccess policy.

JSON Parameters file

Now let’s create the JSON parameters file in which we can pass in environment-specific variables at creation time. Name the file “shared-runner.json” and paste the following code, replacing the values with the ones copied in previous steps:

[
    { "ParameterKey": "Environment", "ParameterValue": "dev" },
    { "ParameterKey": "VpcId", "ParameterValue": "vpc-0xxxxxxxxxxxxxxxe" },
    { "ParameterKey": "PrivateSubnetId", "ParameterValue": "subnet-0xxxxxxxxxxxxxxx6" },
    { "ParameterKey": "AmiId", "ParameterValue": "ami-0xxxxxxxxxxxxxxxb" },
    { "ParameterKey": "InstanceSize", "ParameterValue": "t3.medium" }
]

This step and file are necessary for deploying to multiple environments, as these parameters will override the CloudFormation parameters at run-time.

In the .gitlab-ci.yml File

Now we will create the gitlab CICD file which is responsible for running our builds and deploying our resources. Once created in the root of your repo, Gitlab will automatically recognize this file and run it based on your specifications. Create a file named .gitlab-ci.yml and paste the following code replacing YourAccountHere in the ROLEARN variable with your own account number:

stages:
  - sharedrunners
  
variables:
  ROLEARN: arn:aws:iam::YourAccountHere:role/gitlab-runner-role

Update_sharedrunners:
  stage: sharedrunners
  image: python:latest
  before_script:
    - pip install awscli
  script:
    - aws cloudformation create-stack --stack-name gitlab-shared-runners --role-arn $ROLEARN --template-body file://shared-runner.template --parameters file://shared-runner.json --capabilities CAPABILITY_NAMED_IAM  --region us-west-2
  only:
    refs:
      - dev
    changes:
      - "shared-runner.*"
  tags:
    - docker
    - us-west-2
    - dev

Note that the use of tags here is not tagging the AWS resources created. Gitlab uses these tags to select which runner to use to run the build as mentioned earlier. In the example above, gitlab will choose a runner that has the Docker agent running and deploys to us-west-2 in the dev environment.

When we commit this file to our Gitlab repo, it will automatically kick-off the sharedrunners stage and run the associated scripts. If all goes well, an Auto-Scaling group of GitLab runners and associated resources will be deployed in the region and account specified in the template and CI file.

You can now use the runner to deploy code to your AWS account without having to worry about permissions and roles in your project’s CICD configurations, as the runner will handle this for you. If more permissions are needed, they can be added to the CloudFormation template in the shared-runners repo. Once the permissions are added and the file is committed, they will be deployed to the role in an automated fashion. Use the Tags section of your project’s .gitlab-ci.yml to select the appropriate runner for your job.

Conclusion

This process can be complicated to set-up but makes deployments much easier in the end. Your developers will be able to spend less time worrying about things like how their application is deployed, permission issues, testing, and QA, and more time focusing on bringing innovation to your applications.

If you feel the process is too complex and could use extra help, feel free to contact an AWS expert here at 1Strategy. Reach out to us at info@1strategy.com today, we would love to discuss how we can help you through this, and many more cloud-related journeys!

You can view the GitHub source code repository for this code here.