Implement a CI/CD Pipeline with GitHub Actions and Vultr Kubernetes Engine
Continuous integration and Continuous delivery (CI/CD) is now becoming the norm in developing software applications. This popularity of CI/CD is because adopting CI/CD pipeline allows software projects to release more often without compromising quality. In addition, with the rising popularity of Kubernetes in orchestrating containers in a microservice architecture, the CI/CD pipeline usually involves steps related to deploying the apps to the Kubernetes cluster. Implementing the CI/CD pipeline, which consists in communicating to the Kubernetes cluster, is not a trivial task since there are multiple components in the Kubernetes cluster that you need to configure to work well with the CI tool.
This article will teach you how to implement the CI/CD workflows using GitHub actions with Vultr Kubernetes Engine.
What is GitHub action
GitHub action is a CI/CD tool created by GitHub, similar to Jenkins or GitLab CI, allowing you to deliver your app to a specific environment automatically. GitHub has many actions created by communities or organizations, so you can easily connect your existing technology stack with GitHub without the need to build your integrators.
What is Vultr Kubernetes Engine
Vultr Kubernetes Engine (VKE) is a Vultr-managed Kubernetes cluster. Managing and maintaining system resources for deploying the Kubernetes cluster takes time due to the complexity of Kubernetes. Moreover, to scale the Kubernetes cluster so your application can handle more user workload, you need to add more Kubernetes nodes. Adding nodes to the Kubernetes cluster is a costly initial investment for the software team. A product like Vultr Kubernetes Engine is the solution to these problems.
With Vultr Kubernetes Engine, you can quickly scale up and down the Kubernetes cluster the way you want. You also use the pay-as-you-go pricing method, so you only need to pay for what you use, not investing a lot of money to create multi-node Kubernetes clusters to deploy your app.
The CI/CD workflow for the real-world app
To understand how CI/CD workflow works and how to build the workflow to deploy your app to the Kubernetes cluster, let's create a completed CI/CD workflow for a movie management app. The steps for implementing the CI/CD workflow would be like below:
- Create a Vultr MySQL database to store the movie management app data
- Create a Vultr Kubernetes engine so that you can deploy the app to it
- Implement the movie management app using FastAPI and Python
- Implement the unit tests for the app
- Implement the API test
- Create a cluster role in VKE
- Create a service account in VKE
- Create a secret for the service account to store the GitHub token
- Create an image pull secret for deployment
- Create a secret file for adding environment variables and credentials for the app
- Create the GitHub workflow definition file
- Create a deployment file
- Push code of project to GitHub repository
- Check the deployed app
The movie management app is an API service. It will allow users to sign up and sign in to the app. Then from the access token the users got after signing in, they can add, update, read, and delete their movies through the app.
Prerequisites
- A ready-to-use Ubuntu version 22.04
- Install the MySQL command line client tool on your machine
- Install Python version 3.9 on your machine
- Install the curl command line tool on your machine
- Create a GitHub account
- Create a Vultr account
- Install kubectl command line tool on your machine
- Install virtualenv tool on your machine
Create a Vultr MySQL database
The movie management app stores data using MySQL database. Self-managing a MySQL database is complicated, and it takes time to maintain it. Let's use a Vultr MySQL database, then.
Create the Vultr MySQL database.
From the Vultr products page, click the "+" button to choose the resources you want to create. Then select "Add managed databases" to add a database. Choose the "MySQL" option and the server configuration and region for the MySQL database. Below is the minimum MySQL server configuration option that would satisfy the article scenario.
- Server Type: Cloud Compute
- Plan:
- CPUs: 1 vCPU
- Storage: 55 GB
- Memory: 2 GB DDR4
- Number of Replica Nodes: 1
- Server Location: Singapore
- Label: movie-management-app
Then click the "Deploy" button and wait a few minutes for the database to be created. Vultr automatically created the admin account and default database for you.
Create a movie management database and new user in the Vultr MySQL database.
From the "Users & Databases" tab on the Vultr MySQL page:
- Create a new database named "movie_management"
- Create a new user with any name. The username for the MySQL database for this article is "donald".
Get the database connection info.
From the overview page of the created Vultr MySQL database, you can see the connection details for your database. The example values for this article are as below:
- username = donald
- password = cuongledinh
- host = vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
- port = 16751
- database = movie_management
Create tables in the movie management database
For the movie management app to work, you must create two tables:
user_info
andmovie
. Access the movie management database from your machine using the command line below:$ mysql --host="vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com" --port=16751 --user="donald" --password="cuongledinh"
Show the list of current databases by executing the below command:
show databases;
You should see the "movie_management" database in the result:
+--------------------+ | Database | +--------------------+ | defaultdb | | information_schema | | movie_management | | mysql | | performance_schema | | sys | +--------------------+ 6 rows in set (0,07 sec)
Use the "movie_management" database by running the following code:
use movie_management;
Create a new
user_info
table by executing the following sql code:CREATE TABLE user_info( id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(500) NOT NULL, fullname VARCHAR(50) NOT NULL );
Create a new
movie
table with the following sql code:CREATE TABLE movie( id INT(6) UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(50) NOT NULL, description VARCHAR(500) NOT NULL );
Now that you have finished creating the Vultr MySQL database for storing movie management app data. Let's move on to create a Vultr Kubernetes Engine.
Create a VKE cluster
Add a new Vultr Kubernetes Engine cluster.
From the Vultr Product page, click "+" button and choose "Add Kubernetes". The example values for the Vultr Kubernetes Engine in this article are:
- Cluster name: movie-management
- Kubernetes version: v1.25.6+2
- Cluster location: Singapore
- Number of nodes: 1
- Label: movie-management
- Node pool type: Regular Cloud Compute
- Plan:
- CPU: 1
- Memory: 2048 MB
- Storage: 55 GB
- Bandwidth: 2000 GB
Click "Deploy Now" and wait a few minutes for Vultr finishes creating a new Vultr Kubernetes Engine. Once the VKE is ready to use, click the "Download Configuration" button to download the Kubernetes configuration file. The example file for Kubernetes configuration is: "vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml".
Connect to Vultr Kubernetes Engine.
Open your terminal in the VKE configuration file's directory, then execute the following command to create an environment variable for
KUBECONFIG
.$ export KUBECONFIG="vke-d77dc163-b45d-436a-ab4b-e75b921581fa.yaml"
Using
kubectl
to get node information:$ kubectl get node
You should be able to see similar output as below:
NAME STATUS ROLES AGE VERSION movie-management-7c617d5b34c0 Ready <none> 6d v1.25.6
You have now finished adding a new Vultr Kubernetes Engine. Let's move on to implementing the movie management app.
Implement the movie management app
You will implement the movie management app using FastAPI and Python. Let's create a new directory to store the application code.
$ mkdir ~/Projects/movie-management-app -p
$ cd ~/Projects/movie-management-app
Install the needed dependencies to implement the app.
Create a
requirements.txt
file that stores all the dependencies you need to implement the app.$ nano requirements.txt
Copy the following content into the
requirements.txt
file:anyio==3.6.2 attrs==22.2.0 bcrypt==4.0.1 certifi==2022.12.7 cffi==1.15.1 click==8.1.3 cryptography==39.0.0 fastapi==0.89.1 greenlet==2.0.1 h11==0.14.0 httpcore==0.16.3 httpx==0.23.3 idna==3.4 iniconfig==2.0.0 mysql-connector-python==8.0.32 packaging==23.0 pluggy==1.0.0 protobuf==3.20.3 pycparser==2.21 pydantic==1.10.4 PyJWT==2.6.0 pytest==7.2.1 rfc3986==1.5.0 sniffio==1.3.0 SQLAlchemy==2.0.0 starlette==0.22.0 typing_extensions==4.4.0 uvicorn==0.20.0
Create a new Python virtual environment to isolate your machine's movie management app dependencies with other Python projects.
$ virtualenv venv
Activate the virtual environment using:
$ source venv/bin/activate
Install all the required dependencies from
requirements.txt
file using the following:$ pip install -r requirements.txt
Create
app_utils.py
file.The
app_utils.py
file is for implementing utility functions. When authorizing the user credential, you will define the functions for encoding and decoding access tokens.Create
app_utils.py
and open it using the following:$ nano app_utils.py
Add the following content to it:
from datetime import timedelta, datetime import jwt secret_key = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" algorithm = "HS256" def create_access_token(*, data: dict, expires_delta: timedelta = None): to_encode = data.copy() if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=15) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm) return encoded_jwt def decode_access_token(*, data: str): to_decode = data return jwt.decode(to_decode, secret_key, algorithms=[algorithm])
Create
crud.py
file.The
crud.py
file defines methods that allow the app to interact with the MySQL database to create, edit, retrieve, and delete data.Create and open
crud.py
by running:$ nano crud.py
Copy the following content to
crud.py
file:import bcrypt from sqlalchemy.orm import Session import models import schemas def get_user_by_username(db: Session, username: str): return db.query(models.UserInfo).filter(models.UserInfo.username == username).first() def create_user(db: Session, user: schemas.UserCreate): hashed_password = bcrypt.hashpw(user.password.encode('utf-8'), bcrypt.gensalt()) db_user = models.UserInfo(username=user.username, password=hashed_password, fullname=user.fullname) db.add(db_user) db.commit() db.refresh(db_user) return db_user def check_username_password(db: Session, user: schemas.UserAuthenticate): db_user_info: models.UserInfo = get_user_by_username(db, username=user.username) return bcrypt.checkpw(user.password.encode('utf-8'), db_user_info.password.encode('utf-8')) def create_new_movie(db: Session, movie: schemas.MovieBase): db_movie = models.Movie(title=movie.title, content=movie.content) db.add(db_movie) db.commit() db.refresh(db_movie) return db_movie def get_all_movies(db: Session): return db.query(models.Movie).all() def get_movie_by_id(db: Session, movie_id: int): return db.query(models.Movie).filter(models.Movie.id == movie_id).first() def delete_movie_by_id(db: Session, movie: schemas.Movie): db.delete(movie) db.commit()
Create
database.py
file.The
database.py
file defines the configuration values to connect to the MySQL database. Create and opendatabase.py
file using the following:$ nano database.py
Copy the following content to
database.py
file:from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker import os DB_HOST = os.getenv('DB_HOST').strip() DB_USERNAME = os.getenv('DB_USERNAME').strip() DB_PASSWORD = os.environ.get('DB_PASSWORD').strip() DB_PORT = os.environ.get('DB_PORT').strip() DB_NAME = os.environ.get('DB_NAME').strip() SQLALCHEMY_DATABASE_URL = f"mysql+mysqlconnector://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}: {DB_PORT}/{DB_NAME}" engine = create_engine( SQLALCHEMY_DATABASE_URL, ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base()
Create
models.py
file.The
models.py
file is for creating classes that correspond to the MySQL database tables so that you can interact with the movie management database tables.Create and open
models.py
file:$ nano models.py
Copy the following content to
models.py
file:from sqlalchemy import Column, Integer, String from database import Base class UserInfo(Base): __tablename__ = "user_info" id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True) password = Column(String) fullname = Column(String, unique=True) class Movie(Base): __tablename__ = "movie" id = Column(Integer, primary_key=True, index=True) title = Column(String) description = Column(String)
Create
schemas.py
file.The
schemas.py
file defines Python classes so that you can conveniently interact with the request and response body of the APIs the app will create.Create and open
schemas.py
file using the following:$ nano schemas.py
Copy the following content to
schemas.py
file:from pydantic import BaseModel class UserInfoBase(BaseModel): username: str class UserCreate(UserInfoBase): fullname: str password: str class UserAuthenticate(UserInfoBase): password: str class UserInfo(UserInfoBase): id: int class Config: orm_mode = True class Token(BaseModel): access_token: str token_type: str class TokenData(BaseModel): username: str = None class MovieBase(BaseModel): title: str content: str class Movie(MovieBase): id: int class Config: orm_mode = True
Create
main.py
file.The
main.py
is the entry point of the application. You will create the application APIs inside this file.Create and open
main.py
file:$ nano main.py
Copy the following content to it:
import uvicorn from fastapi.security import OAuth2PasswordBearer from jwt import PyJWTError from sqlalchemy.orm import Session from fastapi import Depends, FastAPI, HTTPException from starlette import status import crud import models import schemas from app_utils import decode_access_token from crud import get_user_by_username from database import engine, SessionLocal from schemas import UserInfo, TokenData, UserCreate, Token models.Base.metadata.create_all(bind=engine) ACCESS_TOKEN_EXPIRE_MINUTES = 30 app = FastAPI(debug=True) def get_db(): db = None try: db = SessionLocal() yield db finally: db.close() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="authenticate") async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) try: payload = decode_access_token(data=token) username: str = payload.get("sub") if username is None: raise credentials_exception token_data = TokenData(username=username) except PyJWTError: raise credentials_exception user = get_user_by_username(db, username=token_data.username) if user is None: raise credentials_exception return user @app.post("/user", response_model=UserInfo) def create_user(user: UserCreate, db: Session = Depends(get_db)): db_user = crud.get_user_by_username(db, username=user.username) if db_user: raise HTTPException(status_code=400, detail="Username already registered") return crud.create_user(db=db, user=user) @app.post("/authenticate", response_model=Token) def authenticate_user(user: schemas.UserAuthenticate, db: Session = Depends(get_db)): db_user = crud.get_user_by_username(db, username=user.username) if db_user is None: raise HTTPException(status_code=400, detail="Username not existed") else: is_password_correct = crud.check_username_password(db, user) if is_password_correct is False: raise HTTPException(status_code=400, detail="Password is not correct") else: from datetime import timedelta access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) from app_utils import create_access_token access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires) return {"access_token": access_token, "token_type": "Bearer"} @app.post("/movie", response_model=schemas.Movie) async def create_new_movie(movie: schemas.MovieBase, current_user: UserInfo = Depends(get_current_user) , db: Session = Depends(get_db)): return crud.create_new_movie(db=db, movie=movie) @app.get("/movie") async def get_all_movies(current_user: UserInfo = Depends(get_current_user) , db: Session = Depends(get_db)): return crud.get_all_movies(db=db) @app.get("/movie/{movie_id}") async def get_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user) , db: Session = Depends(get_db)): return crud.get_movie_by_id(db=db, movie_id=movie_id) @app.delete("/movie/{movie_id}", status_code=204) async def delete_movie_by_id(movie_id, current_user: UserInfo = Depends(get_current_user) , db: Session = Depends(get_db)): movie_delete = crud.get_movie_by_id(db=db, movie_id=movie_id) if movie_delete: crud.delete_movie_by_id(db=db, movie=movie_delete) if __name__ == "__main__": log_config = uvicorn.config.LOGGING_CONFIG log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(levelname)s - %(message)s" uvicorn.run(app, log_config=log_config)
Create a
Dockerfile
file.A
Dockerfile
file defines steps for building a Docker image for the movie management app. You will work with the Docker image later when implementing the CI/CD workflow.Create and open
Dockerfile
by running:$ nano Dockerfile
Copy the following content to
Dockerfile
:FROM python:3.9 WORKDIR /app COPY requirements.txt /app/requirements.txt RUN pip install -r /app/requirements.txt COPY app_utils.py /app/app_utils.py COPY crud.py /app/crud.py COPY database.py /app/database.py COPY main.py /app/main.py COPY models.py /app/models.py COPY schemas.py /app/schemas.py COPY test_unit.py /app/test_unit.py CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8084"]
Now that you finished implementing the movie management app. Let's move on to see how to run the app in the local environment and try to interact with the functionalities that the app provides.
Run the App
From the current terminal, run the following commands to define environment variables for the app to connect with the MySQL database:
$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
$ export DB_USERNAME=donald
$ export DB_PORT=16751
$ export DB_PASSWORD=thisisapassword
$ export DB_NAME=movie_management
Run the following command to bring up the app:
$ uvicorn main:app --port 8084
You should see the app is up and running with the output below:
INFO: Started server process [8485]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8084 (Press CTRL+C to quit)
Open a new terminal, then try to create a new user using curl:
$ curl --location --request POST 'http://localhost:8084/user' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "new_user",
"password": "12345",
"fullname": "Just a new user"
}'
You should see the output showing a new user has been created.
{"username":"new_user","id":1}
Authenticate the user to get the access token so that you can add a new movie later.
$ curl --location --request POST 'http://localhost:8084/authenticate' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "new_user",
"password":"12345"
}'
The output should look like as:
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo","token_type":"Bearer"}
Let's add a new movie using the access token above for authentication.
$ curl --location --request POST 'http://localhost:8084/movie' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ3OTg2MX0.8tDHI-NeIx4KdcnxrJzls3HyB2PfYHP9QKO4UFERNWo' \
--data-raw '{
"title": "It'\''s a beautiful life",
"description":"An emotional classic movie about how life can beautiful even in storms."
}'
The output should look similar to the below:
{"title":"It's a beautiful life","description":"An emotional classic movie about how life can beautiful even in storms.","id":1}
You have finished implementing the movie management app in the local environment. Let's move on to how to write the application's unit test.
Writing unit tests
Create and open a new file named test_unit.py
:
$ nano test_unit.py
Copy the following content to the test_unit.py
file:
def test_encode_decode_access_token():
from app_utils import create_access_token
from datetime import timedelta
input_data_create_access_token = {"sub": "cuongld"}
access_token_expires = timedelta(minutes=10)
access_token = create_access_token(data=input_data_create_access_token, expires_delta=access_token_expires)
from app_utils import decode_access_token
decoded_access_token = decode_access_token(data=access_token)
assert decoded_access_token['sub'] == input_data_create_access_token['sub']
Here you have a test that checks whether the app can decode the encoded token correctly. To run the test from your terminal, execute the following command:
$ pytest test_unit.py
You should see the result as passed.
Writing an integration test
FastAPI provides easy support for implementing integration tests using the TestClient
.
$ nano test_integration.py
Copy the following content to the file.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_main():
response = client.post("/authenticate", json={"username": "new_user", "password": "12345"}, )
assert response.status_code == 200
Here you check whether the response status code of the authentication API is 200 if you provide the correct user credentials.
Let's run the test using pytest
. Note that the app needs to connect with the actual database since you run the integration test. You need to define the environment variables before running the test.
$ export DB_HOST=vultr-prod-14c2c105-f528-4199-a00d-52676fa16673-vultr-prod-2d32.vultrdb.com
$ export DB_USERNAME=donald
$ export DB_PORT=16751
$ export DB_PASSWORD=thisisapassword
$ export DB_NAME=movie_management
$ pytest test_integration.py
You should see the result as "pass". Now you successfully implemented the unit test and integration test for the app. Let's move on to create a cluster role in the Vultr Kubernetes Engine to set up the integration between GitHub actions and VKE.
Create a cluster role in VKE
From the folder where you have saved the VKE configuration file, create a file named clusterrole.yaml
.
$ nano clusterrole.yaml
Please copy the following content to it.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: continuous-deployment
rules:
- apiGroups:
- ''
- apps
- networking.k8s.io
resources:
- namespaces
- deployments
- replicasets
- ingresses
- services
- secrets
verbs:
- create
- delete
- deletecollection
- get
- list
- patch
- update
- watch
Run the following command to create a cluster role:
$ kubectl -f apply clusterrole.yaml
You should see a message that Kubernetes created a new cluster role.
Create a service account in VKE
From the terminal, run the following command to create a new service account named github-actions-kubernetes-vultr
.
$ kubectl create serviceaccount github-actions-kubernetes-vultr
You should see that Kubernetes has created a new service account named github-actions-kubernetes-vultr
.
Then you create a ClusterRoleBinding to bind the continuous-deployment
role to github-actions-kubernetes-vultr
:
$ kubectl create clusterrolebinding continuous-deployment \
--clusterrole=continuous-deployment
--serviceaccount=default:github-actions-kubernetes-vultr
Run the following command to see details of the service account information:
$ kubectl get serviceaccounts github-actions-kubernetes-vultr -o yaml
You should see a similar output below:
apiVersion: v1
kind: ServiceAccount
metadata:
creationTimestamp: "2023-01-30T10:06:29Z"
name: github-actions-kubernetes-vultr
namespace: default
resourceVersion: "487023"
uid: 43365457-4c6f-4b32-842f-fd379ab6512e
Create a secret for the service account to store the GitHub token
After having the service account with role biding for accessing the Kubernetes cluster, you need to create a secret for the service account. You will later use this secret in the GitHub workflow definition to allow GitHub action to set the Kubernetes context to deploy to Kubernetes.
Create a new file named secret-service-account.yaml
to store the definition for the secret.
$ nano secret-service-account.yaml
Copy the following content to the file:
apiVersion: v1
kind: Secret
metadata:
name: secret-github-actions-kubernetes-vultr
annotations:
kubernetes.io/service-account.name: "github-actions-kubernetes-vultr"
type: kubernetes.io/service-account-token
data:
extra: YmFyCg==
Run the following command to create a secret for the service-account:
$ kubectl apply -f secret-service-account.yaml
Get the yaml output of the secret you have just created above:
$ kubectl get secret secret-github-actions-kubernetes-vultr -o yaml
The output should look similar as below:
apiVersion: v1
data:
ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURnVENDQW1tZ0F3SUJBZ0lJU3JJRzlyUHR3cjh3RFFZSktvWklodmNOQVFFTEJRQXdUekVMTUFrR0ExVUUKQmhNQ1ZWTXhGakFVQmdOVkJBY1REVk5oYmlCR2NtRnVZMmx6WTI4eEV6QVJCZ05WQkFvVENrdDFZbVZ5Ym1WMApaWE14RXpBUkJnTlZCQU1UQ2t0MVltVnlibVYwWlhNd0hoY05Nak13TVRJM01qTXhNVEF4V2hjTk1qZ3dNVEkzCk1qTXhNVEF4V2pCUE1Rc3dDUVlEVlFRR0V3SlZVekVXTUJRR0ExVUVCeE1OVTJGdUlFWnlZVzVqYVhOamJ6RVQKTUJFR0ExVUVDaE1LUzNWaVpYSnVaWFJsY3pFVE1CRUdBMVVFQXhNS1MzVmlaWEp1WlhSbGN6Q0NBU0l3RFFZSgpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFOWFlyYnVhMFg1VlhoL1hwZDd2azVNeXFKL0liS3ozCm1nQ1N2VzlEK3ZBUTB1ZGEvWUJWUVBRSWcyaUZxTVpXeXlxS1BFRVEzblBvb09wYUVJVHNXN044aSt4VUp3dDMKc0U5cUd6UGhqYkVSdzc2c25CWlBENHFzUVpvTG4xM2tFbkZLL2thTEJSaDQ0bTA5dytzM3BBRm12elpRMmtnZwpZVzkzUHZuaWgyZVJISFJGNDJGR2xGVGo5N1hyRlBhVUpwOUNoRUZhUjJhaEF3RThXMEhQZmNoRzJNa0lYcTFsClZQbFM2UXVyR0xWbEs1Q1cxZUg0dGxxTTdwYWlBeWxJNVpnbWxqeUpGaDMvUE9WN1NmSlFZdzMyck41RjFVZnQKZVJrVUVWSDkvVjRGVjR6c1N6ZlUvWEVxWitKc21PNGQrOS90S09kMFZrMy81RTU4ek9yVkhmc0NBd0VBQWFOaApNRjh3RGdZRFZSMFBBUUgvQkFRREFnS0VNQjBHQTFVZEpRUVdNQlFHQ0NzR0FRVUZCd01DQmdnckJnRUZCUWNECkFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVFlFbC9JMW5xckFTYk9SK0FXTEN2dTAvZ0wKVURBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUtYSHFzMWRGbDNuTjEzTmlXa3pMT1ZIdnNCa2Q2aTZXN2pXYgowL01MRU9DR2pWRFFaMGVDazJhNDVEMGNlNmJHQTVvS2tCUTJHbEQ4bTJPMlNCTlVxV0JGeS8vYUtHenVKN0piCjcycVByN0RrYkxRemVFcnhtTWkwQWV5YmxQR2hkbkFWVy9SNTBWWHBWYmV5ZjhPT29CWHJIajRiUWNPRGV2NEYKZTJwaWh5bEEvK2dNSEZYMmZXcmdTSjZ6b0VmcHI4bHpDNW9OWW9iUWhyNXpjMVVSdWtwdXF4TG5vZ0JyOWVUZApnNjQ3UDlXV2pBYWpaaTFVZXRQYnlOZWVDNzNhdXViMEhsZnRNc3JKNW16TzFsVHRsVzcwWUZGUTBCUktUM3Y3Cmd6UWFCSUZNNTQ0SzQxcG5KWkhZOGxaNkFYTkg5MjBMRGhsZjR6RXNZaVFmYmFTN093PT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
extra: YmFyCg==
namespace: ZGVmYXVsdA==
token: ZXlKaGJHY2lPaUpTVXpJMU5pSXNJbXRwWkNJNklrYzFSVEJhWDFKcFpVbERiMnRLVm5SaVMydHFNMncyY1RjMWNUVklNakJPWDFoeGEwZFViVk53VmpnaWZRLmV5SnBjM01pT2lKcmRXSmxjbTVsZEdWekwzTmxjblpwWTJWaFkyTnZkVzUwSWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXVZVzFsYzNCaFkyVWlPaUprWldaaGRXeDBJaXdpYTNWaVpYSnVaWFJsY3k1cGJ5OXpaWEoyYVdObFlXTmpiM1Z1ZEM5elpXTnlaWFF1Ym1GdFpTSTZJbk5sWTNKbGRDMW5hWFJvZFdJdFlXTjBhVzl1Y3kxcmRXSmxjbTVsZEdWekxYWjFiSFJ5SWl3aWEzVmlaWEp1WlhSbGN5NXBieTl6WlhKMmFXTmxZV05qYjNWdWRDOXpaWEoyYVdObExXRmpZMjkxYm5RdWJtRnRaU0k2SW1kcGRHaDFZaTFoWTNScGIyNXpMV3QxWW1WeWJtVjBaWE10ZG5Wc2RISWlMQ0pyZFdKbGNtNWxkR1Z6TG1sdkwzTmxjblpwWTJWaFkyTnZkVzUwTDNObGNuWnBZMlV0WVdOamIzVnVkQzUxYVdRaU9pSTBNek0yTlRRMU55MDBZelptTFRSaU16SXRPRFF5WmkxbVpETTNPV0ZpTmpVeE1tVWlMQ0p6ZFdJaU9pSnplWE4wWlcwNmMyVnlkbWxqWldGalkyOTFiblE2WkdWbVlYVnNkRHBuYVhSb2RXSXRZV04wYVc5dWN5MXJkV0psY201bGRHVnpMWFoxYkhSeUluMC54ZnI4bGQ0dUs5WHc4TF9XUFYxcE1aZ2tzeExLbEpEeDZ5dVNWMWd5NWszWFYwOTdITXhkQmpvdEYybjdVZzZGWXUzakpvVU02U3o5WHl1ZVotQTJYb3BsaE1HSDYtZUU5UjhnWGhJUGNIejFjR1BfNU5NZ216WUlfV2k5UkNWMGdzeV9OWlRCUzUwTjVGbDl4NldiUjRzdVNVZjJqZFA0WW1wT1Z6SWIzS0hDdHdMSERWd3RXdHZ6cmtjNjVFTEVkRnVtQlduY2hmMFNIRWkyOWVBVjRUT2p0clFJeTBNSjNvTWpZY3FKZEZBcG53Zk1mendzbm5ueFBLdW5XVWxyaUJwUWV1RVFjNzZRQkZpQnk5dlNDa1k0WlN5V0tsYjFucXlFbEdhcC1pMUk1a3dkUFJTOEsxamF2U0RidGdkMGVWT2lYV1E3OG9GN3BfUFV3cXZWU0E=
kind: Secret
metadata:
annotations:
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"v1","data":{"extra":"YmFyCg=="},"kind":"Secret","metadata":{"annotations":{"kubernetes.io/service-account.name":"github-actions-kubernetes-vultr"},"name":"secret-github-actions-kubernetes-vultr","namespace":"default"},"type":"kubernetes.io/service-account-token"}
kubernetes.io/service-account.name: github-actions-kubernetes-vultr
kubernetes.io/service-account.uid: 43365457-4c6f-4b32-842f-fd379ab6512e
creationTimestamp: "2023-01-30T10:28:26Z"
name: secret-github-actions-kubernetes-vultr
namespace: default
resourceVersion: "490057"
uid: 6b5a9dd0-2fce-4213-915c-3dc754ebd74a
type: kubernetes.io/service-account-token
Create a new GitHub action secret named KUBERNETES_SECRET
in the GitHub actions secret page, and copy the above content from yaml output to the secret. You will use this KUBERNETES_SECRET
later in the GitHub workflow file.
Create an image pull secret for deployment
Go to your GitHub developer settings page, then create a new GitHub token that has the permission to "read:packages", so that Kubernetes can pull the image from the GitHub container registry. Container registry later on. After having the GitHub token created, you create a secret using the command below:
$ kubectl create secret docker-registry github-container-registry --docker-server=ghcr.io --docker-username=<github-username> --docker-password=<token>
The output should show Kubernetes has successfully created a new secret named github-container-registry
. You will use this secret in the Kubernetes deployment yaml file later.
Create a secret file for adding environment variables and credentials for the app
The movie management application requires environment variables for DB_HOST
, DB_NAME
, DB_USERNAME
, DB_PASSWORD
, DB_PORT
to run. You need to create a secret file for defining these environment variables so that the Kubernetes deployment process will use these secret values later on when creating the Kubernetes pod.
You need to encode each environment value using base64 encode method first. For example, below is the command to encode "example" value using base64:
$ echo 'example' | base64
You should see the similar result as:
ZXhhbXBsZQo=
Then create a new file named secret-as-environment-variable.yaml
.
$ nano secret-as-environment-variables.yaml
Copy the following content and replace the values of secrets with values of your secrets in base64 encoded format.
apiVersion: v1
kind: Secret
metadata:
name: mysecret
type: Opaque
data:
DB_HOST: dnVsdHItcHJvZC0xNGMyYzEwNS1mNTI4LTQxOTktYTAwZC01MjY3NmZhMTY2NzMtdnVsdHItcHJvZC0yZDMyLnZ1bHRyZGIuY29tCg==
DB_USERNAME: ZG9uYWxkCg==
DB_PASSWORD: Y3VvbmdsZWRpbmgK
DB_PORT: MTY3NTEK
DB_NAME: bW92aWVfbWFuYWdlbWVudAo=
Then run the following command to create the secret:
$ kubectl apply -f secret-as-environment-variable.yaml
You should see the message showing Kubernetes has created the secret named mysecret
.
Create a GitHub action workflow
You have successfully prepared the secrets, cluster role, and service account for Kubernetes to interact with GitHub actions. Let's create a GitHub action workflow.
Go to your local project, and create a folder named .github
. Inside .github
folder, create a folder named workflows
.
$ cd ~/Projects/movie-management-app
$ mkdir -p .github/workflows
Inside the workflows
folder, you create a workflow definition file named movie-management-vultr.yaml
.
$ nano movie-management-vultr.yaml
Copy the following content to it.
name: movie-management-vultr
on: push
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9 # Modify python version HERE
#Task for installing dependencies, multi-line command
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install black pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: run unit test and integration test
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
DB_PORT: ${{ secrets.DB_PORT }}
DB_NAME: ${{ secrets.DB_NAME }}
run: |
pytest
build:
name: Build
needs: test
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.GH_TOKEN }}
- name: Build and push the Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: |
ghcr.io/cuongld2/vultr-cicd-githubactions:latest
ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy
needs: [ test, build ]
runs-on: ubuntu-latest
steps:
- name: Set the Kubernetes context
uses: azure/k8s-set-context@v2
with:
method: service-account
k8s-url: https://d77dc163-b45d-436a-ab4b-e75b921581fa.vultr-k8s.com:6443
k8s-secret: ${{ secrets.KUBERNETES_SECRET }}
- name: Checkout source code
uses: actions/checkout@v3
- name: Deploy to the Kubernetes cluster
uses: azure/k8s-deploy@v1
with:
namespace: default
manifests: |
kubernetes/deployment.yaml
kubernetes/service.yaml
images: |
ghcr.io/cuongld2/vultr-cicd-githubactions:${{ github.sha }}
The GitHub action will trigger this workflow if you push new code to the repository. Inside this workflow, you have the secrets as DB_HOST
, DB_NAME
, DB_USERNAME
, DB_PASSWORD
, DB_PORT
. You use these secrets when running the test before deploying it to Kubernetes. Create five more new secrets with the actual value of the movie-management database.
You also have another secret for GH_TOKEN
. You need the GitHub token to push the new container image to the GitHub container registry. You must create another GitHub token with permission for repo, write:packages
. Then add a new action secret named GH_TOKEN
, and put the value of the GitHub token you created in it.
You already created the secret for KUBERNETES_SECRET
in the step "Create a secret for the service account to store GitHub token, " so please ignore it.
Create a deployment file
Let's create a deployment file to deploy the app to Kubernetes. Go to your local project, then create a folder named kubernetes
.
$ cd ~/Projects/movie-management-app
$ mkdir kubernetes
Inside the kubernetes
folder, create a file named deployment.yaml
.
$ nano deployment.yaml
Copy the following content to it. Remember to replace the container image ghcr.io/cuongld2/vultr-cicd-githubactions:latest
with your actual value.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: movie-management-deployment
labels:
app: movie-management
spec:
replicas: 1
selector:
matchLabels:
app: movie-management
template:
metadata:
labels:
app: movie-management
spec:
containers:
- name: movie-management
image: ghcr.io/cuongld2/vultr-cicd-githubactions:latest
ports:
- containerPort: 8084
env:
- name: DB_HOST
valueFrom:
secretKeyRef:
name: mysecret
key: DB_HOST
optional: false
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: mysecret
key: DB_USERNAME
optional: false
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: mysecret
key: DB_PASSWORD
optional: false
- name: DB_PORT
valueFrom:
secretKeyRef:
name: mysecret
key: DB_PORT
optional: false
- name: DB_NAME
valueFrom:
secretKeyRef:
name: mysecret
key: DB_NAME
optional: false
imagePullSecrets:
- name: github-container-registry
Push the project code to GitHub
Create a new GitHub repository, and then you push your local project to that GitHub repository in the main
branch. The workflow should automatically run with successful results for all stages: test, build, and deploy.
Check the deployed app
After the GitHub workflows is finished, open the terminal in your local machine to get the Kubernetes pod.
$ kubectl get pod
You should see similar output as:
NAME READY STATUS RESTARTS AGE
movie-management-deployment-5bb8f8cf74-mj9x6 1/1 Running 0 27h
The application is now up and running in Kubernetes. Let's try to interact with the app from the local environment. To do that, you need to forward the app's port inside the Kubernetes cluster to the local port using the below command. Note that you need to replace the pod name with your actual one.
$ kubectl port-forward movie-management-deployment-5bb8f8cf74-mj9x6 8084:8084
You should see the similar output as below:
Forwarding from 127.0.0.1:8084 -> 8084
Forwarding from [::1]:8084 -> 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Handling connection for 8084
Let's use the authenticate API of your app to authenticate the user to see whether the deployed app is working.
$ curl --location --request POST 'http://localhost:8084/authenticate' \
--header 'Content-Type: application/json' \
--data-raw '{
"username": "new_user",
"password":"12345"
}'
You should see similar output as:
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuZXdfdXNlciIsImV4cCI6MTY3NTQ4NTc3MX0.lH8fDhiU2E1cTkola1FAuFFAwe4tZurC5TKr7kQXHg0","token_type":"Bearer"}
The app is working as fine. You have finally completed implementing the CI/CD pipeline triggered to deploy the app to the Vultr Kubernetes cluster.
Conclusion
Through the article, you have learned about how CI/CD pipeline works and have hands-on practice deploying your application to the Vultr Kubernetes cluster with the help of GitHub actions. To learn more about deploying CI/CD pipeline to Kubernetes cluster with different examples, check out other interesting Vultr articles.