In this tutorial you will create a simple CI/CD pipeline using Jenkins, the Pipeline plugin and AWS Fargate.
- Jenkins Pipeline Tutorial
You will need the following to complete this tutorial:
You will have to push changes to Github in order to trigger the CI/CD pipeline. Therefore, before going any further in this tutorial, fork this repository and work on your own fork from now on. If you have never forked a repository, this might help.
The simplest way to get a container running on AWS is to use Fargate, so we will use a sample Fargate deployment which takes care of everything for us: VPC, SGs, IAM and the cluster itself.
NOTE: Fargate is only available on the us-east-1 region at the time of writing, so we will use this region.
Perform the following steps to prepare the Fargate environment for our deployment:
- Log in to the ECS console under the us-east-1 region (or simply click here).
- Click Get Started.
- Under Container definition, leave the sample-app option selected and click Next.
- Under Load balancer type choose Application Load Balancer and click Next.
- If you want, change the name of the cluster under Cluster name. Click Next.
- Click Create.
The cluster should now be created along with all required resources. A sample app will be automatically deployed on the cluster once created (this could take a few minutes).
Verify that the sample app works by browsing the DNS name of the load balancer. To find the DNS name you can click the link near Load balancer in the cluster creation status page, or find the load balancer in the Load Balancers view.
The pipeline you are about to create will generate Docker images, which need to be pushed to some Docker registry. Since we are on AWS we can easily use ECR for this purpose, however you may use other Docker registries as well.
To create a repository on ECR, follow these steps:
- On the Repositories section on the ECS console, click Get started or Create repository.
- Under Repository name type "sample-app" and click Next step then Done.
Make note of the Repository URI - you will need it later.
You will need a Jenkins instance for this tutorial. Perform the following steps to deploy Jenkins inside your AWS account:
- In the IAM roles view click Create role.
- Choose EC2 and click Next: Permissions.
- Check the AmazonECS_FullAccess and the AmazonEC2ContainerRegistryPowerUser policies and click Next: Review.
- Under Role name type "Jenkins" and click Create role.
NOTE: If you are lazy, you can use a pre-configured AMI with Jenkins, Git and Docker. To do so, use jenkins-docker 1524155485 (ami-fc47eb83) in step 2 and jump directly to Run the Jenkins Setup Wizard after launching the instance.
- In the EC2 console click Launch Instance.
- Select an Amazon Linux AMI.
- Leave t2.micro selected and click Next: Configure Instance Details.
- Under Network, choose any VPC with a public subnet. The default VPC will work fine here, too.
- Under Subnet, choose any public subnet.
- Under Auto-assign Public IP choose Enabled.
- Under IAM role choose the Jenkins role you created before.
- Click Next: Add Storage.
- Click Next: Add Tags.
- Create a tag with the key "Name" and the value "Jenkins" and click Next: Configure Security Group.
- Name the security group "Jenkins" and allow SSH access as well as access to TCP port 8080 from your WAN IP address.
- Click Review and Launch.
- Click Launch.
- Choose an existing SSH key or create a new one, then click Launch Instances.
Note: If you've deployed Jenkins from a pre-configured AMI, go directly to Run the Jenkins Setup Wizard.
-
SSH into the instance you created in the previous step.
-
Run the following commands to install Jenkins:
sudo yum remove -y java sudo yum install -y java-1.8.0-openjdk sudo wget -O /etc/yum.repos.d/jenkins.repo http://pkg.jenkins-ci.org/redhat-stable/jenkins.repo sudo rpm --import https://jenkins-ci.org/redhat/jenkins-ci.org.key sudo yum install -y jenkins
-
Run
sudo service jenkins start
to start the Jenkins service.
-
SSH into the Jenkins instance if you are not already there.
-
Run the following commands on the Jenkins instance:
sudo yum install -y git docker sudo service docker start # Allow Jenkins to talk to the Docker daemon sudo usermod -aG docker jenkins sudo service jenkins restart
- Browse
http://<instance_ip>:8080
. - Under Administrator password enter the output of
sudo cat /var/lib/jenkins/secrets/initialAdminPassword
on the Jenkins instance. - Click Continue.
- Click Install suggested plugins and let the installation finish.
- Click Continue as admin.
- Click Start using Jenkins.
We will now create the Jenkins pipeline. Perform the following steps on the Jenkins UI:
- Click New Item to create a new Jenkins job.
- Under Enter an item name type "sample-pipeline".
- Choose Pipeline as the job type and click OK.
- Under Pipeline -> Definition choose Pipeline script from SCM.
- Under SCM choose Git.
- Under Repository URL paste the HTTPS URL of your (forked) repository.
NOTE: It is generally recommended to use Git over SSH rather than HTTPS, especially in automated processes. However, to simplify things and since the repository is public, we can simply use the HTTPS URL instead of dealing with SSH keys.
- Leave the rest at the default and click Save.
You should now have a pipeline configured. When executing the pipeline, Jenkins will clone the Git
repository, look for a file named Jenkinsfile
at its root and execute the instructions in it.
The Jenkins Pipeline plugin supports two types of piplines: declarative pipelines and scripted pipelines. Declarative pipelines are simpler and have a nice, clean syntax. Scripted pipelines, on the other hand, offer unlimited flexibility by exposing the full power of the Groovy programming language in the pipeline.
In this tutorial we will use the declarative syntax, which is more than enough for what we are trying to accomplish.
Let's take a look at the sample pipeline that is already in the repository. Open the file called
Jenkinsfile
file in a text editor (preferably one which supports the Jenkinsfile
syntax).
We can see that the entire pipeline is inside a top-level directive called pipeline
.
Then we have a line saying agent any
- this is required for declarative pipelines, but we are not
going to touch it in this tutorial. If you are still curious about what the agent directive does,
you can read about it here.
Next we have the environment
directive. This section allows us to configure global variables
which will be available (for both reading and writing) in any of the pipeline's stages. This is
useful for configuring global settings.
Lastly, we have a stage
. You can have as many stages as you want in a pipeline. A stage is a
major section of the pipeline and it contains the actual "work" which the pipeline does. This work
is defined in steps
. A step can execute a shell script, push an artifact somewhere, send an email
or a Slack message to someone and do lots of other stuff. We can see that at the moment our
pipeline doesn't do much, just prints something to the console using an echo
step.
NOTE: There is an entire list of step types which can be used in Jenknis pipelines, however in this tutorial we will keep things simple and use mostly the
sh
step, which executes a shell script.
So, now that we understand the structure of our pipeline, let's run it.
- From the top-level view on the Jenkins UI, click on the pipeline's name ("sample-pipeline").
- On the menu to the left, click Build Now.
This will trigger a run. You should see a new run (or "build") under the Build History view on
on the left side. To see the logs from the build, click the build number (#1
if this is your
first build) and the click Console Output.
If all went well, after some Git-related output you should see that the pipeline ran the only stage
we currently have, which should simply print This is a sample stage
.
Great. Now let's make the pipeline do some real stuff.
Let's add a simple CI step to our pipeline. We want to build a Docker image from our app and push it to ECR so that we can later deploy containers from it.
Let's populate the docker_repo_uri
environment variable with the full URI of the ECR repository
you created previously. It shall be similar to the following:
pipeline {
...
environment {
region = "us-east-1"
docker_repo_uri = "xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/sample-app"
task_def_arn = ""
cluster = ""
exec_role_arn = ""
}
...
}
Now, replace the "Example" stage with the following:
stage('Build') {
steps {
// Get SHA1 of current commit
script {
commit_id = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim()
}
// Build the Docker image
sh "docker build -t ${docker_repo_uri}:${commit_id} ."
// Get Docker login credentials for ECR
sh "aws ecr get-login --no-include-email --region ${region} | sh"
// Push Docker image
sh "docker push ${docker_repo_uri}:${commit_id}"
// Clean up
sh "docker rmi -f ${docker_repo_uri}:${commit_id}"
}
}
Notice that we have two types of steps here: script
and sh
. script
steps allow us to run a
Groovy code snippet inside our declarative pipeline. We need this because we want to capture the
SHA1 of the current commit and assign it to a variable, which we can then use to uniquely tag the
Docker image we are building. sh
steps are simply shell commands.
So now our pipeline should build a Docker image, push it to ECR and clean up the leftover image so that we don't accumulate garbage on Jenkins.
In order to update the pipeline, we must commit and push our changes to Github. So, when you are done editing, do the following:
- Commit your changes by running
git commit -am "Add CI step to pipeline"
. - Push your changes to Github by running
git push origin
.
Now, re-run the pipeline on Jenkins and examine its output. If all goes well, the pipeline will build a Docker image, push it to ECR and clean up the local image on Jenkins. Verify this by looking at the Repositories section of the ECS console. Your repository should now have an image in it.
Now that we have a pipeline which automatically generates Docker images for us, let's add another stage that will deploy new images to our deployment environment (Fargate).
In your editor, open the taskdef.json
file. This file defines how to deploy our app to Fargate.
It already contains everything we need except one thing: the Docker image to use for the
deployment. As you can see, at the moment the image
key contains a placeholder - {{image}}
.
This placeholder is invalid if you just try to submit it as-is to Fargate, and must be edited
first. However, we can't simply hardcode a specific Docker image here, because we want our pipeline
to every time deploy the image we just built to the environment. So, we will override this
field with the correct value in our new stage.
Before adding the stage, populate the following variables in your environment
:
task_def_arn
- should contain the ARN of the task definition Fargate has already created for
you, without the revision number (:1
etc.).
Note: You can look for this ARN under the Task Definitions view on the ECS console, or by running
aws ecs list-task-definitions | grep first-run-task-definition
.
cluster
- should contain the name of the Fargate cluster you created before.
exec_role_arn
- should contain the ARN of the ecsTaskExecutionRole role which was created
automatically for you when you created the cluster.
Note: In case you don't have such a role, you can create it using these instructions.
So, after these changes, your environment
should look similar to the following:
environment {
region = "us-east-1"
docker_repo_uri = "xxxxxxxxxxxx.dkr.ecr.us-east-1.amazonaws.com/sample-app"
task_def_arn = "arn:aws:ecs:us-east-1:xxxxxxxxxxxx:task-definition/first-run-task-definition"
cluster = "default"
exec_role_arn = "arn:aws:iam::xxxxxxxxxxxx:role/ecsTaskExecutionRole"
}
Now, add the following stage right after the existing "Build" stage:
stage('Deploy') {
steps {
// Override image field in taskdef file
sh "sed -i 's|{{image}}|${docker_repo_uri}:${commit_id}|' taskdef.json"
// Create a new task definition revision
sh "aws ecs register-task-definition --execution-role-arn ${exec_role_arn} --cli-input-json file://taskdef.json --region ${region}"
// Update service on Fargate
sh "aws ecs update-service --cluster ${cluster} --service sample-app-service --task-definition ${task_def_arn} --region ${region}"
}
}
The first step in this stage overrides the image
field in taskdef.json with the name of the image
that has been created in the CI stage.
Note: We use
|
as a delimiter insed
because${docker_repo_uri}
contains a slash, which creates escaping problems in this case.
The second step registers a new task definition revision which references the new image we already have in ECR.
The last step instructs Fargate to update the app on the cluster, which will cause a new container to be launched from the image we just pushed to ECR, replacing the old one.
Note: When calling
update-service
you may specify a specific task definition revision by including the revision number in the provided ARN (for example:3
). When not doing so, Fargate simply uses the most recent revision, which is fine in our case.
So, we should now be ready to test our CD stage. Commit your changes, push them to Github and run the pipeline. If all goes well, an update should be triggered on Fargate, which will deploy our app instead of the sample app we deployed using the Getting Started wizard. Verify this by browsing the DNS name of the load balancer again. If you still see the sample app, the deployment might still be in progress. You can follow it on the service's Deployments tab.
Up to now, all we did was set up a CI/CD pipeline which will build and deploy code changes automatically. Now, we will verify it actually does so by making a very simple code change.
Open app.go
in your editor and change version = "1.0"
on line 11 to version = "1.1"
. Push
the change to Github and run the pipeline. If all goes well, after a short time you should see the
"Version" field change when refreshing your browser.
Note: Deploying a new version could take a few minutes, mainly because the default Deregistration Delay is 5 minutes. You may reduce this timer to speed up deployments, or manually kill the old tasks.
When you are done experimenting and would like to delete the environment, perform the following:
- Terminate the Jenkins instance and delete its IAM role, security group and SSH key.
- On the Clusters view of the ECS console, choose your cluster, click Delete Cluster and then Delete. This will delete everything Fargate has created for you including the VPC and the load balancer.
- In the Task Definitions view, click first-run-task-definition, check all of the revisions in the list, then click Actions -> Deregister and then Deregister.
- In the Repositories view, check the repository you created, then click Delete repository and then Delete.