How to build a CI/CD pipeline with Docker

How to build a CI/CD pipeline with Docker

I talk with many of my fellow engineers at conferences and other events throughout the year. One thing I like demonstrating is how they can implement a continuous integration/continuous deployment (CI/CD) pipeline into a codebase with very little effort. In this post I will walk through some demo code and the CircleCI config that I use in the demonstration. Following these steps will show you how to implement CI/CD pipelines into your code base.

This post will cover:

  • A simple unit test for a Python Flask application

  • How to implement a CI/CD pipeline in the codebase using a CircleCI config file in the project

  • Building a Docker image

  • Pushing the Docker image to Docker Hub

  • Kicking off a deployment script which will run the application in Docker container on a Digital Ocean server

Prerequisites

Before we get started, you will need:

After you have all the pre-requisites complete you are ready to proceed to the next section.

Using the sample application

For this post I’ll be using a simple Python Flask and you can find the complete source code for this project here and git clone it locally. The app is a simple web server that renders html when a request is made to it. The Flask application lives in the hello_world.py file:

from flask import Flask

app = Flask(__name__)

def wrap_html(message):
    html = """
        <html>
        <body>
            <div style='font-size:120px;'>
            <center>
                <image height="200" width="800" src="https://infosiftr.com/wp-content/uploads/2018/01/unnamed-2.png">
                <br>
                {0}<br>
            </center>
            </div>
        </body>
        </html>""".format(message)
    return html

@app.route('/')
def hello_world():
    message = 'Hello DockerCon 2018!'
    html = wrap_html(message)
    return html

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

The key takeaway in this code the message variable within the hello_world() function. This variable specifies a string value, and the value of this variable will be tested for a match in a unit test.

Testing the code

Code must be tested to make sure that high quality, stable code is released to the public. Python comes with a testing framework named unittest that I will be using for this tutorial. Now that we have a complete Flask application and it needs a companion unit test that will test the application and ensure it’s functioning as designed. The unit test file test_hello_world.py is the unit test for our hello_world.py app. Now, let’s walk through the code.

import hello_world
import unittest

class TestHelloWorld(unittest.TestCase):

    def setUp(self):
        self.app = hello_world.app.test_client()
        self.app.testing = True

    def test_status_code(self):
        response = self.app.get('/')
        self.assertEqual(response.status_code, 200)

    def test_message(self):
        response = self.app.get('/')
        message = hello_world.wrap_html('Hello DockerCon 2018!')
        self.assertEqual(response.data, message)

if __name__ == '__main__':
    unittest.main()
import hello_world
import unittest

Importing the hello_world application using the import statement gives the test access to the code in the hello_world.py. Next, import the unittest modules and start defining test coverage for the application.

class TestHelloWorld(unittest.TestCase): The TestHelloWorld is instantiated from the base class unittest.Test, which is the smallest unit of testing. It checks for a specific response to a particular set of inputs. The unittest framework provides a base class, TestCase, that you will use to create new test cases.

def setUp(self):
        self.app = hello_world.app.test_client()
        self.app.testing = True

The class-level method setUp() is called to prepare the test fixture. It is called immediately before calling the test method. In this example, we create and define a variable named app and instantiate it as app.test_client() object from the hello_world.py code.

def test_status_code(self):
    response = self.app.get('/')
    self.assertEqual(response.status_code, 200)

The method test_status_code() specifies an actual test case in code. This test case makes a get request to the Flask application and captures the app’s response in the response variable. The self.assertEqual(response.status_code, 200) compares the value of the response.status_code result to the expected value of 200, which signifies the get request was successful. If the server responds with a status_code other than 200, the test will fail.

def test_message(self):
    response = self.app.get('/')
    message = hello_world.wrap_html('Hello DockerCon 2018!')
    self.assertEqual(response.data, message)

Another method, test_message(), specifies a different test case. This test case is designed to check the value of the message variable that is defined in the hello_world() method from the hello_world.py code. Like the previous test, a get call is made to the app and results are captured in a response variable:

message = hello_world.wrap_html('Hello DockerCon 2018!')

The message variable is assigned the resulting html from the hello_world.wrap_html() helper method, as defined in the hello_world app. The string Hello DockerCon 2018 is supplied to the wrap_html() method which is then injected and returned in html. The test_message() verifies that the message variable in the app matches the expected string in this test case. If the strings do not match, the test will fail.

Implementing the CI/CD pipeline

Now that we are clear on the application and its unit tests, it is time to implement a CI/CD pipeline into the codebase. Implementing a CI/CD pipeline using CircleCI is very simple, but before continuing, make sure you do the following:

Setting up a CI/CD pipeline

Once your project is set up in the CircleCI platform, any commits pushed upstream will be detected and CircleCI will execute the job defined in your config.yml file.

Create a new directory in the repo’s root and add a yaml file within this new directory. The new assets must follow these naming schema - directory: .circleci/ file: config.yml in your project’s git repository. This directory and file basically define your CI/CD pipeline adn configuration for the CircleCI platform.

Configuration files

The config.yml file is where all of the CI/CD magic happens. After this code block which shows the example file, I will briefly explain what is going on within the syntax.

