{"id":4476,"date":"2022-05-27T13:58:00","date_gmt":"2022-05-27T11:58:00","guid":{"rendered":"https:\/\/blog.besharp.it\/?p=4476"},"modified":"2022-06-30T15:29:48","modified_gmt":"2022-06-30T13:29:48","slug":"a-serverless-approach-for-gitlab-integration-on-aws","status":"publish","type":"post","link":"https:\/\/blog.besharp.it\/a-serverless-approach-for-gitlab-integration-on-aws\/","title":{"rendered":"A serverless approach for GitLab integration on AWS"},"content":{"rendered":"\n

Cost optimization and operational efficiency are key value drivers for a successful Cloud adoption path; using managed serverless services significantly lowers maintenance costs while speeding up operations.<\/p>\n\n\n\n

In this article, you’ll find how to better integrate GitLab pipelines on AWS using ECS Fargate in a multi-environment scenario.<\/p>\n\n\n\n

GitLab offers a lot of flexibility for computational resources: pipelines can run on Kubernetes clusters, Docker, on-premise, or custom platforms using GitLab custom executor drivers.<\/p>\n\n\n\n

The tried and tested solution to run pipelines on the AWS Cloud uses EC2 instances as computational resources. <\/p>\n\n\n\n

This approach leads to some inefficiency: starting instances on-demand will make pipeline executions slower and developers impatient (because of the initialization time). Keeping a spare runner available for builds, on the other hand, will increase costs.
<\/p>\n\n\n\n

We want to find a solution that can reduce execution time, ease maintenance and optimize costs.<\/p>\n\n\n\n

Containers have a faster initialization time and help decrease costs: billing will be based only on used build time. Our goal is to use them for our pipeline executions, they will run on ECS clusters. Additionally, we will see how to use ECS Services for autoscaling.<\/p>\n\n\n\n

Before describing our implementation, we need to know a few things: GitLab Runners are software agents that can execute pipeline scripts. We can configure a runner instance to manage the pipeline’s computational resources autoscaling by adding or removing capacity as demand for build capacity changes.<\/p>\n\n\n\n

In our scenario, we\u2019ll also assume that we have three different environments: development, staging, and production: we’ll define different IAM roles for our runners, so they will use the least privilege available to build and deploy our software.<\/p>\n\n\n\n

GitLab Runners have associated tags that help choose the environment that will run the execution step when defined in a pipeline.<\/p>\n\n\n\n

In this example, you can see a pipeline that builds and deploys in different environments:<\/p>\n\n\n\n

stages: \n - build dev\n - deploy dev \n - build staging\n - deploy staging\n - build production\n - deploy production\n \nbuild-dev: \n stage: build dev \n tags: \n   - dev \n script: \n   - .\/scripts\/build.sh\n artifacts: \n   paths: \n     - .\/artifacts\n   expire_in: 7d \n  \ndeploy-dev: \n stage: deploy dev \n tags: \n   - dev \n script: \n   - .\/scripts\/deploy.sh\n\nbuild-staging: \n stage: build staging\n tags: \n   - staging\n script: \n   - .\/scripts\/build.sh\n artifacts: \n   paths: \n     - .\/artifacts\n   expire_in: 7d \n\n deploy-staging: \n stage: deploy staging\n tags: \n   - staging\n script: \n   - .\/scripts\/deploy.sh\n\nbuild-production: \n stage: build production\n tags: \n   - production\n script: \n   - .\/scripts\/build.sh\n artifacts: \n   paths: \n     - .\/artifacts\n   expire_in: 7d \n\n deploy-production: \n stage: deploy production\n tags: \n   - production\n script: \n   - .\/scripts\/deploy.sh<\/code><\/pre>\n\n\n\n

Making a base Fargate runner<\/h2>\n\n\n\n

Let’s assume that our codebase uses NodeJS: we can build a custom generic Docker image with all the dependencies (including GitLab runner).<\/p>\n\n\n\n

Dockerfile<\/strong><\/p>\n\n\n\n

FROM ubuntu:20.04 \n \n# Ubuntu based GitLab runner with nodeJS, npm, and aws CLI \n# --------------------------------------------------------------------- \n# Install https:\/\/github.com\/krallin\/tini - a very small 'init' process \n# that helps process signals sent to the container properly. \n# --------------------------------------------------------------------- \nARG TINI_VERSION=v0.19.0 \n \nCOPY docker-entrypoint.sh \/usr\/local\/bin\/docker-entrypoint.sh \n \nRUN ln -snf \/usr\/share\/zoneinfo\/Europe\/Rome \/etc\/localtime && echo Europe\/Rome > \/etc\/timezone \\ \n   && echo \"Installing base packaes\" \\ \n   && apt update && apt install -y curl gnupg unzip jq software-properties-common \\ \n   && echo \"Installing awscli\" \\ \n   && curl \"https:\/\/awscli.amazonaws.com\/awscli-exe-linux-x86_64.zip\" -o \"awscliv2.zip\" \\ \n   && unzip awscliv2.zip \\ \n   && .\/aws\/install \\ \n   && rm -f awscliv2.zip \\ \n   && apt update \\ \n   && echo \"Installing packages\" \\ \n   && apt install -y unzip openssh-server ca-certificates git git-lfs nodejs npm \\ \n   && echo \"Installing tini and ssh\" \\ \n   && curl -Lo \/usr\/local\/bin\/tini https:\/\/github.com\/krallin\/tini\/releases\/download\/${TINI_VERSION}\/tini-amd64 \\ \n   && chmod +x \/usr\/local\/bin\/tini \\ \n   && mkdir -p \/run\/sshd \\ \n   && curl -L https:\/\/packages.gitlab.com\/install\/repositories\/runner\/gitlab-runner\/script.deb.sh | bash \\ \n       && apt install -y gitlab-runner \\ \n       && rm -rf \/var\/lib\/apt\/lists\/* \\ \n       && rm -f \/home\/gitlab-runner\/.bash_logout \\ \n   && git lfs install --skip-repo \\ \n   && chmod +x \/usr\/local\/bin\/docker-entrypoint.sh \\ \n   && echo \"Done\"\n\nEXPOSE 22 \n \nENTRYPOINT [\"tini\", \"--\", \"\/usr\/local\/bin\/docker-entrypoint.sh\"]<\/code><\/pre>\n\n\n\n

docker-entrypoint.sh<\/strong><\/p>\n\n\n\n

#!\/bin\/sh \n \n# Create a folder to store the user's SSH keys if it does not exist. \nUSER_SSH_KEYS_FOLDER=~\/.ssh \n[ ! -d ${USER_SSH_KEYS_FOLDER} ] && mkdir -p ${USER_SSH_KEYS_FOLDER} \n \n# Copy contents from the `SSH_PUBLIC_KEY` environment variable \n# to the `$USER_SSH_KEYS_FOLDER\/authorized_keys` file. \n# The environment variable must be set when the container starts. \necho \"${SSH_PUBLIC_KEY}\" > ${USER_SSH_KEYS_FOLDER}\/authorized_keys \n \n# Clear the `SSH_PUBLIC_KEY` environment variable. \nunset SSH_PUBLIC_KEY \n \n# Start the SSH daemon \n\/usr\/sbin\/sshd -D<\/code><\/pre>\n\n\n\n

As you can see, there’s no environment-dependent configuration. <\/p>\n\n\n\n

Building a Runner for autoscaling (formerly Runner Manager)<\/strong><\/p>\n\n\n\n

This runner instance needs to be specialized to handle the environment configuration; we’ll use the Fargate Custom Executor provided by GitLab to interact and use different ECS Fargate Clusters for different environments.<\/p>\n\n\n\n

We’ll automatically handle our runner registration with the GitLab server during the Docker build phase by specifying its token using variables.<\/p>\n\n\n\n

Our Fargate custom executor will need a configuration file (“config.toml”) to specify a cluster, subnets, security groups, and task definition for our pipeline execution. We\u2019ll also handle this customization at build time.<\/p>\n\n\n\n

First, we need to get a registration token from our GitLab server: <\/p>\n\n\n\n

Go to your project CI\/CD settings and expand the “Runners\u201d section.<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

Copy the registration token and GitLab server address<\/p>\n\n\n\n

You can embed the GitLab server address in your DockerFile; we’ll treat the registration token as a secret.<\/p>\n\n\n\n

As you\u2019ll see below, these lines will customize our configuration file:<\/p>\n\n\n\n

RUNNER_TASK_TAGS=$(echo ${RUNNER_TAGS} | tr \",\" \"-\") \nsed -i s\/RUNNER_TAGS\/${RUNNER_TASK_TAGS}\/g \/tmp\/ecs.toml \nsed -i s\/SUBNET\/${SUBNET}\/g \/tmp\/ecs.toml\nsed -i s\/SECURITY_GROUP_ID\/${SECURITY_GROUP_ID}\/g \/tmp\/ecs.toml<\/code><\/pre>\n\n\n\n

DockerFile<\/strong><\/p>\n\n\n\n

FROM ubuntu:20.04 \n \nARG GITLAB_TOKEN \nARG RUNNER_TAGS \nARG GITLAB_URL=\"https:\/\/gitlab.myawesomecompany.com\" \nARG SUBNET\nARG SECURITY_GROUP_ID \n \n \nCOPY config.toml \/tmp\/ \nCOPY ecs.toml \/tmp\/ \nCOPY entrypoint \/ \nCOPY fargate-driver \/tmp \n \n \nRUN apt update && apt install -y curl unzip \\ \n       && curl -L https:\/\/packages.gitlab.com\/install\/repositories\/runner\/gitlab-runner\/script.deb.sh | bash \\ \n       && apt install -y gitlab-runner \\ \n       && rm -rf \/var\/lib\/apt\/lists\/* \\ \n       && rm -f \"\/home\/gitlab-runner\/.bash_logout\" \\ \n       && chmod +x \/entrypoint \\ \n       && mkdir -p \/opt\/gitlab-runner\/metadata \/opt\/gitlab-runner\/builds \/opt\/gitlab-runner\/cache \\ \n       && curl -Lo \/opt\/gitlab-runner\/fargate https:\/\/gitlab-runner-custom-fargate-downloads.s3.amazonaws.com\/latest\/fargate-linux-amd64 \\ \n       && chmod +x \/opt\/gitlab-runner\/fargate \\ \n       && RUNNER_TASK_TAGS=$(echo ${RUNNER_TAGS} | tr \",\" \"-\") \\ \n       && sed -i s\/RUNNER_TAGS\/${RUNNER_TASK_TAGS}\/g \/tmp\/ecs.toml \\ \n       && sed -i s\/SUBNET\/${SUBNET}\/g \/tmp\/ecs.toml \\ \n       && sed -i s\/SECURITY_GROUP_ID\/${SECURITY_GROUP_ID}\/g \/tmp\/ecs.toml \\ \n       && cp \/tmp\/ecs.toml \/etc\/gitlab-runner\/ \\ \n       && echo \"Token: ${GITLAB_TOKEN} url: ${GITLAB_URL} Tags: ${RUNNER_TAGS}\" \\ \n       && gitlab-runner register \\ \n               --non-interactive \\ \n               --url ${GITLAB_URL} \\ \n               --registration-token ${GITLAB_TOKEN} \\ \n               --template-config \/tmp\/config.toml \\ \n               --description \"GitLab runner for ${RUNNER_TAGS}\" \\ \n               --executor \"custom\" \\ \n               --tag-list ${RUNNER_TAGS} \n \nENTRYPOINT [\"\/entrypoint\"] \nCMD [\"run\", \"--user=gitlab-runner\", \"--working-directory=\/home\/gitlab-runner\"]<\/code><\/pre>\n\n\n\n

We can build our runner manager using: <\/p>\n\n\n\n

docker build . -t gitlab-runner-autoscaling --build-arg GITLAB_TOKEN=\"generatedgitlabtoken\" --build-arg RUNNER_TAGS=\"dev\" --build-arg SUBNET=\"subnet-12345\" --build-arg SECURITY_GROUP_ID=\"sg-12345\"<\/code><\/pre>\n\n\n\n

When Docker build finishes, you can see runner registration.<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

config.toml<\/strong><\/p>\n\n\n\n

concurrent = 1 \ncheck_interval = 0 \n \n[session_server] \n session_timeout = 1800 \n \n[[runners]] \n name = \"ec2-ecs\" \n executor = \"custom\" \n builds_dir = \"\/opt\/gitlab-runner\/builds\" \n cache_dir = \"\/opt\/gitlab-runner\/cache\" \n [runners.cache] \n   [runners.cache.s3] \n   [runners.cache.gcs] \n [runners.custom] \n   config_exec = \"\/opt\/gitlab-runner\/fargate\" \n   config_args = [\"--config\", \"\/etc\/gitlab-runner\/ecs.toml\", \"custom\", \"config\"] \n   prepare_exec = \"\/opt\/gitlab-runner\/fargate\" \n   prepare_args = [\"--config\", \"\/etc\/gitlab-runner\/ecs.toml\", \"custom\", \"prepare\"] \n   run_exec = \"\/opt\/gitlab-runner\/fargate\" \n   run_args = [\"--config\", \"\/etc\/gitlab-runner\/ecs.toml\", \"custom\", \"run\"] \n   cleanup_exec = \"\/opt\/gitlab-runner\/fargate\" \n   cleanup_args = [\"--config\", \"\/etc\/gitlab-runner\/ecs.toml\", \"custom\", \"cleanup\"]<\/code><\/pre>\n\n\n\n

ecs.toml<\/strong><\/p>\n\n\n\n

LogLevel = \"info\" \nLogFormat = \"text\" \n \n[Fargate] \n Cluster = \"acme-gitlab-RUNNER-TAGS-cluster\" \n Region = \"eu-west-1\" \n Subnet = \"SUBNET\"\n SecurityGroup = \"SECURITY_GROUP_ID\"\n TaskDefinition = \"gitlab-runner-RUNNER_TAGS-task\" \n EnablePublicIP = false \n \n[TaskMetadata] \n Directory = \"\/opt\/gitlab-runner\/metadata\" \n \n[SSH] \n Username = \"root\" \n Port = 22<\/code><\/pre>\n\n\n\n

entrypoint<\/strong><\/p>\n\n\n\n

!\/bin\/bash\n\n# gitlab-runner data directory\nDATA_DIR=\"\/etc\/gitlab-runner\"\nCONFIG_FILE=${CONFIG_FILE:-$DATA_DIR\/config.toml}\n# custom certificate authority path\nCA_CERTIFICATES_PATH=${CA_CERTIFICATES_PATH:-$DATA_DIR\/certs\/ca.crt}\nLOCAL_CA_PATH=\"\/usr\/local\/share\/ca-certificates\/ca.crt\"\n\nupdate_ca() {\n  echo \"Updating CA certificates...\"\n  cp \"${CA_CERTIFICATES_PATH}\" \"${LOCAL_CA_PATH}\"\n  update-ca-certificates --fresh >\/dev\/null\n}\n\nif [ -f \"${CA_CERTIFICATES_PATH}\" ]; then\n  # update the ca if the custom ca is different than the current\n  cmp --silent \"${CA_CERTIFICATES_PATH}\" \"${LOCAL_CA_PATH}\" || update_ca\nfi\n\n# launch gitlab-runner passing all arguments\nexec gitlab-runner \"$@\"<\/code><\/pre>\n\n\n\n

We can now push our Docker images to ECR repositories (we’ll use gitlab-runner and gitlab-runner-autoscaling as repository names); please refer to ECR documentation for push commands.<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

<\/p>\n\n\n\n

Once we finish pushing, we can proceed to define task definitions. <\/p>\n\n\n\n

We’ll describe our configuration for the development environment only; configuration steps will be the same for every environment.<\/p>\n\n\n\n

You can find a complete guide on creating ECR repositories, task definitions, and services here<\/a>:<\/p>\n\n\n\n

We will configure task definitions for runners in our environments (gitlab-runner-dev-task, gitlab-runner-stage-task, gitlab-runner-prod-task).<\/p>\n\n\n\n

Please note that the runner task definition has to define a container using \u201cci-coordinator<\/strong>\u201d as the container name. You also need to define a port mapping for runner task definition for port 22 and a security group that accepts inbound connections on port 22: GitLab will use an ssh connection to execute the pipeline.<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

Once we have defined our runner task definition, we can proceed to configure the task definition for autoscaling.<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

We then need to configure an ECS Service that keeps our runner alive.<\/p>\n\n\n\n

\"\"

<\/figcaption><\/figure>\n\n\n\n

<\/p>\n\n\n\n

And then define a role with an associated policy to start and terminate tasks on our ECS cluster for the task role.<\/p>\n\n\n\n

{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Sid\": \"AllowRunTask\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"ecs:RunTask\",\n                \"ecs:ListTasks\",\n                \"ecs:StartTask\",\n                \"ecs:StopTask\",\n                \"ecs:ListContainerInstances\",\n                \"ecs:DescribeTasks\"\n            ],\n            \"Resource\": [\n                \"arn:aws:ecs:eu-west-1:account-id:task\/acme-gitlab-dev-cluster\/*\",\n                \"arn:aws:ecs:eu-west-1:account-id:cluster\/acme-gitlab-dev-cluster\",\n                \"arn:aws:ecs:eu-west-1:account-id:task-definition\/*:*\",\n                \"arn:aws:ecs:*:account-id:container-instance\/*\/*\"\n            ]\n        },\n        {\n            \"Sid\": \"AllowListTasks\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"ecs:ListTaskDefinitions\",\n                \"ecs:DescribeTaskDefinition\"\n            ],\n            \"Resource\": \"*\"\n        }\n    ]\n}<\/code><\/pre>\n\n\n\n

After a minute, our runner service will be ready:<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

We can now define a test execution pipeline in .gitlab-ci.yml<\/em>: <\/p>\n\n\n\n

test:\n  tags:\n    - dev\n  script:\n  - echo \"It works!\"\n  - for i in $(seq 1 30); do echo \".\"; sleep 1; done<\/code><\/pre>\n\n\n\n

Our runner will run a new task when you execute the pipeline:<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

The task will run, and pipeline execution will start:<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n
\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

And, as you can see, execution is successful!<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n
\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

Once the pipeline execution finishes, our container terminates, and our build container ends.<\/p>\n\n\n\n

Troubleshooting<\/strong><\/p>\n\n\n\n

If you get a timeout error, verify your security groups definition and routing from the subnets to the ECR repositories (if you use private subnets). If you use isolated subnets, provide a VPC endpoint for ECR service
If you receive the error: “starting new Fargate task: running new task on Fargate: error starting AWS Fargate Task: InvalidParameterException: No Container Instances were found in your cluster<\/em>.” verify that you have set a default capacity provider for your ECS Cluster (click on “Update Cluster” and select a capacity provider)<\/p>\n\n\n\n

\"\"<\/figure>\n\n\n\n

<\/p>\n\n\n\n

Today we explored a serverless approach for running GitLab pipelines, scratching only the surface. There’s a lot more to explore: Spot Container Instances, cross-account build and deploy, and different architectures (ARM and Windows, anyone?).<\/p>\n\n\n\n

Do you already have a strategy for optimizing your builds? Have you already tinkered with custom executors for GitLab pipelines? Let us know in the comments!\u00a0<\/p>\n\n\n\n


\n\n\n\n

Resources:<\/p>\n\n\n\n