Deploy a Ktor Service Docker Image from Artifact Registry to Cloud Run Using Gradle

In this post, we’ll walk through how to deploy a Ktor service Docker container from Artifact Registry to Cloud Run Using Gradle. This post builds on our two previous posts:

Using the approaches outlined in the previous series posts, we can leverage Gradle to automate build tasks for our service deployment.  Primarily, we can use Gradle to compile our code, build our Docker image and deploy it to some container registry such as Artifact Registry.

To complete our deployment pipeline, we’ll once again use Gradle to automate the deployment of a Docker image from Artifact Registry to Cloud Run.  By doing so, we can leverage Gradle’s task avoidance characteristics to avoid building or deploying if our source code hasn’t changed. Additionally, we keep deployment logic contained in a common language and build system as the rest of our Ktor service code; making it easier for developers to maintain both the application and deployment code.

Prerequisites

Since we’re deploying our Ktor service container to Artifact Registry, that comes with a new Google Cloud Platform (GCP) prerequisites.

  1. We need to have access to a GCP organization
    1. How to setup a GCP organization?
    2. You’ll eventually need to set up a billing account for your organization.  If you want to try GCP for learning purposes, there are often ways to find free, or discounted, billing credits
      1. Trial period with $300 free credits for new users
      2. GCP credits for faculty and students
      3. Google Cloud Innovators Plugs program
  2. We need to have a project set up within that organization
    1. How to create and manage GCP projects?
  3. Artifact Registry must be enabled within that project
    1. How to enable Artifact Registry for a GCP project?
  4. Cloud Run must be enabled within that project
    1. How to enable Cloud Run for a GCP project?

Reviewing Artifact Registry Deployment

Before we start on deploying to Cloud Run, let’s review our Gradle buildscript configuration for publishing our Ktor service to Artifact Registry.

We have a single Gradle module named :service to start. We have a simple class ArtifactRegistry that implements the DockerImageRegistry interface coming from the Ktor plugin.   This allows us to configure the needed information to publish our built Docker image to Artifact Registry.

// :service/build.gradle.kts

private class ArtifactRegistry(
    projectName: Provider<String>,
    regionName: Provider<String>,
    repositoryName: Provider<String>,
    imageName: Provider<String>,
) : DockerImageRegistry {
    override val username: Provider<String> = provider { "" }
    override val password: Provider<String> = provider { "" }
    override val toImage: Provider<String> = provider {
        val region = regionName.get()
        val project = projectName.get()
        val repository = repositoryName.get()
        val image = imageName.get()

        "$region-docker.pkg.dev/$project/$repository/$image"
    }
}

Next, we can configure an instance of our ArtifactRegistry class; pulling the configuration data from environment variables and project properties.

// :service/build.gradle.kts

// deployment config
private val deploymentProjectName = System.getenv("GCP_CONTAINER_PROJECT_NAME")
private val deploymentRegion = System.getenv("GCP_REGION")
private val artifactRegistryContainerName = System.getenv("GCP_CONTAINER_NAME")
private val deploymentEnv = System.getenv("ENV")
private val deployedImageTag: String = "${rootProject.version}"
private val deployedImageName = rootProject.name

private val artifactRegistryConfig = ArtifactRegistry(
    projectName = provider { deploymentProjectName },
    regionName = provider { deploymentRegion },
    repositoryName = provider { artifactRegistryContainerName },
    imageName = provider { deployedImageName },
)

Lastly, we put it all together by configuring the Ktor plugin to build the Docker image and set our ArtifactRegistry config property as the externalRegistry for publication.

// :service/build.gradle.kts

ktor {
    docker {
        jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_17)
        localImageName.set(deployedImageName)
        imageTag.set(deployedImageTag)

        portMappings.set(
            listOf(
                io.ktor.plugin.features.DockerPortMapping(
                    outsideDocker = 8080,
                    insideDocker = 8080,
                    io.ktor.plugin.features.DockerPortMappingProtocol.TCP,
                ),
            ),
        )
        externalRegistry.set(artifactRegistryConfig)
    }
}

