CloudRun Python - Moving to Kubernetes and Azure Functions

Published: Oct 6, 2022 by Isaac Johnson

So far we have setup a Python based Cloud Run function and added Unit Tests and a CICD Flow in Github. Our next step will show how we can use this containerized function in other providers such as Kubernetes and Azure Functions.

We’ll first show how to copy the built container into a private Harbor CR. Then, we’ll launch it into an on-prem K3s cluster and use port-forward to test. We’ll add Ingress with TLS and “secure” it with a private endpoint URL.

Lastly, we’ll walk through how to use the same container image for a linux-based Azure App Service Plan function app. We’ll wrap by exploring features of an Azure App Service (such as logs and metrics) and securing it with an IdP and OAuth flow.

Fetching and copying the container from GCP

login to gcloud and set the project id

$ gcloud auth login --no-launch-browser
Go to the following link in your browser:

    https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=32555940559.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fsdk.cloud.google.com%2Fauthcode.html&scope=openid+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fappengine.admin+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fsqlservice.login+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcompute+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Faccounts.reauth&state=Eb3aThv0XbaR3q5rrUv2NLAzG531Js&prompt=consent&access_type=offline&code_challenge=W4DnsazV6C3zfFInkNseLLqBTcPVPp80uOZ9T2TvCrk&code_challenge_method=S256

Enter authorization code: 4/0ARtbsJry_1pCrLNJIRjUdnmz2Li31LESOJfmUXKReK9223fweJEDBcq4mdxBNCTvGcMEgQ

You are now logged in as [isaac.johnson@gmail.com].
Your current project is [myanthosproject2].  You can change this setting by running:
  $ gcloud config set project PROJECT_ID


$ gcloud config set project myanthosproject2
Updated property [core/project].

I can now see the private repository I created for the python container that was used in Cloud Run

$ gcloud container images list
NAME
gcr.io/myanthosproject2/pythonfunction
Only listing images in gcr.io/myanthosproject2. Use --repository to list images in other repositories.

I then want to get the tags so we can pull the image

$ gcloud container images list-tags gcr.io/myanthosproject2/pythonfunction
DIGEST        TAGS               TIMESTAMP
04ce84892758  3123889903,latest  2022-09-25T17:22:47
e922947f7f73  3123855199         2022-09-25T17:11:19
39339292671d  3123705581         2022-09-25T16:19:41
ab345118d2cf  3120705034         2022-09-24T22:23:00
0fed933a89bc  3120492894         2022-09-24T20:40:52
9fae5133da44  3120464723         2022-09-24T20:28:35
89dbcb33f2f5  3120375478         2022-09-24T19:49:12
3f33a8798bad  3120328407         2022-09-24T19:31:48
396c2a5c2106  3120262228         2022-09-24T19:11:05
7e35d34b68fc  3120004317         2022-09-24T17:33:05
ec5afbec78d7  3119981975         2022-09-24T17:23:52
2ab22fd9487a  3102378241         2022-09-21T21:07:28
4d45c26cb4d8  3097404036         2022-09-21T06:25:25
3f55a4a8c394  3094842334         2022-09-20T21:44:49
9b7e1ac9bed4  3094553799         2022-09-20T20:18:45
16f41bd58fef  3093601599         2022-09-20T16:34:41
8cbe0f278f0a  3093537776         2022-09-20T16:22:00
0ca813da3f4e  3090046439         2022-09-20T07:25:41
25ae6cd8d542  3090008324         2022-09-20T07:13:27
6e636503301b  3089854983         2022-09-20T07:09:37

The docker pull command kept crashing

$ gcloud docker -- pull gcr.io/myanthosproject2/pythonfunction
WARNING: `gcloud docker` will not be supported for Docker client versions above 18.03.

As an alternative, use `gcloud auth configure-docker` to configure `docker` to
use `gcloud` as a credential helper, then use `docker` as you would for non-GCR
registries, e.g. `docker pull gcr.io/project-id/my-image`. Add
`--verbosity=error` to silence this warning: `gcloud docker
--verbosity=error -- pull gcr.io/project-id/my-image`.

