Day199-200 - MLops Review: CI/CD with GitHub Actions and Azure (1)
Automating Docker image builds with ACR and seamless deployments to ACI
I recently picked up the book “Learning DevOps and MLOps by Doing: A Hands-on Guide with Azure, AWS, and GCP”. (Korean title: 따라하며 배우는 DevOps, MLOps). It’s designed as a practical guide where you learn by following along, building real pipelines, and experimenting directly in the cloud. What I like most about this book is that it doesn’t just stay theoretical—it walks through concrete implementations of CI/CD and MLOps across the three major cloud platforms: Azure, AWS, and GCP.
In this blog series, I’ll review the book and share my own takeaways as I follow along with the exercises. For today’s post, I’ll start with Azure—covering how to set up CI/CD pipelines, deploy applications with Azure Container Registry and Azure Container Instances, and connect everything seamlessly with GitHub Actions.
Stay tuned as I dive into the Azure chapter and highlight both what I learned and how it relates to real-world MLOps practices.
Summarized Concept
- CI: On each push → build Docker image → push to Azure Container Registry (ACR).
- CD: After build succeeds → deploy that image to Azure Container Instances (ACI).
0) Detailed Explanation
- CI: Push to ACR
- ACR acts as a private Docker registry; you’ll need to create one inside your Azure subscription.
- Authentication is handled using a Service Principal (client ID + secret) or with admin credentials.
- Service Principal: It is a security credential that enables applications, services, or automation tools, such as GitHub Actions, to manage resources using the Azure API. It is linked to an application in Azure AD, which has specific permissions to Azure resources. This is necessary when you want applications or services to automatically access resources without requiring users to log in directly to perform tasks (for example, when using automation tools like CI/CD pipelines).
- Use GitHub Actions to build (
docker build
) and push (docker push
) the image.
-
CD: Deploy that image to ACI
- ACI provides serverless containers: we don’t manage VMs, just run our containers.
- Make sure the Microsoft.ContainerInstance resource provider is registered in our subscription.
- Deployment requires passing ACR credentials (
registry-login-server
,username
,password
) so ACI can pull the private image. - You must explicitly expose the application port (e.g.,
8000
) in both the Dockerfile (EXPOSE 8000
) and the deployment configuration (ports: 8000
). - By default, ACI assigns a public IP address if you set
ip-address: Public
; otherwise, it can only be accessed internally.
1) Prerequisites (one-time setup)
1.1 Create Resource Group + Register ACR
- A Resource Group (RG) is a logical container for Azure resources.
- Example resource group:
devops-RG
ineastus
- Example ACR:
devopsimages01
→ login server:devopsimages01.azurecr.io
- What ACR Does
- Stores container images (Docker images you build locally or in CI/CD).
- Works like Docker Hub, but private, secure, and integrated with Azure.
- Pulls images into Azure services such as: Azure Container Instances (ACI), Azure Kubernetes Service (AKS)
- What ACR Does
1.2 Create a Service Principal (SP) with rights
# Get ACR resource ID
REGISTRY_NAME=devopsimages01
RESOURCE_GROUP=devops-RG
REGISTRY_ID=$(az acr show -n $REGISTRY_NAME -g $RESOURCE_GROUP --query id -o tsv)
# Create SP and grant AcrPush role on this ACR
az ad sp create-for-rbac \
--name "sp-acr-push-${REGISTRY_NAME}" \
--role "AcrPush" \
--scopes "$REGISTRY_ID" \
-o json
Copy the appId
(clientId), password
(clientSecret), and tenant
.
- To deploy to ACI, the SP usually also needs Contributor rights on the subscription or resource group.
1.3 Register ACI resource provider (mandatory)
az provider register --namespace Microsoft.ContainerInstance
1.4 Add GitHub repo secrets
Go to Repo → Settings → Secrets and variables → Actions → New repository secret:
AZURE_CREDENTIALS
: JSON object like:
{
"clientId": "<appId>",
"clientSecret": "<password>",
"subscriptionId": "<subscriptionId>",
"tenantId": "<tenant>",
"activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
"resourceManagerEndpointUrl": "https://management.azure.com/",
"activeDirectoryGraphResourceId": "https://graph.windows.net/",
"sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
"galleryEndpointUrl": "https://gallery.azure.com/",
"managementEndpointUrl": "https://management.core.windows.net/"
}
REGISTRY_LOGIN_SERVER
→ e.g.devopsimages01.azurecr.io
REGISTRY_USERNAME
→ usually theappId
REGISTRY_PASSWORD
→ thepassword
RESOURCE_GROUP
→devops-RG
2) Application + Dockerfile
Repo structure:
repo/
├─ Dockerfile
├─ src/
│ └─ main.py # app = FastAPI(...)
└─ .github/
└─ workflows/
└─ cd-azure.yaml
- Dockerfile placement: Keep it at the repository root so that
docker build .
works without extra flags. If it’s in a subfolder, you’ll need-f path/to/dockerfile
.
Dockerfile:
FROM python:3.11-slim
WORKDIR /mlops
COPY . .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir fastapi "uvicorn[standard]"
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
COPY . .
: Copies the entire repository into the container image. Use a.dockerignore
file to exclude unnecessary files (.git
,__pycache__
, etc.).- For larger projects, it’s best to maintain a
requirements.txt
file and install from it (this improves caching).
3) GitHub Actions CI Workflows
.github/workflows/ci.yaml
name: Python application CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# Check only for basic syntax errors
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# Run a full style check
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
env:
PYTHONPATH: .
run: |
python -m pytest tests
on
: Triggers run on two events.push
to themain
branch.pull_request
into themain
branch.
- It uses
flake8
for linter andpytest
for testing framework.- Runs
flake8
twice:- First, strict mode: checks only for fatal syntax/logic errors (E9, F63, etc.).
- Second, full style check: reports warnings and style issues but doesn’t fail the workflow (
--exit-zero
).
- Runs
pytest
:- Runs all tests in the
tests
folder with pytest. - If any test fails, the workflow fails.
- Runs all tests in the
- Runs
4) CD Workflows
name: fastAPI app development to ACI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 'Checkout GitHub Action'
uses: actions/checkout@v4
- name: 'Login via Azure CLI'
uses: azure/login@v1
with:
creds: $
- name: 'ACR Login'
uses: azure/docker-login@v1
with:
login-server: $
username: $
password: $
- name: 'Build and Push Image'
run: |
docker build . -t $/fastapiapp:$
docker push $/fastapiapp:$
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: 'Login via Azure CLI'
uses: azure/login@v1
with:
creds: $
- name: 'Deploy to Azure Container Instances'
uses: azure/aci-deploy@v1
with:
resource-group: $
dns-name-label: $$
image: $/fastapiapp:$
registry-login-server: $
registry-username: $
registry-password: $
name: aci-fastapiapp
location: eastus
4) Usage
- Direct deploy on push to main
git add .
git commit -m "ci/cd: build to ACR & deploy to ACI"
git push origin main
→ Workflow runs automatically.
- PR test first
git checkout -b chore/update-pipeline
# edit files...
git add .
git commit -m "ci: fix pipeline"
git push -u origin chore/update-pipeline
# open PR → Actions runs → merge into main → deploy
Leave a comment