DEV Community

Edgaras
Edgaras

Posted on

Deploying PaddleOCR PDF OCR service on Azure Container Apps

paddleocr-pdf-api converts PDFs to markdown over an HTTP API. This post deploys it to Azure Container Apps as a single container app with an HTTP ingress.

Using a Self-Hosted PDF OCR API with PaddleOCR describes the API itself. Read that one first.

Engines and workload profiles

Three OCR engines are available, one per image tag. Each image contains its model weights, and Container Apps has a workload profile for each one.

Engine Image tag Models Needs Workload profile
text latest-text-baked PP-OCRv5 detection and recognition CPU Consumption
vl latest-vl-baked PaddleOCR-VL-1.6 plus PP-DocLayoutV3 ~8.5 GB VRAM Consumption-GPU-NC8as-T4
structure latest-structure-baked PP-StructureV3 ~10.5 GB VRAM Consumption-GPU-NC8as-T4

The Consumption profile allows 0.25 to 4 vCPU and 0.5 to 8 GiB per replica, in a fixed 1:2 ratio. The two GPU profiles are Consumption-GPU-NC8as-T4 and Consumption-GPU-NC24-A100. A T4 has 16 GB of VRAM, which is enough for both GPU engines.

This deployment uses the text engine on the Consumption profile.

The latest tag contains no model weights. OCR_ENGINE defaults to vl, so it downloads PaddleOCR-VL-1.6 and PP-DocLayoutV3 on first run. That download repeats on every replica replacement, so the baked tags are the ones to deploy here.

Setting up the environment

RG=rg-paddleocr
LOC=swedencentral
ENV=env-paddleocr
APP=paddleocr-api

az group create --name $RG --location $LOC

az containerapp env create \
  --name $ENV \
  --resource-group $RG \
  --location $LOC \
  --enable-workload-profiles
Enter fullscreen mode Exit fullscreen mode

Deploying the app

API_KEY=$(openssl rand -hex 16)

az containerapp create \
  --name $APP \
  --resource-group $RG \
  --environment $ENV \
  --workload-profile-name Consumption \
  --image docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked \
  --target-port 8000 \
  --ingress external \
  --cpu 4.0 \
  --memory 8.0Gi \
  --min-replicas 1 \
  --max-replicas 1 \
  --secrets api-key=$API_KEY \
  --env-vars API_KEY=secretref:api-key
Enter fullscreen mode Exit fullscreen mode

This deployment needs no other Azure resource. The models are in the image, and job state is written to the replica's own storage. Mounting Azure Files replaces that storage, and setting DATABASE_URL moves the database to PostgreSQL.

Replica settings

POST /ocr writes the file, records the job as queued, and returns. A background thread does the OCR. No request stays open while a document is processed.

Container Apps adds an HTTP scale rule when you do not define one. It scales on request count, and its minimum is zero replicas. OCR runs without requests, so nothing keeps a replica running.

minReplicas: 0 scales the app to zero while a job is running. Pages already written survive if state persists, and the job resumes on the next request. On ephemeral storage the job and its results are lost.

minReplicas: 1 keeps one replica running, and a job finishes without traffic.

maxReplicas: 1 is not optional here. Jobs are stored in SQLite on the replica's own disk, so each replica has a separate database. A status poll can reach a replica that does not know the job and returns 404. Running more replicas needs PostgreSQL for the records and a shared mount for the uploads. With PostgreSQL every replica sees every queued job, so the replica that claims one may not be the replica that received the upload.

Verifying the deployment

FQDN=$(az containerapp show --name $APP --resource-group $RG \
  --query properties.configuration.ingress.fqdn -o tsv)

JOB_ID=$(curl -s -X POST "https://$FQDN/ocr" \
  -H "X-API-Key: $API_KEY" \
  -F "file=@document.pdf" | jq -r .job_id)

curl -s "https://$FQDN/ocr/$JOB_ID" -H "X-API-Key: $API_KEY"
Enter fullscreen mode Exit fullscreen mode

The models load on the worker thread, so the API answers before OCR can start. A job submitted in that window stays queued until the models are ready.

Making job state survive a restart

/data contains the SQLite database and the uploaded PDFs. Each replica gets ephemeral storage, so both are lost when the replica is replaced. Replacement happens on every revision change.

Setup Survives replica loss Requires
SQLite on ephemeral storage No Nothing
PostgreSQL through DATABASE_URL Results yes, running job no A database server
Azure Files mount on /data Yes A storage account and share

PostgreSQL moves the records off the replica, and several replicas can then share them. The uploaded files stay local.

An Azure Files mount keeps both the SQLite database and the uploaded files. Create a storage account and a file share, then register the share with the environment:

STORAGE=stpaddleocr$RANDOM

az storage account create \
  --name $STORAGE \
  --resource-group $RG \
  --location $LOC \
  --sku Standard_LRS \
  --kind StorageV2