See: https://cloud.google.com/container-registry/docs/support/deprecation-notices#gcloud-docker

ERROR: gcloud crashed (TypeError): a bytes-like object is required, not 'str'

If you would like to report this issue, please run the following command:
  gcloud feedback

To check gcloud for common problems, please run the following command:
  gcloud info --run-diagnostics

However, using the credential helper worked

$ gcloud auth configure-docker
Adding credentials for all GCR repositories.
WARNING: A long list of credential helpers may cause delays running 'docker build'. We recommend passing the registry name to configure only the registry you are using.
After update, the following will be written to your Docker config file located at [/home/builder/.docker/config.json]:
 {
  "credHelpers": {
    "gcr.io": "gcloud",
    "us.gcr.io": "gcloud",
    "eu.gcr.io": "gcloud",
    "asia.gcr.io": "gcloud",
    "staging-k8s.gcr.io": "gcloud",
    "marketplace.gcr.io": "gcloud"
  }
}

Do you want to continue (Y/n)?  y

Docker configuration file updated.

$ docker pull gcr.io/myanthosproject2/pythonfunction
Using default tag: latest
latest: Pulling from myanthosproject2/pythonfunction
31b3f1ad4ce1: Already exists
f335cc1597f2: Already exists
501b4d0d8bea: Already exists
abd735557fdf: Already exists
9358bdbbffdc: Already exists
0c3b8bec9940: Pull complete
9d60ad65a66a: Pull complete
f9e7527f6326: Pull complete
Digest: sha256:04ce848927589424cc8f972298745f8e11f94e3299ab2e31f8d9ccd78e4f0f41
Status: Downloaded newer image for gcr.io/myanthosproject2/pythonfunction:latest
gcr.io/myanthosproject2/pythonfunction:latest

I can now see it locally

$ docker images | head -3
REPOSITORY                                                               TAG                                                                          IMAGE ID       CREATED         SIZE
gcr.io/myanthosproject2/pythonfunction                                   latest                                                                       2c3fa45965c6   8 days ago      152MB
test                                                                     latest                                                                       d9611b409f42   13 days ago     147MB

My next step is to push it to my private CR.

I’ll tag it then push to Harbor

$ docker tag gcr.io/myanthosproject2/pythonfunction:latest harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest

$ docker push harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest
The push refers to repository [harbor.freshbrewed.science/freshbrewedprivate/pythonfunction]
9283b229a025: Pushed
526119217a7a: Pushed
4e86cf43e3a8: Pushed
95557c4c07af: Pushed
873602908422: Pushed
e4abe883350c: Pushed
95a02847aa85: Pushed
b45078e74ec9: Pushed
latest: digest: sha256:04ce848927589424cc8f972298745f8e11f94e3299ab2e31f8d9ccd78e4f0f41 size: 1996

I should note that there are ways to generate a service account key for GCR that one could use with image pull secrets (see this SO post).

I now see it in Harbor

/content/images/2022/10/pythonfunc-01.png

Now let’s create a Deployment YAML with a Service definition

$ cat python.function.deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
  generation: 3
  labels:
    app: python-crfunction
  name: python-crfunction
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: python-crfunction
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        dapr.io/app-id: python-crfunction
        dapr.io/app-port: "5001"
        dapr.io/enabled: "true"
      creationTimestamp: null
      labels:
        app: python-crfunction
        app.kubernetes.io/name: python-crfunction
    spec:
      containers:
      - image: harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest
        imagePullPolicy: Always
        name: python-crfunction
        env:
        - name: PORT
          value: "8080"
        ports:
        - containerPort: 8080
          protocol: TCP
          name: httpsvcend
        resources: {}
      dnsPolicy: ClusterFirst
      imagePullSecrets:
      - name: myharborreg
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/instance: python-crfunction
    app.kubernetes.io/name: python-crfunction
  name: python-crfunction
  namespace: default
