Pic-a-daily: Lab 3—Create a collage of most recent pictures

1. Overview

In this code lab, you create a new Cloud Run service, collage service, that will be triggered by Cloud Scheduler at a regular interval of time. The service fetches the latest pictures uploaded and creates a collage of those pictures: it finds the list of recent pictures in Cloud Firestore, and then downloads the actual picture files from Cloud Storage.

df20f5d0402b54b4.png

What you'll learn

  • Cloud Run
  • Cloud Scheduler
  • Cloud Storage
  • Cloud Firestore

2. Setup and Requirements

Self-paced environment setup

  1. Sign-in to the Google Cloud Console and create a new project or reuse an existing one. If you don't already have a Gmail or Google Workspace account, you must create one.

96a9c957bc475304.png

b9a10ebdf5b5a448.png

a1e3c01a38fa61c2.png

  • The Project name is the display name for this project's participants. It is a character string not used by Google APIs, and you can update it at any time.
  • The Project ID must be unique across all Google Cloud projects and is immutable (cannot be changed after it has been set). The Cloud Console auto-generates a unique string; usually you don't care what it is. In most codelabs, you'll need to reference the Project ID (and it is typically identified as PROJECT_ID), so if you don't like it, generate another random one, or, you can try your own and see if it's available. Then it's "frozen" after the project is created.
  • There is a third value, a Project Number which some APIs use. Learn more about all three of these values in the documentation.
  1. Next, you'll need to enable billing in the Cloud Console in order to use Cloud resources/APIs. Running through this codelab shouldn't cost much, if anything at all. To shut down resources so you don't incur billing beyond this tutorial, follow any "clean-up" instructions found at the end of the codelab. New users of Google Cloud are eligible for the $300 USD Free Trial program.

Start Cloud Shell

While Google Cloud can be operated remotely from your laptop, in this codelab you will be using Google Cloud Shell, a command line environment running in the Cloud.

From the GCP Console click the Cloud Shell icon on the top right toolbar:

bce75f34b2c53987.png

It should only take a few moments to provision and connect to the environment. When it is finished, you should see something like this:

f6ef2b5f13479f3a.png

This virtual machine is loaded with all the development tools you'll need. It offers a persistent 5GB home directory, and runs on Google Cloud, greatly enhancing network performance and authentication. All of your work in this lab can be done with simply a browser.

3. Enable APIs

You will need a Cloud Scheduler to trigger the Cloud Run service on a regular interval. Make sure it is enabled:

gcloud services enable cloudscheduler.googleapis.com

You should see the operation to finish successfully:

Operation "operations/acf.5c5ef4f6-f734-455d-b2f0-ee70b5a17322" finished successfully.

4. Clone the code

Clone the code, if you haven't already in the previous code lab:

git clone https://github.com/GoogleCloudPlatform/serverless-photosharing-workshop

You can then go to the directory containing the service:

cd serverless-photosharing-workshop/services/collage/nodejs

You will have the following file layout for the service:

services
 |
 ├── collage
      |
      ├── nodejs
           |
           ├── Dockerfile
           ├── index.js
           ├── package.json

Inside the folder,, you have 3 files:

  • index.js contains the Node.js code
  • package.json defines the library dependencies
  • Dockerfile defines the container image

5. Explore the code

Dependencies

The package.json file defines the needed library dependencies:

{
  "name": "collage_service",
  "version": "0.0.1",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "bluebird": "^3.7.2",
    "express": "^4.17.1",
    "imagemagick": "^0.1.3",
    "@google-cloud/firestore": "^4.9.9",
    "@google-cloud/storage": "^5.8.3"
  }
}

We depend on the Cloud Storage library to read and save image files within Cloud Storage. We declare a dependency on Cloud Firestore, to fetch picture metadata that we stored previously. Express is a JavaScript / Node web framework. Bluebird is used for handling promises, and imagemagick is a library for manipulating images.

Dockerfile

Dockerfile defines the container image for the application:

FROM node:14-slim

# installing Imagemagick
RUN set -ex; \
  apt-get -y update; \
  apt-get -y install imagemagick; \
  rm -rf /var/lib/apt/lists/*

WORKDIR /picadaily/services/collage
COPY package*.json ./
RUN npm install --production
COPY . .
CMD [ "npm", "start" ]

We're using a light Node 14 base image. We're installing the imagemagick library. Then we're installing the NPM modules needed by our code, and we run our node code with npm start.

index.js

Let's have a closer look at our index.js code:

const express = require('express');
const imageMagick = require('imagemagick');
const Promise = require("bluebird");
const path = require('path');
const {Storage} = require('@google-cloud/storage');
const Firestore = require('@google-cloud/firestore');

We require the various dependencies needed for our program to run: Express is the Node web framework we will be using, ImageMagick the library for doing image manipulation, Bluebird is a library for handling JavaScript promises, Path is used for dealing with files and directories paths, and then Storage and Firestore are for working respectively with Google Cloud Storage (our buckets of images), and the Cloud Firestore datastore.

const app = express();

app.get('/', async (req, res) => {
    try {
        console.log('Collage request');

        /* ... */

    } catch (err) {
        console.log(`Error: creating the collage: ${err}`);
        console.error(err);
        res.status(500).send(err);
    }
});

Above, we have the structure of our Node handler: our app responds to HTTP GET requests. And we're doing a bit of error handling in case something goes wrong. Let's now have a look at what is inside this structure.

const thumbnailFiles = [];
const pictureStore = new Firestore().collection('pictures');
const snapshot = await pictureStore
    .where('thumbnail', '==', true)
    .orderBy('created', 'desc')
    .limit(4).get();

if (snapshot.empty) {
    console.log('Empty collection, no collage to make');
    res.status(204).send("No collage created.");
} else {

    /* ... */

}

Our collage service will need at least four pictures (whose thumbnails have been generated), so be sure to upload 4 pictures first.

We retrieve the 4 latest pictures uploaded by our users, from the metadata stored in Cloud Firerstore. We check if the resulting collection is empty or not, and then proceed further in the else branch of our code.

Let's collect the list of file names:

snapshot.forEach(doc => {
    thumbnailFiles.push(doc.id);
});
console.log(`Picture file names: ${JSON.stringify(thumbnailFiles)}`);

We are going to download each of those files from the thumbnail bucket, whose name is coming from an environment variable that we set at deployment time:

const thumbBucket = storage.bucket(process.env.BUCKET_THUMBNAILS);

await Promise.all(thumbnailFiles.map(async fileName => {
    const filePath = path.resolve('/tmp', fileName);
    await thumbBucket.file(fileName).download({
        destination: filePath
    });
}));
console.log('Downloaded all thumbnails');

Once the latest thumbnails are uploaded, we're going to use the ImageMagick library to create a 4x4 grid of those thumbnail pictures. We use the Bluebird library and its Promise implementation to transform the callback-driven code into async / await friendly code, then we await on the promise that is making the image collage:

const collagePath = path.resolve('/tmp', 'collage.png');

const thumbnailPaths = thumbnailFiles.map(f => path.resolve('/tmp', f));
const convert = Promise.promisify(im.convert);
await convert([
    '(', ...thumbnailPaths.slice(0, 2), '+append', ')',
    '(', ...thumbnailPaths.slice(2), '+append', ')',
    '-size', '400x400', 'xc:none', '-background', 'none',  '-append',
    collagePath]);
console.log("Created local collage picture");

As the collage picture has been saved to disk locally in the temporary folder, we now need to upload it to Cloud Storage, and then return a successful response (status code 2xx):

await thumbBucket.upload(collagePath);
console.log("Uploaded collage to Cloud Storage bucket ${process.env.BUCKET_THUMBNAILS}");

res.status(204).send("Collage created.");

Now time to make our Node script listen to incoming requests:

const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
    console.log(`Started collage service on port ${PORT}`);
});

At the end of our source file, we have the instructions to have Express actually start our web application on the 8080 default port.

6. Test locally

Test the code locally to make sure it works before deploying to the cloud.

Inside collage/nodejs folder, install npm dependencies and start the server:

npm install; npm start

If everything went well, it should start the server on port 8080:

Started collage service on port 8080

Use CTRL-C to exit.

7. Build and deploy to Cloud Run

Before deploying to Cloud Run, set the Cloud Run region to one of the supported regions and platform to managed:

gcloud config set run/region europe-west1
gcloud config set run/platform managed

You can check that the configuration is set:

gcloud config list

...
[run]
platform = managed
region = europe-west1

Instead of building and publishing the container image using Cloud Build manually, you can also rely on Cloud Run to build the container image for you using Google Cloud Buildpacks.

Run the following command to build the container image:

BUCKET_THUMBNAILS=thumbnails-$GOOGLE_CLOUD_PROJECT
SERVICE_NAME=collage-service
gcloud run deploy $SERVICE_NAME \
    --source . \
    --no-allow-unauthenticated \
    --update-env-vars BUCKET_THUMBNAILS=$BUCKET_THUMBNAILS

Note the –-source flag. This is the source based deployment in Cloud Run. If a Dockerfile is present in the source code directory, the uploaded source code is built using that Dockerfile. If no Dockerfile is present in the source code directory, Google Cloud buildpacks automatically detects the language you are using and fetches the dependencies of the code to make a production-ready container image, using a secure base image managed by Google. This flags Cloud Run to use Google Cloud Buildpacks to build the container image defined in Dockerfile.

The --no-allow-unauthenticated flag makes the Cloud Run service an internal service that will only be triggered by specific service accounts.

8. Set up Cloud Scheduler

Now that the Cloud Run service is ready and deployed, it's time to create the regular schedule, to invoke the service every minute.

Create a service account:

SERVICE_ACCOUNT=collage-scheduler-sa
gcloud iam service-accounts create $SERVICE_ACCOUNT \
   --display-name "Collage Scheduler Service Account"

Give service account permission to invoke the Cloud Run service:

gcloud run services add-iam-policy-binding $SERVICE_NAME \
   --member=serviceAccount:$SERVICE_ACCOUNT@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
   --role=roles/run.invoker

Create a Cloud Scheduler job to execute every 1 minute:

SERVICE_URL=$(gcloud run services describe $SERVICE_NAME --format 'value(status.url)')
gcloud scheduler jobs create http $SERVICE_NAME-job --schedule "* * * * *" \
   --http-method=GET \
   --uri=$SERVICE_URL \
   --oidc-service-account-email=$SERVICE_ACCOUNT@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
   --oidc-token-audience=$SERVICE_URL

You can go to Cloud Scheduler section in Cloud Console to see that it is setup and pointing to Cloud Run service url:

35119e28c1da53f3.png

9. Test the service

To test if the setup is working, check in the thumbnails bucket for the collage image (called collage.png). You can also check the logs of the service:

93922335a384be2e.png

10. Clean up (Optional)

If you don't intend to continue with the other labs in the series, you can clean up resources to save costs and to be an overall good cloud citizen. You can clean up resources individually as follows.

Delete the service:

gcloud run services delete $SERVICE_NAME -q

Delete the Cloud Scheduler job:

gcloud scheduler jobs delete $SERVICE_NAME-job -q

Alternatively, you can delete the whole project:

gcloud projects delete $GOOGLE_CLOUD_PROJECT

11. Congratulations!

Congratulations! You created a scheduled service: thanks to Cloud Scheduler, which pushes a message every minute on a Pub/Sub topic, your Cloud Run collage service is invoked and is able to append pictures together to create the resulting picture.

What we've covered

  • Cloud Run
  • Cloud Scheduler
  • Cloud Storage
  • Cloud Firestore

Next Steps