With our build.gradle.kts file configured in this way, we can run the publishImage Gradle task to build our Ktor project as a Docker image and publish it to Artifact Registry.

Building a Custom Gradle Plugin to Deploy from Artifact Registry to Cloud Run

What are we trying to automate?

To deploy an image from Artifact Registry to Cloud Run we have several options. Perhaps the most straightforward is using the gcloud cli.  With gcloud, we could build up a deployment command like the following:

gcloud run deploy <service name> --image <docker image path> --region <deployed gcp region>

This command would take the specified Artifact Registry image, and deploy it to Cloud Run as the specified service name in the specified region.

There are plenty of ways we could automate this command for deployment.  For our purposes, we will use Gradle so our build and deployment commands are consolidated into a single language/tool pairing.

What should the final buildscript configuration look like?

Our end goal is to be able to configure our Cloud Run deployment from our build.gradle.kts file in a similar way to how we configure our Artifact Registry deployment.  The final result should look something like this:

cloudRunDeploy {
    deployedName.set(deployedImageName)
    deployedRegion.set(deploymentRegion)
    image.set(artifactRegistryConfig.toImage)
    imageTag.set(deployedImageTag)
    env.set(deploymentEnv)
}

Creating the Gradle plugin

To achieve this, we’ll start by creating a new Gradle plugin project named :cloudrun-deploy-plugin and adding it as an included build to our main Gradle project. Our :service module will eventually apply the plugin provided by :cloudun-deploy-plugin to enable our Cloud Run deployment.

Before that, we need to update our root settings.gradle.kts in add this new Gradle project as an included build:

// settings/gradle.kts

includeBuild("cloudrun-deploy-plugin")

Creating a configuration extension interface

With the plugin project created, we can create an interface defining properties for configuring the Cloud Run deployment.

// :cloudrun-deploy-plugin/build.gradle.kts

interface CloudRunDeployPluginExtension {
    val deployedName: Property<String>
    val deployedRegion: Property<String>
    val image: Property<String>
    val imageTag: Property<String>
    val env: Property<String>
}

Notice that these properties match the properties we configured in our :service/build.gradle.kts file.

Now that we have this interface, we can stub out how we will configure Cloud Run deployment.

We will add an extension method on the Gradle Project type that takes an instance of CloudRunDeployPluginExtension as a receiver.  This will provide us the configuration syntax we’re looking for and allow us to retrieve these configuration values when needed during task execution.

// :cloudrun-deploy-plugin/build.gradle.kts

fun Project.cloudRunDeploy(configure: Action<CloudRunDeployPluginExtension>) {
    (this as org.gradle.api.plugins.ExtensionAware).extensions.configure(EXTENSION_NAME, configure)
}

Defining a deployment Gradle task

Now, we just need two more things to pull this all together.  We need a custom Gradle task to do the deployment. And, we need to define the Gradle plugin that will apply that task to our Ktor project.

We’ll start with the task. 

We’ll create a new class CloudRunDeployTask that extends Gradle’s DefaultTask type.

// :cloudrun-deploy-plugin/build.gradle.kts

private abstract class CloudRunDeployTask : DefaultTask() {

    @get:Inject
    abstract val exec: ExecOperations
    @get:Input
    abstract val deployedName: Property<String>
    @get:Input
    abstract val image: Property<String>
    @get:Input
    abstract val imageTag: Property<String>
    @get:Input
    abstract val deployedRegion: Property<String>
    @get:Input
    abstract val env: Property<String>

    init {
        group = TASK_GROUP
    }

    @TaskAction
    fun deploy() {
        val deployCommand = "gcloud run deploy ${deployedName.get()} --image ${image.get()}:${imageTag.get()} --region ${deployedRegion.get()} --set-env-vars KTOR_ENV=${env.get()}"
        logger.warn("Executing: $deployCommand")
        exec.exec { commandLine(deployCommand.split(" ")) }
    }
}

There are a few important items to note in the implementation of this task class

  1. We define a property of type ExecOperations so we can execute terminal commands such as gcloud
  2. We define properties of type Property<String> so we can, at runtime, access the configuration values needed to deploy our service
  3. We define a method deploy() and annotate it with @TaskAction to define what should run when this task is executed. Within this implementation we
    1. Build up the desired gcloud deployment command
    2. Use the ExecOperations property to run the gcloud command

