Author: Donald Le
Last Updated: Tue, Feb 7, 2023Continuous 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.
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.
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.
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.
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
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
and movie
. 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.
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.
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 open database.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.
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.
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.
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.
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.
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
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.
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.
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
.
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.
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
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.
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.
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.