Signals & Pixels

Deploying Earth Engine Cloud Functions using IaC

Running Earth Engine code as a serverless function is normally a multi-step process that involves manually creating a service account through the web UI, downloading a credential file to zip with your code, and enabling and configuring a handful of APIs. It’s a hassle and a great opportunity for human error with misconfigured services and accidentally committed credentials.

Infrastructure-as-code (IaC) allows you to set up, configure, and deploy cloud infrastructure programmatically instead of manually, turning complicated configuration instructions into easily reproducible scripts.

To demonstrate, let’s set up a Google Cloud Run function to execute some Earth Engine code which we can trigger by visiting a URL, using an IaC tool called Pulumi.

The Infrastructure

Here’s the Earth Engine code I want to run – just a quick demo that returns the cloud cover1 of the most recently ingested Landsat 9 image:

import ee

def main():
    # TODO: Initialize Earth Engine

    now = datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000    
    last_cloud_cover = (
        ee.ImageCollection("LANDSAT/LC09/C02/T1")
        .filterDate(now - 172_800_000, now)
        .sort("system:time_start", False)
        .first()
        .get("CLOUD_COVER")
    )
    
    try:
        return f"The last Landsat 9 image was {last_cloud_cover.getInfo():.2f}% cloudy."
    except Exception as e:
        logging.error(e)
        return "No new Landsat 9 acquisitions in the last 48 hours..."

To run that in the cloud, we’ll need:

The Code

You can see the full code and setup instructions here, but I’ll break down the important chunks below, all of which live in a __main__.py file at the project root.

Enable APIs

Let’s start by enabling the various APIs we’ll need. Rather than clicking through these one by one in the web UI, we can set them up in one go by simply declaring the required services.

import pulumi
import pulumi_gcp as gcp

earthengine = gcp.projects.Service("earthengine-api", service="earthengine.googleapis.com")
cloudbuild = gcp.projects.Service("cloudbuild-api", service="cloudbuild.googleapis.com")
cloudfunctions = gcp.projects.Service("cloudfunctions-api", service="cloudfunctions.googleapis.com")
run = gcp.projects.Service("run-api", service="run.googleapis.com")

Note: You will still need to register the project in Earth Engine separately to specify whether it’s a commercial or non-commercial project.

Create the Credentials

We’ll need a service account to authenticate our cloud function. We can set up the account and an access key with:

account = gcp.serviceaccount.Account(
    "service-account",
    account_id="demo-service-account",
    display_name="Demo Service Account"
)

key = gcp.serviceaccount.Key(
    "service-key",
    service_account_id=account.name,
)

Earth Engine can initialized from a service account using either a credential file or a credential string. To avoid the security risk of having to store and upload a secret file, we’ll eventually pass the credentials as an environment variable to our cloud function. For now, we’ll just update the function itself to load the key, and worry about exposing it later:

def main(_):
    key_data = os.environ["SERVICE_ACCOUNT_KEY"]

    credentials = ee.ServiceAccountCredentials(None, key_data=key_data)
    ee.Initialize(credentials)
    ...

Build the Bucket

In order to deploy a cloud function, it needs to be stored somewhere. We’ll create a cloud bucket and a bucket object to store our code from the local function folder:

# Create a GCS bucket to store cloud functions
bucket = gcp.storage.Bucket(
    "bucket",
    location="US",
)

# Write the zipped function to the cloud bucket
function_object = gcp.storage.BucketObject(
    "ee-function-source",
    bucket=bucket.name,
    source=pulumi.FileArchive("./function"),
)

Export the Function

Finally, we can create the cloud function by specifying a Python runtime, the file name, the bucket where it’s located, and the service account credentials to authenticate it.

Pulumi builds infrastructure in parallel, so when one stage depends on another stage, that needs to be handled explicitly. In this case, we can only build a cloud function once we’ve successfully enabled the related APIs, so we’ll mark those as dependencies using opts. Otherwise, the cloud function would fail to deploy the first time and would need to be re-deployed.

function = gcp.cloudfunctionsv2.Function(
    "ee-function",
    location="us-central1",
    description="A demo serverless EE function",
    build_config={
        "runtime": "python311",
        "entry_point": "main",
        "source": {
            "storage_source": {
                "bucket": function_object.bucket,
                "object": function_object.name,
            },
        },
    },
    # Minimal config to reduce potential cost
    service_config={
        "max_instance_count": 1,
        "available_memory": "128Mi",
        "timeout_seconds": 5,
        # Expose the service account credentials to the function at runtime
        "environment_variables": {
            "SERVICE_ACCOUNT_KEY": key.private_key.apply(lambda k: base64.b64decode(k).decode()),
        }
    },
    # Wait for required APIs to enable before attempting to build to function
    opts=pulumi.ResourceOptions(depends_on=[cloudbuild, cloudfunctions, run]),
)

One last step is to (optionally) allow public access to our function, so that anyone can trigger it from the URL, which we’ll export to the terminal for convenience:

run_invoker = gcp.cloudrun.IamBinding(
    "run-invoker",
    location=function.location,
    service=function.name,
    role="roles/run.invoker",
    members=["allUsers"],
)

pulumi.export("function", function.url)

Deploying

If everything is set up correctly, running pulumi up will build out the entire cloud infrastructure for the project, deploy the function, and print the URL where we can run it:

https://ee-function-38e19d5-225331302352.us-central1.run.app/

Clicking that link now gives us the cloud cover of the most recent Landsat 9 acquisition, computed in the cloud!

When we’re done, running pulumi destroy will deconstruct all of the infrastructure that it built, taking us back to a clean slate.


  1. Pun fully intended. ↩︎

#Python #Earth-Engine #Google-Cloud