az storage share-rm create \
  --name ocr-data \
  --storage-account $STORAGE \
  --resource-group $RG \
  --quota 100

STORAGE_KEY=$(az storage account keys list \
  --account-name $STORAGE \
  --resource-group $RG \
  --query "[0].value" -o tsv)

az containerapp env storage set \
  --name $ENV \
  --resource-group $RG \
  --storage-name ocrdata \
  --azure-file-account-name $STORAGE \
  --azure-file-account-key "$STORAGE_KEY" \
  --azure-file-share-name ocr-data \
  --access-mode ReadWrite
Enter fullscreen mode Exit fullscreen mode

az containerapp update has no flag for Azure Files mounts, so the mount goes in YAML:

# mount.yaml
properties:
  template:
    containers:
      - name: paddleocr-api
        image: docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked
        resources:
          cpu: 4.0
          memory: 8.0Gi
        env:
          - name: API_KEY
            secretRef: api-key
        volumeMounts:
          - volumeName: ocr-data
            mountPath: /data
    volumes:
      - name: ocr-data
        storageName: ocrdata
        storageType: AzureFile
        mountOptions: "dir_mode=0777,file_mode=0777,uid=0,gid=0,mfsymlinks,nobrl"
    scale:
      minReplicas: 1
      maxReplicas: 1
Enter fullscreen mode Exit fullscreen mode
az containerapp update --name $APP --resource-group $RG --yaml mount.yaml
Enter fullscreen mode Exit fullscreen mode

nobrl is required. Azure Files is an SMB share, and SQLite locks the database with byte-range locks that the SMB client forwards to the server. Those locks fail on SMB, so without nobrl the app cannot open its database and the container does not start.

Locks handled locally are invisible to other clients of the share. That is one more reason maxReplicas stays at 1: two replicas writing the same database file would not see each other's locks.

A completed job survives a replica restart with this mount in place. A job that ran before the mount does not.

Conclusion

Container Apps runs the image without changes. The deployment is a resource group, an environment, and one az containerapp create. Job state that has to outlive a replica adds a storage account, a file share, and nobrl in the mount options.

OCR runs without open requests, so minReplicas: 0 can stop a job in progress, and maxReplicas stays at 1 as long as the database is SQLite.

Bicep template

One file deploys the same resources: the storage account, the share, the environment, the share registration, and the app with the mount.

// main.bicep
param location string = resourceGroup().location
@secure()
param apiKey string
param storageAccountName string = 'stpaddleocr${uniqueString(resourceGroup().id)}'

resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: storageAccountName
  location: location
  sku: { name: 'Standard_LRS' }
  kind: 'StorageV2'
}

resource share 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01' = {
  name: '${storage.name}/default/ocr-data'
  properties: { shareQuota: 100 }
}

resource env 'Microsoft.App/managedEnvironments@2024-03-01' = {
  name: 'env-paddleocr'
  location: location
  properties: {
    workloadProfiles: [
      { name: 'Consumption', workloadProfileType: 'Consumption' }
    ]
  }
}

resource envStorage 'Microsoft.App/managedEnvironments/storages@2024-03-01' = {
  parent: env
  name: 'ocrdata'
  properties: {
    azureFile: {
      accountName: storage.name
      accountKey: storage.listKeys().keys[0].value
      shareName: 'ocr-data'
      accessMode: 'ReadWrite'
    }
  }
  dependsOn: [ share ]
}

resource app 'Microsoft.App/containerApps@2024-03-01' = {
  name: 'paddleocr-api'
  location: location
  properties: {
    managedEnvironmentId: env.id
    workloadProfileName: 'Consumption'
    configuration: {
      ingress: {
        external: true
        targetPort: 8000
      }
      secrets: [
        { name: 'api-key', value: apiKey }
      ]
    }
    template: {
      containers: [
        {
          name: 'paddleocr-api'
          image: 'docker.io/edgaras0x4e/paddleocr-pdf-api:latest-text-baked'
          resources: {
            cpu: json('4.0')
            memory: '8.0Gi'
          }
          env: [
            { name: 'API_KEY', secretRef: 'api-key' }
          ]
          volumeMounts: [
            { volumeName: 'ocr-data', mountPath: '/data' }
          ]
        }
      ]
      volumes: [
        {
          name: 'ocr-data'
          storageName: envStorage.name
          storageType: 'AzureFile'
          mountOptions: 'dir_mode=0777,file_mode=0777,uid=0,gid=0,mfsymlinks,nobrl'
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 1
      }
    }
  }
}

output fqdn string = app.properties.configuration.ingress.fqdn
Enter fullscreen mode Exit fullscreen mode
az group create --name $RG --location $LOC

az deployment group create \
  --resource-group $RG \
  --template-file main.bicep \
  --parameters apiKey=$API_KEY
Enter fullscreen mode Exit fullscreen mode

The deployment prints the app's hostname as the fqdn output.

Top comments (0)