Free lesson · GenAI Security Engineering

Deploy PII defense pipeline on GKE

Containerize Presidio analyzer and anonymizer as separate services. Deploy with Helm and configure inter-service communication.

Course: AI Security Engineering · Chapter 8 · PII Leakage Engineering

Free to read — no subscription required.

Introduction

When your PII redaction logic outgrows a single process, packing Presidio's analysis and anonymization into one monolithic container becomes a scaling bottleneck: analysis is CPU-bound on spaCy inference while anonymization is a lightweight string-rewrite, yet a fused service forces you to scale both together and redeploy the heavy NLP model every time you tweak a masking rule. Splitting the two into independent containers lets each scale on its own load profile and roll out on its own cadence. By the end of this lesson, you will be able to containerize a Presidio analyzer and a Presidio anonymizer as two separate FastAPI services, package them into a single Helm chart, and wire them together over Kubernetes cluster DNS so a gateway can call analyze-then-anonymize across the network on Google Kubernetes Engine.

Key Terminology

  • AnalyzerEngine: The Presidio class that runs recognizers over text and returns detected PII spans. It loads a spaCy model at construction time, making it memory-heavy and slow to cold-start—the reason it deserves its own container and replica count.
  • AnonymizerEngine: The Presidio class that consumes analyzer results plus the original text and returns a redacted string. It holds no ML model, so it starts fast and scales cheaply.
  • ClusterIP Service: A Kubernetes Service type that exposes a set of pods under a stable internal DNS name and virtual IP reachable only inside the cluster. Inter-service calls target http://analyzer rather than a pod IP that changes on every restart.
  • Helm chart: A packaged bundle of templated Kubernetes manifests plus a values.yaml file. Running helm install renders the templates against the values and applies the result, giving you one versioned unit for both services.
  • Readiness probe: A periodic HTTP check Kubernetes runs against a pod; traffic is only routed to the pod once the probe passes. For the analyzer this gates traffic until the spaCy model has finished loading.
  • values.yaml: The chart's configuration surface—image tags, replica counts, and ports—overridable at install time with --set so the same chart deploys to staging and production without edits.

The two-service split established here is the deployment substrate a redaction gateway depends on: once both pods are healthy behind their ClusterIP Services, the gateway can fan a prompt out to analysis and pipe the findings into anonymization before the text ever reaches the LLM.

Concepts

Now that you understand why analysis and anonymization warrant separate containers, consider how a single redaction request threads through the deployed topology. A caller sends raw text to a gateway. The gateway first POSTs the text to the analyzer service, which returns a list of PII spans as JSON. The gateway then POSTs both the original text and those spans to the anonymizer service, which returns the masked string. Each hop crosses a ClusterIP Service, so neither the gateway nor the anonymizer needs to know the analyzer's pod IPs—only its DNS name.

This decomposition changes the failure and scaling model. The AnalyzerEngine container carries the spaCy model, so it has a slow cold start and benefits from a higher replica count and generous CPU limits; its readiness probe must not report healthy until the model finishes loading, or the Service will route traffic to a pod that answers with errors. The AnonymizerEngine container is stateless string manipulation—it starts in milliseconds and can run leaner replicas. Because both live in one Helm chart, a single helm upgrade versions them together, but their replicaCount values in values.yaml stay independent.

Loading diagram...

Code Walkthrough

Having established the two-service topology, the next step is writing the code that fills it: two FastAPI applications and a gateway helper. The first fenced block defines analyzer_service.py (an AnalyzerEngine behind a /analyze endpoint with a /healthz readiness route), anonymizer_service.py (an AnonymizerEngine behind /anonymize), and gateway.py (a redact_prompt function that reads the ANALYZER_URL and ANONYMIZER_URL service addresses from the environment and chains the two calls). The second block is the Helm chart that renders both Deployments and their ClusterIP Services.

Code snippetpython
1# analyzer_service.py — Presidio AnalyzerEngine behind HTTP 2from fastapi import FastAPI 3from pydantic import BaseModel 4from presidio_analyzer import AnalyzerEngine 5 6analyzer = AnalyzerEngine() 7app = FastAPI() 8 9class AnalyzeRequest(BaseModel): 10 text: str 11 language: str = "en" 12 13@app.post("/analyze") 14def analyze(req: AnalyzeRequest): 15 results = analyzer.analyze(text=req.text, language=req.language) 16 return [r.to_dict() for r in results] 17 18@app.get("/healthz") 19def healthz(): 20 return {"status": "ok"} 21 22# anonymizer_service.py — Presidio AnonymizerEngine behind HTTP 23from fastapi import FastAPI 24from pydantic import BaseModel 25from presidio_anonymizer import AnonymizerEngine 26from presidio_anonymizer.entities import RecognizerResult 27 28anonymizer = AnonymizerEngine() 29app = FastAPI() 30 31class AnonymizeRequest(BaseModel): 32 text: str 33 analyzer_results: list 34 35@app.post("/anonymize") 36def anonymize(req: AnonymizeRequest): 37 spans = [RecognizerResult(r["entity_type"], r["start"], r["end"], r["score"]) 38 for r in req.analyzer_results] 39 output = anonymizer.anonymize(text=req.text, analyzer_results=spans) 40 return {"text": output.text} 41 42# gateway.py — orchestrates both services over cluster DNS 43import os, requests 44 45ANALYZER_URL = os.environ["ANALYZER_URL"] 46ANONYMIZER_URL = os.environ["ANONYMIZER_URL"] 47 48def redact_prompt(text: str) -> str: 49 found = requests.post(f"{ANALYZER_URL}/analyze", json={"text": text}).json() 50 resp = requests.post(f"{ANONYMIZER_URL}/anonymize", 51 json={"text": text, "analyzer_results": found}).json() 52 return resp["text"]
  • Lines 1-11 (analyzer_service.py): The AnalyzerEngine is constructed once at module import, so the spaCy model loads a single time per pod rather than per request; the /analyze handler serializes each RecognizerResult via to_dict() into JSON the gateway can forward untouched.
  • Line 18 (/healthz): This route is what the readiness probe hits. Because construction of analyzer blocks module import until the model is loaded, the pod only begins serving /healthz after the engine is ready—giving the probe an honest signal.
  • Lines 21-37 (anonymizer_service.py): The handler rebuilds each span into a RecognizerResult from the JSON dict the analyzer emitted, then calls anonymizer.anonymize(); the reconstruction is required because Presidio's anonymizer needs typed objects, not raw dicts, and the network hop flattened them.
  • Lines 40-50 (gateway.py): ANALYZER_URL and ANONYMIZER_URL come from the environment—injected by the chart as http://analyzer and http://anonymizer—so redact_prompt targets stable DNS names and does not hardcode pod IPs. The function does not return until both hops complete.