Defining a custom Plugin class

With this task type created, we can now define our custom plugin.  

  1. We’ll create a new class CloudRunDeployPlugin that implements Plugin<Project>  
  2. We must override the apply() method to register our custom deployment task
  3. We’ll create an instance of our CloudRunDeployPluginExtension interface so it can be accessed and configured from the buildscript
  4. We’ll register our CloudRunDeployTask type with a name of deployToCloudRun
  5. Within the register configuration, we can then set the task properties using the values set on the created CloudRunDeployPluginExtension
// :cloudrun-deploy-plugin/build.gradle.kts

class CloudRunDeployPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extensionConfig = project.extensions.create(EXTENSION_NAME, CloudRunDeployPluginExtension::class.java)
        project.tasks.register<CloudRunDeployTask>(TASK_NAME) {
            deployedName.set(extensionConfig.deployedName)
            image.set(extensionConfig.image)
            imageTag.set(extensionConfig.imageTag)
            deployedRegion.set(extensionConfig.deployedRegion)
            env.set(extensionConfig.env)
        }
    }
}

Applying the custom Gradle plugin

With a plugin class created, we need to define the plugin metadata so our main :service project can locate our Plugin. We can do this by leveraging the java-gradle-plugin from the :cloudrun-deploy-plugin/build.gradle.kts buildscript:

// :cloudrun-deploy-plugin/build.gradle.kts

version = "1.0.0"
gradlePlugin {
    plugins.create("cloud-run-deploy") {
        id = "cloud-run-deploy"
        implementationClass = "dev.goobar.cloudrun.deploy.CloudRunDeployPlugin"
    }
}

All we need to do now before we configure the plugin is to apply it to our project.  The exact method of applying it to your project will depend on how you’ve structured your project(s).  In my case, I’ve defined the coordinates of my custom plugin in my version catalog file:

// gradle/libs.versions.toml

[versions]
cloudRunDeployPluginVersion = "1.0.0"

[plugins]
cloudRunDeploy = { id = "cloud-run-deploy", version.ref = "cloudRunDeployPluginVersion" }

Now, I can apply the plugin to my Ktor service buildscript as follows:

// :service/build.gradle.kts

plugins {
    application
    ...
    alias(libs.plugins.cloudRunDeploy)
}

Configuring the Cloud Run Deployment Plugin

We now have a custom Gradle task, a custom Gradle plugin to register the task, and a registered extension interface.

With that, we can configure our deployment from our module-level build.gradle.kts script:

// :service/build.gradle.kts

cloudRunDeploy {
    deployedName.set(deployedImageName)
    deployedRegion.set(deploymentRegion)
    image.set(artifactRegistryConfig.toImage)
    imageTag.set(deployedImageTag)
    env.set(deploymentEnv)
}

Deploying the Cloud Run Service

With our cloudrun-deploy plugin applied to our project and configured for deployment, deploying the service to Cloud Run is simple.  We simply run the deployToCloudRun gradle task we registered from our plugin.

This task relies on gcloud under the hood. Whether running from a local development machine or a CI machine, we need to be authenticated with gcloud.

Conclusion

In this post, we’ve explored how to streamline a Cloud Run deployment for a Ktor service by using a custom Gradle plugin.  Building on the concepts from previous posts, we were able to define a Gradle task that takes in the necessary configuration data to deploy a container from Artifact Registry to Cloud Run.

By consolidating this logic in Gradle tasks, we reduce the complexity of building and deploying our Kotlin service.  This also allows us to leverage the Ktor plugin’s support for building and publishing Docker containers for our service so we get Gradle task avoidance for free; further simplifying our build and deployment pipeline.


Want to Learn More?

If you want to learn more about Kotlin language basics, check out my playlist on YouTube

And if you’re ready to learn more about Kotlin for server-side development, take a look at my LinkedIn Learning course Transitioning from Java to Kotlin for the Web.