version: 2
jobs:
  build:
    docker:
      - image: circleci/python:2.7.14
        environment:
          FLASK_CONFIG: testing
    steps:
      - checkout
      - run:
          name: Setup VirtualEnv
          command: |
            echo 'export TAG=0.1.${CIRCLE_BUILD_NUM}' >> $BASH_ENV
            echo 'export IMAGE_NAME=python-circleci-docker' >> $BASH_ENV 
            virtualenv helloworld
            . helloworld/bin/activate
            pip install --no-cache-dir -r requirements.txt
      - run:
          name: Run Tests
          command: |
            . helloworld/bin/activate
            python test_hello_world.py
      - setup_remote_docker:
          docker_layer_caching: true
      - run:
          name: Build and push Docker image
          command: |
            . helloworld/bin/activate
            pyinstaller -F hello_world.py
            docker build -t ariv3ra/$IMAGE_NAME:$TAG .
            echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
            docker push ariv3ra/$IMAGE_NAME:$TAG
      - run:
          name: Deploy app to Digital Ocean Server via Docker
          command: |
            ssh -o StrictHostKeyChecking=no root@hello.dpunks.org "/bin/bash ./deploy_app.sh ariv3ra/$IMAGE_NAME:$TAG"

The jobs: key represents a list of jobs that will be run. A job encapsulates the actions to be executed. If you only have one job to run, you must give it the key name build:. There are more details available about jobs and builds here.

The build: key is composed of a few elements:

  • docker:

  • steps:

The docker: key tells CircleCI to use a Docker executor, which means our build will be executed using Docker containers.

image: circleci/python:2.7.14 specifies the Docker image that the build must use.

steps:

The steps: key is a collection that specifies all of the commands that will be executed in this build. The first action that happens, the - checkout command, performs a git clone of your code into the execution environment.

The - run: keys specify commands to execute within the build. Run keys have a name: parameter where you can label a grouping of commands. For example, name: Run Tests groups the test related actions. This grouping helps organize and display build data within the CircleCI dashboard.

Note: Each run block is equivalent to separate, individual shells or terminals. Commands that are configured or executed will not persist in later run blocks. Use the $BASH_ENV workaround in the tips & tricks section of the docs.

- run:
    name: Setup VirtualEnv
    command: |
      echo 'export TAG=0.1.${CIRCLE_BUILD_NUM}' >> $BASH_ENV
      echo 'export IMAGE_NAME=python-circleci-docker' >> $BASH_ENV 
      virtualenv helloworld
      . helloworld/bin/activate
      pip install --no-cache-dir -r requirements.txt

The command: key for this run block has a list of commands to execute. These commands set the $TAG & IMAGE_NAME custom environment variables that will be used throughout this build. The remaining commands set up the python virtualenv and installs the Python dependencies specified in the requirements.txt file.

- run:
    name: Run Tests
    command: |
      . helloworld/bin/activate
      python test_hello_world.py

In this run block, the command executes tests on our application. If these tests fail, the entire build will fail. Developers will need to fix their code and recommit.

- setup_remote_docker:
    docker_layer_caching: true

This run block specifies the setup_remote_docker: key, a feature that enables building, running and pushing images to Docker registries from within a Docker executor job. When docker_layer_caching is set to true, CircleCI will try to reuse Docker Images (layers) built during a previous job or workflow. That is, every layer you built in a previous job will be accessible in the remote environment. However, in some cases your job may run in a clean environment, even if the configuration specifies docker_layer_caching: true.

The setup_remote_docker: feature is required because we are building a Docker image for our app and pushing that image to Docker Hub.

- run:
    name: Build and push Docker image
    command: |
      . helloworld/bin/activate
      pyinstaller -F hello_world.py
      docker build -t ariv3ra/$IMAGE_NAME:$TAG .
      echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
      docker push ariv3ra/$IMAGE_NAME:$TAG

The Build and push Docker image run block specifies the commands that package the application into a single binary using pyinstaller. It then continues on to the Docker image building process.

docker build -t ariv3ra/$IMAGE_NAME:$TAG .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ariv3ra/$IMAGE_NAME:$TAG

These commands build the docker image based on the Dockerfile included in the repo. Instructions for building the Docker image can be found here: Dockerfile.

FROM python:2.7.14

RUN mkdir /opt/hello_word/
WORKDIR /opt/hello_word/

COPY requirements.txt .
COPY dist/hello_world /opt/hello_word/

EXPOSE 80

CMD [ "./hello_world" ]

The echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin command uses the $DOCKER_LOGIN and $DOCKER_PWD env variables set in the CircleCI dashboard as credentials to login & push this image to Docker Hub.

- run:
    name: Deploy app to Digital Ocean Server via Docker
    command: |
      ssh -o StrictHostKeyChecking=no root@hello.dpunks.org "/bin/bash ./deploy_app.sh ariv3ra/$IMAGE_NAME:$TAG"

The final run block deploys our new code to a live server running on the Digital Ocean platform, s make sure that you have created a deploy script on the remote server. The ssh command accesses the remote server and executes the deploy_app.sh script, including ariv3ra/$IMAGE_NAME:$TAG, which specifies the image to pull and deploy from Docker Hub.

After the job completes successfully, the new application should be running on the target server you specified in your config.yml file.

Summary

My goal for this tutorial is to guide you through implementing a CI/CD pipeline into your code. Although this example is built using Python technologies, the general build, test and deployment concepts can easily be implemented using any language or framework you prefer. You can expand on the simple examples in this tutorial and tailor them to your own pipelines.

I am Sunil kumar, Please do follow me here and support #devOps #trainwithshubham #github #devopscommunity #devops #cloud #devoparticles #trainwithshubham

sunil kumar

Shubham Londhe

Connect with me over linkedin : linkedin.com/in/sunilkumar2807