Chapter 2: Serverless Deployment (Azure Functions)#
Welcome back! In Chapter 1: Web Interface (Frontend), we saw the friendly face of our project β the part you interact with in your web browser. You learned how it gathers information (like the repo URL) and displays the final tutorial. But what happens after you click that "Generate Tutorial" button? Where does the magic happen, and how is it handled efficiently?
That's where the backend comes in, and in this project, we've chosen a modern, cost-effective approach called Serverless Deployment using Azure Functions.
The Problem: Idle Servers are Expensive!#
Imagine you have a traditional web application. You need a server running all the time just in case someone visits your website or wants to use a feature like generating a tutorial. Most of the time, that server might be doing nothing, just waiting. But you're still paying for it to run, whether it's busy or idle.
Generating a tutorial is a task that doesn't happen constantly. Users request it sometimes, and it might take a while. Keeping a powerful server running 24/7 just for these occasional, potentially long tasks is wasteful.
The Solution: Serverless!#
Serverless doesn't mean there are no servers. It means you don't have to manage the servers yourself. A cloud provider (like Microsoft Azure) handles all the server infrastructure, scaling, and maintenance for you.
You just provide your code, tell the cloud provider when to run it (what "event" should trigger it), and they run it for you. You typically only pay for the time your code is actually running, which is perfect for tasks that happen on demand.
Think of it like paying for electricity. You don't own a power plant; you just plug in your devices and pay for the electricity you consume when you use them. Serverless is like that for your code!
Introducing Azure Functions#
Azure Functions is Microsoft Azure's serverless compute service. It lets you run small pieces of code, called "functions," without worrying about the underlying infrastructure.
Here are the key concepts you need to know:
- Function: A single piece of code that performs a specific task (e.g., processing a request, reacting to a database change).
- Trigger: The event that starts a function running. This could be an HTTP request, a message arriving in a queue, a timer, etc.
- Bindings: A way to easily connect your function to other services (like databases, storage, queues) without writing a lot of connection code. (We'll see input and output bindings implicitly used with the queue and storage).
How Our Tutorial Generation Works with Azure Functions#
Our project uses Azure Functions to handle the backend processing. Here's the basic flow:
- You submit the form on the Frontend (Chapter 1).
- The Frontend sends an HTTP request to an Azure Function.
- This first Azure Function (
start-job
) receives the request and doesn't start the long generation process immediately. - Instead,
start-job
takes the request details and puts them into a Queue. Think of the queue as a waiting list for jobs. - Another Azure Function (
generate
) is constantly watching this Queue. - When a new job message appears in the Queue, the
generate
function is automatically triggered. - The
generate
function reads the job details from the queue message and starts the heavy lifting: fetching the code, analyzing it, talking to the AI, and generating the tutorial content. (Workflow Engine (Pocket Flow) will explain how this internal processing happens). - Once the
generate
function is done, it saves the resulting tutorial files to a Storage Account (specifically, Azure Blob Storage). - Later, when you visit the output page on the Frontend, the Frontend makes HTTP requests to other Azure Functions (
get-output-structure
,get-output-content
) to read the saved tutorial files from the Storage Account and display them to you.
This queuing pattern is great because:
* It makes the initial request (start-job
) very fast, so the user gets quick feedback ("Job accepted!").
* It separates the request reception from the heavy processing, making the system more robust. If the generate
function is busy or fails, the message stays in the queue and can be processed later.
* It allows Azure Functions to scale the generate
function automatically. If many jobs arrive in the queue, Azure can start multiple instances of the generate
function to process them in parallel.
Let's visualize this with a simple diagram:
sequenceDiagram
participant User
participant Frontend as Web Interface
participant StartJobFunc as "start-job" Function (HTTP Trigger)
participant JobsQueue as Jobs Queue
participant GenerateFunc as "generate" Function (Queue Trigger)
participant BlobStorage as Tutorial Storage (Blob Storage)
User->>Frontend: Submit Tutorial Request
Frontend->>StartJobFunc: HTTP POST /start-job (Request details)
StartJobFunc->>JobsQueue: Send message (Job details)
StartJobFunc-->>Frontend: HTTP 202 Accepted
Frontend->>User: Show "Generating..." / Redirect
Note over JobsQueue: Message is waiting...
JobsQueue-->>GenerateFunc: Trigger (New message arrived)
GenerateFunc->>GenerateFunc: Process Tutorial (Code fetching, AI, etc.)
GenerateFunc->>BlobStorage: Save Tutorial Files
GenerateFunc-->>JobsQueue: Message processed (removed from queue)
Note over User: Later, User visits output page
User->>Frontend: View Tutorial Output
Frontend->>BlobStorage: Read Tutorial Files (via other functions)
BlobStorage-->>Frontend: Tutorial Content
Frontend-->>User: Display Tutorial
This shows how the initial request triggers a function that queues the real work, and then a separate function is triggered by the queue to do the processing and save the result. Other functions then serve the saved result.
Looking at the Code#
The core Azure Functions logic for our backend is in the function_app
directory, primarily within the function_app.py
file.
Let's look at simplified snippets.
First, the start-job
function that receives the HTTP request:
# function_app/function_app.py (Simplified start_job)
import azure.functions as func
import json
import os
from azure.storage.queue import QueueClient # Need this!
app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
@app.function_name(name="start_job")
@app.route(route="start-job", methods=["POST"])
def start_job(req: func.HttpRequest) -> func.HttpResponse:
try:
req_body = req.get_json()
# Get request data (like repo_url, keys, etc.)
except ValueError:
# Handle bad JSON
pass # Simplified
# Connect to the queue
queue_connection_string = os.getenv('AzureWebJobsStorage') # Connection string from settings
queue_client = QueueClient.from_connection_string(queue_connection_string, queue_name="jobsqueue")
# Send the request data as a message to the queue
job_message = json.dumps(req_body)
queue_client.send_message(job_message)
print(f"Message sent to queue: {job_message}") # Logging
# Respond quickly that the job was accepted
return func.HttpResponse(
json.dumps({"message": "Job accepted."}),
status_code=202, # 202 Accepted status
mimetype="application/json"
)
- The
@app.route
decorator tells Azure Functions that this Python function should run when an HTTP request arrives at the/api/start-job
path (the/api
part is added by the Azure Functions host). - We read the incoming request body using
req.get_json()
. - We get the connection information for our Azure Storage Account (where the queue lives) from environment settings.
- We connect to the specific queue named
jobsqueue
. - We convert the request data into a JSON string and send it as a message to the queue using
queue_client.send_message()
. - Finally, we return an HTTP response with status
202 Accepted
to the frontend, indicating that the request was received and queued.
Next, the generate
function that is triggered by the queue:
# function_app/function_app.py (Simplified generate)
import azure.functions as func
import json
# import the core generation logic
# from main import generate_tutorial_content # Need this!
@app.function_name(name="generate")
@app.queue_trigger(arg_name="msg", queue_name="jobsqueue", connection="AzureWebJobsStorage")
def generate(msg: func.QueueMessage) -> None:
"""Triggered by a message in the queue."""
try:
# Read the message content (which is JSON)
req_body = json.loads(msg.get_body().decode('utf-8'))
except Exception as e:
print(f"Error reading queue message: {e}")
# Log and exit if message is bad
return
try:
# Extract parameters from the message
repo_url = req_body.get('repo_url')
# ... get other parameters like keys, patterns, etc.
# Call the main generation logic (which is defined in main.py)
# This function handles code fetching, AI calls, saving output etc.
# generate_tutorial_content(...) # Simplified call
print(f"Successfully processed job for repo: {repo_url}")
except Exception as e:
print(f"Error during generation: {e}")
# Log the error
# save_error_log(...) # Simplified error handling
return
- The
@app.queue_trigger
decorator tells Azure Functions that this function should run whenever a message appears in thejobsqueue
. The message content will be passed into the function as themsg
argument. - We read the message body (
msg.get_body()
) and decode the JSON string back into a Python dictionary (req_body
). - We extract the necessary information (like
repo_url
) from the dictionary. - Crucially, we then call the main tutorial generation logic, which is separated into the
generate_tutorial_content
function within themain.py
file. This keeps the Azure Function focused on the trigger and the core application logic separate. - The
generate_tutorial_content
function (which we'll look at in later chapters like Workflow Engine (Pocket Flow)) performs all the complex steps and saves the results. - If the function completes without error, Azure automatically removes the message from the queue. If it fails, the message becomes visible again after a delay, allowing for retries.
The generated tutorial output (Markdown files, structure JSON) needs to be stored somewhere the frontend can access it later. This is done in Azure Blob Storage. Think of Blob Storage as a simple online file system. The generate_tutorial_content
function saves the files there.
Then, the frontend needs to retrieve this data. This is handled by two other Azure Functions: get-output-structure
and get-output-content
. These are triggered by HTTP GET requests from the frontend.
# function_app/function_app.py (Simplified get_output_structure)
import azure.functions as func
import json
import os
from azure.storage.blob import BlobServiceClient # Need this!
@app.function_name(name="get_output_structure")
@app.route(route="output-structure/{repo_name}", methods=["GET"])
def get_output_structure(req: func.HttpRequest) -> func.HttpResponse:
repo_name = req.route_params.get('repo_name') # Get repo name from the URL path
if not repo_name:
# Handle missing repo name
pass # Simplified
# Connect to Blob Storage
connection_string = os.environ.get("AzureWebJobsStorage")
blob_service_client = BlobServiceClient.from_connection_string(connection_string)
container_client = blob_service_client.get_container_client("tutorials") # Our container name
try:
# List blobs that match the repo name prefix (e.g., "my-repo/...")
# This finds all files belonging to this tutorial
# blobs = list(container_client.list_blobs(name_starts_with=f"{repo_name}/")) # Simplified
# Process blob list to build structure dictionary
structure = {"chapters": []} # Simplified structure building
return func.HttpResponse(
json.dumps(structure), # Return the structure as JSON
status_code=200,
mimetype="application/json"
)
except Exception as e:
# Handle errors accessing storage
pass # Simplified
@app.route
sets up an HTTP GET trigger that expects arepo_name
in the URL path (e.g.,/api/output-structure/my-repo
).- We get the
repo_name
from the request's route parameters. - We connect to Azure Blob Storage using the connection string and specify the container (
tutorials
) where the output is stored. - We list the blobs (files) within that container that start with the given
repo_name
as a prefix (e.g.,my-repo/chapter_1/intro.md
). - The full code then processes this list to figure out the chapter and lesson structure, formats it, and returns it as a JSON response.
And the get-output-content
function to fetch a specific file:
# function_app/function_app.py (Simplified get_output_content)
import azure.functions as func
import json
import os
from azure.storage.blob import BlobServiceClient # Need this!
@app.function_name(name="get_output_content")
@app.route(route="output-content/{repo_name}/{*file_path}", methods=["GET"])
def get_output_content(req: func.HttpRequest) -> func.HttpResponse:
repo_name = req.route_params.get('repo_name') # Get repo name
file_path = req.route_params.get('file_path') # Get the rest of the URL path as file_path
if not repo_name or not file_path:
# Handle missing parameters
pass # Simplified
# Connect to Blob Storage
connection_string = os.environ.get("AzureWebJobsStorage")
blob_service_client = BlobServiceClient.from_connection_string(connection_string)
container_client = blob_service_client.get_container_client("tutorials")
# Construct the full path to the blob (file)
blob_path = f"{repo_name}/{file_path}"
try:
# Get the specific blob client
blob_client = container_client.get_blob_client(blob_path)
if not blob_client.exists():
# Handle file not found
return func.HttpResponse(json.dumps({"error": "File not found"}), status_code=404, mimetype="application/json") # Simplified
# Download the content
content = blob_client.download_blob().readall().decode('utf-8')
return func.HttpResponse(
json.dumps({"content": content}), # Return content in a JSON object
status_code=200,
mimetype="application/json"
)
except Exception as e:
# Handle errors accessing storage
pass # Simplified
@app.route
handles a URL path like/api/output-content/my-repo/chapter_1/intro.md
. It extracts therepo_name
and thefile_path
(the rest of the path).- We connect to Blob Storage and construct the full path to the specific file (
my-repo/chapter_1/intro.md
). - We use the
blob_client
for that specific file to download its content (download_blob().readall()
). - The content is decoded (since it's stored as bytes) and returned as a JSON response.
There's also a fetch-patterns
function triggered by HTTP POST (/api/fetch-patterns
) that the frontend uses to suggest file patterns for filtering. Its code is structured similarly, receiving data via HTTP and returning a JSON response after performing its task (analyzing GitHub file paths using the GitHub API, which requires requests
).
Finally, the host.json
file contains configuration settings for the Azure Functions host itself, such as timeouts:
// function_app/host.json
{
"version": "2.0",
"functionTimeout": "00:10:00", // This allows functions to run for up to 10 minutes
"logging": {
// ... logging settings ...
},
"extensionBundle": {
// ... bundle settings ...
}
}
The functionTimeout
is important for our generate
function, as fetching, analyzing, and calling the AI can take several minutes for larger repositories. Setting it to "00:10:00" allows functions to run for up to 10 minutes by default.
Benefits of this Approach#
Using Azure Functions and this queue-based pattern gives us several advantages:
Feature | Benefit | Why it matters here |
---|---|---|
Serverless | No server management needed. | Reduces operational overhead for the project maintainer. |
Cost-Effective | Pay only for execution time and storage used. | Cheaper than a dedicated server if tutorial generation is not happening constantly. |
Scalable | Azure automatically scales up/down based on demand. | Handles sudden spikes in tutorial requests without manual intervention. |
Decoupling | Queue separates job submission from processing. | Improves reliability; front-end is responsive, processing can be retried if needed. |
Asynchronous | Processing happens in the background. | User doesn't have to wait for generation to finish on the initial request. |
Conclusion#
By leveraging Azure Functions, our project's backend is set up to be efficient, scalable, and cost-effective. HTTP-triggered functions handle initial requests and serving saved output, while a queue-triggered function handles the main, potentially long-running tutorial generation process asynchronously. The results are stored reliably in Azure Blob Storage, ready to be retrieved by the frontend.
This serverless architecture allows us to run the complex codebase analysis and tutorial generation only when it's needed, without the overhead of managing dedicated servers.
In the next chapter, we'll dive deeper into the generate
function and explore how it orchestrates the multiple steps required to create a tutorial using a special tool called Pocket Flow.
Next Chapter: Workflow Engine (Pocket Flow)
Generated by AI Codebase Knowledge Builder. References: 1(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/function_app.py), 2(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/host.json), 3(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/main.py), 4(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/test/test_fetch_patterns.py), 5(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/test/test_function.py), 6(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/test/test_output_content.py), 7(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/test/test_output_structure.py), 8(https://github.com/hieuminh65/Tutorial-Codebase-Knowledge/blob/be7f595a38221b3dd7b1585dc226e47c815dec6e/function_app/test/test_start_job.py)