spec:
  internalTrafficPolicy: Cluster
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: httpsvcend
  selector:
    app.kubernetes.io/name: python-crfunction
  type: ClusterIP

and apply

$ kubectl apply -f python.function.deployment.yaml
deployment.apps/python-crfunction created
service/python-crfunction created

While I likely need to remove or tweak the dapr configuration (as it expects a listener on 5001), I can port-forward to the service

$ kubectl port-forward svc/python-crfunction 8080:80
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
Handling connection for 8080
Handling connection for 8080

and invoke the container

/content/images/2022/10/pythonfunc-02.png

and I can see that triggered an email

/content/images/2022/10/pythonfunc-03.png

Clearly, I don’t wish to port-forward to hit this endpoint

First, I create a JSON for Route53

$ cat r53-pyfunction.json
{
  "Comment": "CREATE python-crfunction fb.s A record",
  "Changes": [
    {
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "python-crfunction.freshbrewed.science",
        "Type": "A",
        "TTL": 300,
        "ResourceRecords": [
          {
            "Value": "73.242.50.46"
          }
        ]
      }
    }
  ]
}

Then apply it

$ aws route53 change-resource-record-sets --hosted-zone-id Z39E8QFU0F9PZP --change-batch file://r53-pyfunction.json
{
    "ChangeInfo": {
        "Id": "/change/C027964413831YBZWYF7R",
        "Status": "PENDING",
        "SubmittedAt": "2022-10-05T14:18:19.412Z",
        "Comment": "CREATE python-crfunction fb.s A record"
    }
}

Now I can apply an Ingress to use it

$ cat python-crfunction.ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "0"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.org/client-max-body-size: "0"
    nginx.org/proxy-connect-timeout: "600"
    nginx.org/proxy-read-timeout: "600"
  name: python-crfunction
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: python-crfunction.freshbrewed.science
    http:
      paths:
      - backend:
          service:
            name: python-crfunction
            port:
              number: 80
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - python-crfunction.freshbrewed.science
    secretName: python-crfunction-tls

I likely do not need those other annotations, such as max body size. I use the “0” body size as well as the longer timeouts for addressing large payloads to some of my other services (such as large sonar deliveries or harbor image pushes).

We can apply the ingress;

$ kubectl apply -f python-crfunction.ingress.yaml
ingress.networking.k8s.io/python-crfunction created

Then I can see it applied

$ kubectl get ingress python-crfunction
NAME                CLASS   HOSTS                                   ADDRESS                                                PORTS     AGE
python-crfunction   nginx   python-crfunction.freshbrewed.science   192.168.1.214,192.168.1.38,192.168.1.57,192.168.1.77   80, 443   6m28s

One minor issue is nginx isnt going to route to a failing service. And the service is unhappy because the pod keeps crashing

$ kubectl get pods | tail -n 1
python-crfunction-54487b6bb8-nklmb                       1/2     CrashLoopBackOff   562 (4m44s ago)   26h

$ kubectl get deployments python-crfunction
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
python-crfunction   0/1     1            0           26h

I’ll update the app port in the dapr annotations

$ kubectl get deployments python-crfunction -o yaml > python-crfunction.dep.yaml
$ kubectl get deployments python-crfunction -o yaml > python-crfunction.dep.yaml.bak
$ vi python-crfunction.dep.yaml
$ diff -c python-crfunction.dep.yaml python-crfunction.dep.yaml.bak
*** python-crfunction.dep.yaml  2022-10-05 09:41:31.009573000 -0500
--- python-crfunction.dep.yaml.bak      2022-10-05 09:39:08.399273000 -0500
***************
*** 29,35 ****
      metadata:
        annotations:
          dapr.io/app-id: python-crfunction
!         dapr.io/app-port: "8080"
          dapr.io/enabled: "true"
        creationTimestamp: null
        labels:
--- 29,35 ----
      metadata:
        annotations:
          dapr.io/app-id: python-crfunction
!         dapr.io/app-port: "5001"
          dapr.io/enabled: "true"
        creationTimestamp: null
        labels:
$ kubectl apply -f python-crfunction.dep.yaml
deployment.apps/python-crfunction configured

This then fixed the dapr sidecar

$ kubectl get deployments python-crfunction
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
python-crfunction   1/1     1            1           26h

$ kubectl get pods  python-crfunction-7d44797b8b-hgsmn
NAME                                 READY   STATUS    RESTARTS   AGE
python-crfunction-7d44797b8b-hgsmn   2/2     Running   0          3m32s

/content/images/2022/10/pythonfunc-04.png

However, this creates a different issue. I now have it emailing me every time the URL loads. And moreover, there is no authentication

/content/images/2022/10/pythonfunc-05.png

There are two ways I can think to handle this.

The first is to use nginx basic auth:

$ sudo apt install apache2-utils
$ htpasswd -c auth2 builder
New password:
Re-type new password:
Adding password for user builder
$ kubectl create secret generic pyfunc-basic-auth --from-file=auth2
secret/pyfunc-basic-auth created

Then I patch it in (actually i just deleted it and readded)

builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ kubectl get ingress python-crfunction -o yaml > python-crfunction.ingress.yaml
builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ kubectl get ingress python-crfunction -o yaml > python-crfunction.ingress.yaml.bak
builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ kubectl delete ingress python-crfunction
ingress.networking.k8s.io "python-crfunction" deleted
builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ vi python-crfunction.ingress.yaml
builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ diff python-crfunction.ingress.yaml python-crfunction.ingress.yaml.bak
6,9d5
<
<     nginx.ingress.kubernetes.io/auth-realm: Authentication Required
<     nginx.ingress.kubernetes.io/auth-secret: pyfunc-basic-auth
<     nginx.ingress.kubernetes.io/auth-type: basic
builder@DESKTOP-72D2D9T:~/Workspaces/jekyll-blog$ kubectl apply -f python-crfunction.ingress.yaml
ingress.networking.k8s.io/python-crfunction created

Unfortunately, because I’ve setup https redirect, it will skip the basic auth steps so we will never actually use these annotatations.

Another approach would be to use a more complicated path. We could do this with the Ingress definition.

$ diff -c python-crfunction.ingress.yaml python-crfunction.ingress.yaml.
bak
*** python-crfunction.ingress.yaml      2022-10-05 17:54:01.242819000 -0500
--- python-crfunction.ingress.yaml.bak  2022-10-05 17:53:25.630078000 -0500
***************
*** 33,39 ****
              name: python-crfunction
              port:
                number: 80
!         path: /12345asdfg
          pathType: ImplementationSpecific
    tls:
    - hosts:
--- 33,39 ----
              name: python-crfunction
              port:
                number: 80
!         path: /
          pathType: ImplementationSpecific
    tls:
    - hosts:
    
$ kubectl apply -f python-crfunction.ingress.yaml
ingress.networking.k8s.io/python-crfunction created

Now the base URL gives us a 404

/content/images/2022/10/pythonfunc-06.png

That didn’t work. I actually tried many permutations. Fundamentally, I need to update the code to allow other routes

I added another route to main.py

$ git diff
diff --git a/main.py b/main.py
index c44517a..11252e8 100644
--- a/main.py
+++ b/main.py
@@ -17,6 +17,18 @@ def hello_world():
     )
     return "Hello hello {}!".format(name)

+@app.route("/asdf1234")
+def hello_world2():
+    name = os.environ.get("NAME", "World")
+    apobj = apprise.Apprise()
+    apobj.add('sendgrid://SENDGRIDTOKENHERE:isaac@freshbrewed.science/isaac.johnson@gmail.com')
+    apobj.add('msteams://MSTEAMSTOKENHERE')
+    apobj.notify(
+        body='Notified by Cloud Run Function',
+        title='From Python Cloud Run',
+    )
+    return "Hello hello {}!".format(name)
+

 if __name__ == "__main__":
     app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))