The second block packages both services. values.yaml exposes per-service image and replica settings, and the template renders a Deployment plus a matching ClusterIP Service for each, so helm install brings up the whole pipeline in one command:

Code snippetyaml
1# values.yaml 2analyzer: { image: gcr.io/PROJECT/presidio-analyzer:1.0, replicaCount: 3 } 3anonymizer: { image: gcr.io/PROJECT/presidio-anonymizer:1.0, replicaCount: 2 } 4 5# templates/analyzer.yaml 6apiVersion: apps/v1 7kind: Deployment 8metadata: { name: analyzer } 9spec: 10 replicas: {{ .Values.analyzer.replicaCount }} 11 selector: { matchLabels: { app: analyzer } } 12 template: 13 metadata: { labels: { app: analyzer } } 14 spec: 15 containers: 16 - name: analyzer 17 image: {{ .Values.analyzer.image }} 18 ports: [ { containerPort: 8000 } ] 19 readinessProbe: 20 httpGet: { path: /healthz, port: 8000 } 21 initialDelaySeconds: 20 22--- 23apiVersion: v1 24kind: Service 25metadata: { name: analyzer } # DNS name the gateway calls 26spec: 27 selector: { app: analyzer } 28 ports: [ { port: 80, targetPort: 8000 } ]
  • Lines 2-3 (values.yaml): Analyzer and anonymizer carry independent replicaCount values, so the CPU-heavy analyzer scales to 3 while the light anonymizer holds at 2—the whole point of the split.
  • Line 26 (Service metadata.name): Naming the Service analyzer is what makes http://analyzer resolve; the anonymizer template (omitted for brevity but identical in shape) names its Service anonymizer, matching the gateway's ANONYMIZER_URL.
  • Lines 19-21 (readinessProbe): The probe hits /healthz with a 20-second initial delay, holding the pod out of the Service's endpoint pool until the model is warm.

Verify by running helm install pii ./chart, then kubectl run -it test --image=curlimages/curl -- sh and calling curl http://analyzer/healthz—you'll know it works when the analyzer returns {"status":"ok"} and a redact_prompt call through the gateway returns text with detected spans replaced.

Do's and Don'ts

Having walked through the two services and their chart, the following Do's and Don'ts distill the deployment into practice.

Do's

  1. Do point the analyzer's readinessProbe at /healthz with an initialDelaySeconds long enough to cover model load — because AnalyzerEngine() blocks until spaCy finishes loading, a probe that fires too early or targets /analyze will mark cold pods ready and the ClusterIP Service will route real traffic into requests that time out while the model initializes.
  2. Do inject ANALYZER_URL and ANONYMIZER_URL as environment variables resolving to Service DNS names — hardcoding a pod IP in gateway.py breaks the moment Kubernetes reschedules a pod, whereas http://analyzer stays stable across restarts because the ClusterIP Service, not the pod, owns the name.
  3. Do set replicaCount independently per service in values.yaml — the analyzer is CPU-bound on inference and the anonymizer is a cheap string rewrite, so scaling them together wastes memory on idle spaCy models; independent replica counts let each match its own load.

Don'ts

  1. Don't forward the analyzer's JSON straight into AnonymizerEngine.anonymize() without rebuilding RecognizerResult objects — the anonymizer expects typed spans, and passing the raw dicts that crossed the network raises a type error, so the anonymize handler must reconstruct each RecognizerResult from its entity_type, start, end, and score fields first.
  2. Don't fuse both engines into one container to "save a hop" — merging AnalyzerEngine and AnonymizerEngine forces you to redeploy the heavy spaCy model every time you change a masking rule and couples their scaling, erasing the independent replicaCount benefit the split exists to provide.
  3. Don't expose either Service as a LoadBalancer or NodePort — the analyzer and anonymizer are internal dependencies of the gateway, and giving them a public IP invites callers to POST arbitrary text straight to the PII detector, bypassing the gateway; keep both as ClusterIP so only in-cluster traffic reaches them.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Security Engineering subscription.

From · cancel anytime

More free lessons in AI Security Engineering

All free lessons in GenAI Security Engineering