FastAPI simple REST application in GKE with Terraform CI/CD. Part 3.
Building simple REST API application of Python using our Terraform GKE infra.
In previous Part 2 we created the Terraform infrastructure with CI/CD pipeline on GitHub Actions what includes automatic certificate management and HTTPS ingress controller.
Let’s use this infrastructure to deploy the first application that would provide API capabilities of our services.
First of all we need to register the domain name or use existing GCP endpoints. We will go forward with second option since it is only the API.
Recap
We have those environments:
PROJECT=devops-example-projectREGION=us-central1REGION_WITH_ZONE=us-central1-aCLUSTER_NAME=devops-clusterEXTERNAL_IP=<your external ip address>Here we are adding one more, this service name
SERVICE_NAME=example-api-serviceThe next step would be to authenticate in GCP to have a working environment.
gcloud auth logingcloud config set project devops-example-projectNow we are ready to implement the first API application.
API Service
For the service we should create a source code repository in GitHub with this file structure:
config __init__.py config.pyrouters __init__.py service_api.pyschemas __init__.py schemas.pyutils __init__.py load_secrets.py central_log.pyterraform .terraform-version backend.tf ingress.tf main.tf outputs.tf proxy.tf service.tf terraform.tfvars variables.tf.env.gitignoreapp.pymain.pyDockerfileMakefilerequirements.txtThe config module is needed to keep some specifics for the project, for example the secret name and project id that are going to be used to get secrets from GCP Secret Manager.
The config.py content could look like this
import osproject_id = "devops-example-project"service_name = "example-api-service"version = "1.0.0"env_name = os.getenv("ENV_NAME", "local")In the real application you could have complex logic to initialize config and take those parameters from environments.
Now we can implement the first utility module using those configs: utils/load_secrets.py
import osfrom google.cloud import secretmanagerfrom google.oauth2 import service_accountimport jsonimport base64from config.config import project_id, service_namedef load_secrets(): credentials = None # Check for base64 credentials b64_creds = os.getenv("SERVICE_ACCOUNT_KEY") if b64_creds: creds_json = json.loads(base64.b64decode(b64_creds).decode("utf-8")) credentials = service_account.Credentials.from_service_account_info(creds_json) # Initialize Secret Manager client client = secretmanager.SecretManagerServiceClient(credentials=credentials) secret_name = f"projects/{project_id}/secrets/{service_name}/versions/latest" try: response = client.access_secret_version(name=secret_name) secret_payload = response.payload.data.decode("UTF-8") secrets = json.loads(secret_payload) for key, value in secrets.items(): os.environ[key] = str(value) except Exception as e: print(f"Error loading secrets: {e}") raiseWhy this utility method is not in the config.py? The reason for this is that we want to initialize the secrets only on startup of the application. You potentially could have conditional logic to load them from GCP Secrets or from .env file for development. Additionally, this util class is using SERVICE_ACCOUNT_KEY environment variable, that expects to have the content of GOOGLE_APPLICATION_CREDENTIALS file encoded in Base64.
The good practice is to have central logging system for the application from the earlier beginning. This helps to keep this logic in one place and log it outside of the application. Let’s create the utils/central_log.py
import jsonimport tracebackfrom config.config import service_name, env_nameimport logginglogger = logging.getLogger(__name__)def log(level, message, data=None): entry = { "level": level, "message": message, "service": service_name, "version": version, "env": env_name } if data: entry["data"] = data if level == "error": entry["stack"] = traceback.format_exc() payload = json.dumps(entry) if level == "error": logger.error(payload) elif level == "warning": logger.warning(payload) else: logger.info(payload)Let’s implement the main.py entry point module to initialize and load application.
from dotenv import load_dotenvfrom utils.load_secrets import load_secretsimport osimport uvicorn# Load environment variables from GCP Secret Managerload_secrets()# Load environment variables from .env into the environmentload_dotenv()if __name__ == "__main__": if os.getenv("INSIDE_DOCKER"): uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=False) else: uvicorn.run("app:app", port=8000, reload=True)You could have here conditional logic of loading environment, but I prefer this approach since I can override some settings from local ones, it is extremely helpful for development when you have dozens of secrets.
If we deploy this service in to Docker the file .env is not going to be added to container, so the container never has secrets.
You can add your own .env file in to project
NAME_ABC=VALUE_XYZ...And escape it from including to git in .gitignore file.
__pycache__.DS_Store.idea/.coverage.env.venv/terraform/.terraform.lock.hclterraform/.terraformAdditionally, I added here temporary files for terraform, virtual environment, test coverage and IDEs.
This is a good start already, but we need to add requirements.txt with required libraries.
fastapipydanticuvicornaiohttpretrycoveragepyyamlgoogle-cloud-secret-managerpython-dotenvHere we have a standard set of libraries for the API service application.
For the simplicity of the project let’s initialize all __init__.py files with empty content. It would tell to python that this folder is module, that is sufficient for this example.
Now it is time to implement the first interface service_api.py:
import jsonfrom schemas.schema import *from fastapi import Depends, HTTPException, APIRouterfrom fastapi.security import HTTPBearerfrom fastapi import Requestfrom fastapi.security.http import HTTPAuthorizationCredentialsfrom utils.central_log import logrouter = APIRouter(prefix="/v1")security = HTTPBearer()@router.get("/status")async def status(): log("info", "get status") return {"status": "ok"}In the file schema/schema.py we should have API payloads, but let’s keep it empty for now
from pydantic import BaseModelfrom typing import Any, List, Dict, OptionalNow we are ready to implement application itself in file app.py.
from fastapi import FastAPI, Requestfrom fastapi.responses import Responsefrom fastapi.middleware.cors import CORSMiddlewareimport config.config as configfrom routers import service_apiapp = FastAPI(title=config.service_name, description=f"${config.service_name} Service", version=config.version)origins = ["*"]app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"],)@app.get("/")async def read_root(): return {"status": "ok"}app.include_router(service_api.router)In this file we are creating FastAPI application that going to serve:
- Read root on /path.
- Service_api router.
We have to reply status_code=200 on root path, otherwise GKE thinks that container is not healthy and kills it.
Now the application development is finished, let’s create the docker container.
Docker
This application it easy dockerize because it is stateless and has zero dependencies on other applications.
Let’s create the Dockerfile
FROM python:3.10.0-slim-bullseye# Set environment variablesENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1ENV INSIDE_DOCKER=1# Create non-root user and groupRUN groupadd -r app && useradd -r -g app -d /app app# Set working directory and ensure it's owned by the non-root userWORKDIR /appRUN mkdir -p /app && chown app:app /app# Set trusted pip hosts (optional security/perf)ENV PIP_TRUSTED_HOST="pypi.org pypi.python.org files.pythonhosted.org"# Copy application code and requirements with correct ownershipCOPY --chown=app:app . /app# Upgrade pip and install requirements (as root)RUN pip install --upgrade pip && \ pip install --no-cache-dir -r /app/requirements.txt# Optional: pre-run tests as root (if needed)# RUN python -m coverage run -m unittest discover tests && python -m coverage report -m# Switch to non-root userUSER app# Expose service portEXPOSE 8000CMD ["python", "main.py"]We are using env INSIDE_DOCKER to differentiate run inside and outside the docker container.
Sometimes it is had to remember all build parameters for the Docker container, therefore let’s create Makefile as a placeholder for future more complex build logic.
PROJECT := example-api-serviceVERSION := $(shell git describe --tags --always --dirty)PLATFORM := $(if $(PLATFORM),$(PLATFORM),linux/arm64)all: buildversion: @echo $(VERSION)build: version docker build -f Dockerfile --tag $(PROJECT):$(VERSION) --platform $(PLATFORM) .run: docker run -it -p 8000:8000 -e SERVICE_ACCOUNT_KEY $(PROJECT):$(VERSION)Now we are finished with application itself, and in the next part we will work on creation of the endpoint for the application.
Summary
New we should have the application buildable locally and working with local environments .env with potencially load them from GCP Security Manager. This application requires for now only two environment variables:
- SERVICE_ACCOUNT_KEY — encoded GCP credentials in Base64
- ENV_NAME — environment name: local, dev, staging, prod, etc.