which triggered a build and deploy

/content/images/2022/10/pythonfunc-07.png

Once it was pushed, I did a local docker pull from gcr.io, tag, and push back to harbor. Then I rotated the pod to see it pull a fresh image

Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  27s   default-scheduler  Successfully assigned default/python-crfunction-7d44797b8b-9m5gm to builder-hp-elitebook-850-g2
  Normal  Pulling    27s   kubelet            Pulling image "harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest"
  Normal  Pulled     14s   kubelet            Successfully pulled image "harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest" in 13.365231702s
  Normal  Created    14s   kubelet            Created container python-crfunction
  Normal  Started    14s   kubelet            Started container python-crfunction
  Normal  Pulled     14s   kubelet            Container image "docker.io/daprio/daprd:1.8.2" already present on machine
  Normal  Created    14s   kubelet            Created container daprd
  Normal  Started    14s   kubelet            Started container daprd

Now when i apply an fresh ingress yaml:

$ cat python-crfunction.ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/app-root: /
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/add-base-url : "true"
    kubernetes.io/tls-acme: "true"
  name: python-crfunction
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - host: python-crfunction.freshbrewed.science
    http:
      paths:
      - backend:
          service:
            name: python-crfunction
            port:
              number: 80
        path: /asdf1234
        pathType: Prefix
      - backend:
          service:
            name: python-crfunction
            port:
              number: 80
        path: /asdf1234/*
        pathType: Prefix
  tls:
  - hosts:
    - python-crfunction.freshbrewed.science
    secretName: python-crfunction-tls

$ kubectl delete -f python-crfunction.ingress.yaml
ingress.networking.k8s.io "python-crfunction" deleted
$ kubectl apply -f python-crfunction.ingress.yaml
ingress.networking.k8s.io/python-crfunction created

Then I can hit the URL on the “secret” path

/content/images/2022/10/pythonfunc-08.png

but not the short path

/content/images/2022/10/pythonfunc-09.png

Azure Function

First, I’ll login to azure

$ az login --use-device-code
To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code R57USFYFS to authenticate.
The following tenants don't contain accessible subscriptions. Use 'az login --allow-no-subscriptions' to have tenant level access.
...

Next, I’ll create a Resource Group to hold the storage account and function. Optionally, I could also create an ACR here as well

$ az group create --name pythonfunction-rg --location centralus
{
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/pythonfunction-rg",
  "location": "centralus",
  "managedBy": null,
  "name": "pythonfunction-rg",
  "properties": {
    "provisioningState": "Succeeded"
  },
  "tags": null,
  "type": "Microsoft.Resources/resourceGroups"
}

$ az storage account create --name ijfuncstorage1 --location centralus --resource-group pythonfunc
tion-rg --sku Standard_LRS
{
  "accessTier": "Hot",
  "allowBlobPublicAccess": true,
  "allowCrossTenantReplication": null,
  "allowSharedKeyAccess": null,
  "allowedCopyScope": null,
  "azureFilesIdentityBasedAuthentication": null,
  "blobRestoreStatus": null,
  "creationTime": "2022-10-06T13:47:17.943785+00:00",
  ...

The next step can cost us money if we don’t pay attention. Here we’ll create a Premium App Service Plan to run the workers

$ az functionapp plan create --resource-group pythonfunction-rg --name funcPremiumPlan --location
centralus --number-of-workers 1 --sku EP1 --is-linux
{
  "elasticScaleEnabled": true,
  "extendedLocation": null,
  "freeOfferExpirationTime": null,
  "geoRegion": "Central US",
  "hostingEnvironmentProfile": null,
  "hyperV": false,
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/pythonfunction-rg/providers/Microsoft.Web/serverfarms/funcPremiumPlan",
  "isSpot": false,
  "isXenon": false,
  "kind": "elastic",
  "kubeEnvironmentProfile": null,
  "location": "centralus",
  "maximumElasticWorkerCount": 1,
  "maximumNumberOfWorkers": 0,
  "name": "funcPremiumPlan",
  "numberOfSites": 0,
  "numberOfWorkers": 1,
  "perSiteScaling": false,
  "provisioningState": "Succeeded",
  "reserved": true,
  "resourceGroup": "pythonfunction-rg",
  "sku": {
    "capabilities": null,
    "capacity": 1,
    "family": "EP",
    "locations": null,
    "name": "EP1",
    "size": "EP1",
    "skuCapacity": null,
    "tier": "ElasticPremium"
  },
  "spotExpirationTime": null,
  "status": "Ready",
  "subscription": "d955c0ba-13dc-44cf-a29a-8fed74cbb22d",
  "tags": null,
  "targetWorkerCount": 0,
  "targetWorkerSizeId": 0,
  "type": "Microsoft.Web/serverfarms",
  "workerTierName": null,
  "zoneRedundant": false
}

The last step is to deploy. You’ll see how we add a docker username and password so we can pull from our private registry

$ az functionapp create --name ijpythonfunction1 --storage-account ijfuncstorage1 --resource-group pythonfunction-rg --plan funcPremiumPlan --deployment-container-image-name harbor.freshbrewed.science/freshbrewedprivate/pythonfunction:latest --docker-registry-server-user imagepuller --docker-registry-server-password ******************
No functions version specified so defaulting to 3. In the future, specifying a version will be required. To create a 3.x function you would pass in the flag `--functions-version 3`
Application Insights "ijpythonfunction1" was created for this Function App. You can visit https://portal.azure.com/#resource/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/pythonfunction-rg/providers/microsoft.insights/components/ijpythonfunction1/overview to view your Application Insights component
{
  "availabilityState": "Normal",
  "clientAffinityEnabled": false,
  "clientCertEnabled": false,
  "clientCertExclusionPaths": null,
  "clientCertMode": "Required",
  "cloningInfo": null,
  "containerSize": 0,
  "customDomainVerificationId": "1B4C8E9BFA263939F5437F88623F1C0397DE707EB4D40A3AB2A7B071129D28ED",
  "dailyMemoryTimeQuota": 0,
  "defaultHostName": "ijpythonfunction1.azurewebsites.net",
  "enabled": true,
  "enabledHostNames": [
    "ijpythonfunction1.azurewebsites.net",
    "ijpythonfunction1.scm.azurewebsites.net"
  ],
  "extendedLocation": null,
  "hostNameSslStates": [
    {
      "hostType": "Standard",
      "ipBasedSslResult": null,
      "ipBasedSslState": "NotConfigured",
      "name": "ijpythonfunction1.azurewebsites.net",
      "sslState": "Disabled",
      "thumbprint": null,
      "toUpdate": null,
      "toUpdateIpBasedSsl": null,
      "virtualIp": null
    },
    {
      "hostType": "Repository",
      "ipBasedSslResult": null,
      "ipBasedSslState": "NotConfigured",
      "name": "ijpythonfunction1.scm.azurewebsites.net",
      "sslState": "Disabled",
      "thumbprint": null,
      "toUpdate": null,
      "toUpdateIpBasedSsl": null,
      "virtualIp": null
    }
  ],
  "hostNames": [
    "ijpythonfunction1.azurewebsites.net"
  ],
  "hostNamesDisabled": false,
  "hostingEnvironmentProfile": null,
  "httpsOnly": false,
  "hyperV": false,
  "id": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/pythonfunction-rg/providers/Microsoft.Web/sites/ijpythonfunction1",
  "identity": null,
  "inProgressOperationId": null,
  "isDefaultContainer": null,
  "isXenon": false,
  "keyVaultReferenceIdentity": "SystemAssigned",
  "kind": "functionapp,linux",
  "lastModifiedTimeUtc": "2022-10-06T13:54:12.153333",
  "location": "Central US",
  "maxNumberOfWorkers": null,
  "name": "ijpythonfunction1",
  "outboundIpAddresses": "20.112.192.230,20.112.192.241,20.112.192.249,20.112.193.15,20.112.193.17,20.112.193.109,20.118.56.8",
  "possibleOutboundIpAddresses": "20.112.192.180,20.112.192.185,20.112.192.189,20.112.192.203,20.112.192.204,20.112.192.214,20.112.192.230,20.112.192.241,20.112.192.249,20.112.193.15,20.112.193.17,20.112.193.109,20.112.193.136,20.112.193.171,20.112.193.173,20.112.193.182,20.112.193.188,20.112.193.197,20.112.193.200,20.112.193.217,20.112.193.220,20.112.193.223,20.112.193.234,20.112.193.238,20.118.56.8",
  "publicNetworkAccess": null,
  "redundancyMode": "None",
  "repositorySiteName": "ijpythonfunction1",
  "reserved": true,
  "resourceGroup": "pythonfunction-rg",
  "scmSiteAlsoStopped": false,
  "serverFarmId": "/subscriptions/d955c0ba-13dc-44cf-a29a-8fed74cbb22d/resourceGroups/pythonfunction-rg/providers/Microsoft.Web/serverfarms/funcPremiumPlan",
  "siteConfig": {
    "acrUseManagedIdentityCreds": false,
    "acrUserManagedIdentityId": null,
    "alwaysOn": false,
    "antivirusScanEnabled": null,
    "apiDefinition": null,
    "apiManagementConfig": null,
    "appCommandLine": null,
    "appSettings": null,
    "autoHealEnabled": null,
    "autoHealRules": null,
    "autoSwapSlotName": null,
    "azureMonitorLogCategories": null,
    "azureStorageAccounts": null,
    "connectionStrings": null,
    "cors": null,
    "customAppPoolIdentityAdminState": null,
    "customAppPoolIdentityTenantState": null,
    "defaultDocuments": null,
    "detailedErrorLoggingEnabled": null,
    "documentRoot": null,
    "elasticWebAppScaleLimit": null,
    "experiments": null,
    "fileChangeAuditEnabled": null,
    "ftpsState": null,
    "functionAppScaleLimit": 0,
    "functionsRuntimeScaleMonitoringEnabled": null,
    "handlerMappings": null,
    "healthCheckPath": null,
    "http20Enabled": false,
    "http20ProxyFlag": null,
    "httpLoggingEnabled": null,
    "ipSecurityRestrictions": [
      {
        "action": "Allow",
        "description": "Allow all access",
        "headers": null,
        "ipAddress": "Any",
        "name": "Allow all",
        "priority": 2147483647,
        "subnetMask": null,
        "subnetTrafficTag": null,
        "tag": null,
        "vnetSubnetResourceId": null,
        "vnetTrafficTag": null
      }
    ],
    "ipSecurityRestrictionsDefaultAction": null,
    "javaContainer": null,
    "javaContainerVersion": null,
    "javaVersion": null,
    "keyVaultReferenceIdentity": null,
    "limits": null,
    "linuxFxVersion": "",
    "loadBalancing": null,
    "localMySqlEnabled": null,
    "logsDirectorySizeLimit": null,
    "machineKey": null,
    "managedPipelineMode": null,
    "managedServiceIdentityId": null,
    "metadata": null,
    "minTlsCipherSuite": null,
    "minTlsVersion": null,
    "minimumElasticInstanceCount": 0,
    "netFrameworkVersion": null,
    "nodeVersion": null,
    "numberOfWorkers": 1,
    "phpVersion": null,
    "powerShellVersion": null,
    "preWarmedInstanceCount": null,
    "publicNetworkAccess": null,
    "publishingPassword": null,
    "publishingUsername": null,
    "push": null,
    "pythonVersion": null,
    "remoteDebuggingEnabled": null,
    "remoteDebuggingVersion": null,
    "requestTracingEnabled": null,
    "requestTracingExpirationTime": null,
    "routingRules": null,
    "runtimeADUser": null,
    "runtimeADUserPassword": null,
    "scmIpSecurityRestrictions": [
      {
        "action": "Allow",
        "description": "Allow all access",
        "headers": null,
        "ipAddress": "Any",
        "name": "Allow all",
        "priority": 2147483647,
        "subnetMask": null,
        "subnetTrafficTag": null,
        "tag": null,
        "vnetSubnetResourceId": null,
        "vnetTrafficTag": null
      }
    ],
    "scmIpSecurityRestrictionsDefaultAction": null,
    "scmIpSecurityRestrictionsUseMain": null,
    "scmMinTlsVersion": null,
    "scmType": null,
    "sitePort": null,
    "storageType": null,
    "supportedTlsCipherSuites": null,
    "tracingOptions": null,
    "use32BitWorkerProcess": null,
    "virtualApplications": null,
    "vnetName": null,
    "vnetPrivatePortsCount": null,
    "vnetRouteAllEnabled": null,
    "webSocketsEnabled": null,
    "websiteTimeZone": null,
    "winAuthAdminState": null,
    "winAuthTenantState": null,
    "windowsFxVersion": null,
    "xManagedServiceIdentityId": null
  },
  "slotSwapStatus": null,
  "state": "Running",
  "storageAccountRequired": false,
  "suspendedTill": null,
  "tags": null,
  "targetSwapSlot": null,
  "trafficManagerHostNames": null,
  "type": "Microsoft.Web/sites",
  "usageState": "Normal",
  "virtualNetworkSubnetId": null,
  "vnetContentShareEnabled": false,
  "vnetImagePullEnabled": false,
  "vnetRouteAllEnabled": false
}

And we can test and see we are now hosted as an azure function app as well

/content/images/2022/10/pythonfunc-10.png

We can also test our “secret” endpoint of asdf1234 and see it responds (but others like asdfasdf are ignored)

/content/images/2022/10/pythonfunc-11.png

In Azure we can look at metrics and logs in app insights or basic metrics on the Function App itself, such as memory usage

/content/images/2022/10/pythonfunc-12.png

We can use App Insights to check similar, such as memory usage or number of 404s returned

/content/images/2022/10/pythonfunc-13.png

In some cases, you may be able to get a shell to the running container (I was not able to with this function container)

/content/images/2022/10/pythonfunc-14.png

Add an Identity Provider

/content/images/2022/10/pythonfunc-15.png

We can then choose an IdP such as Google, Facebook, Github and more. Here I’ll just use Microsoft

/content/images/2022/10/pythonfunc-16.png

I’ll accept the default permission (read from Microsoft Graph)

And now we can see we require auth to hit the endpoint

/content/images/2022/10/pythonfunc-17.png

If I use my work account, it routes through our MFA

/content/images/2022/10/pythonfunc-18.png

and I’ll be rejected

/content/images/2022/10/pythonfunc-19.png

But if I go back to my gmail account (The Microsoft account tied to this function), I have the choice to allow this App to see my identity

/content/images/2022/10/pythonfunc-20.png

And upon accept, I’m redirected via the normal OAuth flow back to the app

/content/images/2022/10/pythonfunc-21.png

Summary

We covered pulling the container we built for cloud run and using it in our local kubernetes cluster. This involved pushing to a different CR, then using a Deployment and Service definition paired with an Ingress one.

We covered updating our container to use a “secret” path that could a length token that could be rotated. There are other ways to secure this we will cover next time.

We setup the function app as an Azure Function app including showing how we pull from our own private container registry. Lastly, we checked out some of the features of Azure App Services.

sonarqube python testing

Have something to add? Feedback? You can use the feedback form

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes