Skip to content

Recipe: Jobs in Java on Azure Container Apps

Use this recipe to build a Spring Boot CLI runner for manual Jobs, adapt it to process one Service Bus message, and add a dedup-table pattern.

Prerequisites

  • Azure Container Apps environment and registry
  • Azure Service Bus namespace and queue for the event-driven example
  • Java 21, Maven, Docker, and Azure CLI
export RG="rg-aca-java-prod"
export ACA_ENV_NAME="aca-env-java-prod"
export ACR_NAME="acrjavaprod"
export JOB_NAME="job-java-manual"
export EVENT_JOB_NAME="job-java-servicebus"
export SERVICEBUS_NAMESPACE="sb-aca-prod"
export SERVICEBUS_QUEUE="orders"

What You'll Build

  • a Spring Boot CommandLineRunner Job
  • an event-driven Service Bus consumer that processes one message and exits
  • a dedup-table example using an embedded table you can later move to a shared database

Steps

flowchart TD
    A[Create Spring Boot CLI runner] --> B[Build JAR and image]
    B --> C[Create manual job]
    C --> D[Run execution]
    D --> E[Switch to Service Bus consumer]
    E --> F[Add dedup table]

1. Create a manual Spring Boot Job

pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.contoso.jobs</groupId>
  <artifactId>aca-java-job</artifactId>
  <version>1.0.0</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.2</version>
  </parent>
  <properties>
    <java.version>21</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
      <groupId>com.azure</groupId>
      <artifactId>azure-identity</artifactId>
      <version>1.13.2</version>
    </dependency>
    <dependency>
      <groupId>com.azure</groupId>
      <artifactId>azure-messaging-servicebus</artifactId>
      <version>7.17.7</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

src/main/java/com/contoso/jobs/JobApplication.java:

package com.contoso.jobs;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class JobApplication {
    public static void main(String[] args) {
        SpringApplication.run(JobApplication.class, args);
    }

    @Bean
    CommandLineRunner manualRunner() {
        return args -> {
            String execution = System.getenv().getOrDefault("CONTAINER_APP_JOB_EXECUTION_NAME", "local");
            System.out.println("{\"event\":\"job-start\",\"execution\":\"" + execution + "\"}");
            System.out.println("{\"event\":\"job-end\",\"status\":\"Succeeded\"}");
            System.exit(0);
        };
    }
}

Dockerfile:

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN mvn --batch-mode --quiet package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /src/target/aca-java-job-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Deploy the manual Job:

az acr build \
  --registry "$ACR_NAME" \
  --image "java-jobs/manual:v1" \
  --file "Dockerfile" \
  "."

az containerapp job create \
  --name "$JOB_NAME" \
  --resource-group "$RG" \
  --environment "$ACA_ENV_NAME" \
  --trigger-type "Manual" \
  --image "$ACR_NAME.azurecr.io/java-jobs/manual:v1" \
  --replica-timeout 600 \
  --replica-retry-limit 1
Command Why it is used
az acr build ... Builds and pushes the container image to Azure Container Registry.

2. Receive one Service Bus message and exit

Replace the runner bean with:

@Bean
CommandLineRunner serviceBusRunner() {
    return args -> {
        var namespace = System.getenv("SERVICEBUS_NAMESPACE") + ".servicebus.windows.net";
        var queueName = System.getenv("SERVICEBUS_QUEUE");

        var client = new com.azure.messaging.servicebus.ServiceBusClientBuilder()
            .credential(namespace, new com.azure.identity.DefaultAzureCredentialBuilder().build())
            .receiver()
            .queueName(queueName)
            .buildClient();

        var messages = client.receiveMessages(1, java.time.Duration.ofSeconds(15));
        for (var message : messages) {
            System.out.println("{\"event\":\"message-received\",\"messageId\":\"" + message.getMessageId() + "\"}");
            client.complete(message);
        }
        client.close();
        System.exit(0);
    };
}

Create the event-driven Job:

az acr build \
  --registry "$ACR_NAME" \
  --image "java-jobs/servicebus:v1" \
  --file "Dockerfile" \
  "."

az containerapp job create \
  --name "$EVENT_JOB_NAME" \
  --resource-group "$RG" \
  --environment "$ACA_ENV_NAME" \
  --trigger-type "Event" \
  --image "$ACR_NAME.azurecr.io/java-jobs/servicebus:v1" \
  --scale-rule-name "orders-queue" \
  --scale-rule-type "azure-servicebus" \
  --scale-rule-metadata "queueName=$SERVICEBUS_QUEUE" "messageCount=1" "namespace=$SERVICEBUS_NAMESPACE.servicebus.windows.net" \
  --env-vars SERVICEBUS_NAMESPACE="$SERVICEBUS_NAMESPACE" SERVICEBUS_QUEUE="$SERVICEBUS_QUEUE"
Command Why it is used
az acr build ... Builds and pushes the container image to Azure Container Registry.

3. Add a dedup table

For a runnable demo, use an embedded H2 table. In production, move the same insert-if-absent logic to a shared database.

@Bean
CommandLineRunner dedupRunner(org.springframework.jdbc.core.JdbcTemplate jdbcTemplate) {
    return args -> {
        jdbcTemplate.execute("create table if not exists processed_messages (message_id varchar(200) primary key)");
        int inserted = jdbcTemplate.update(
            "merge into processed_messages key(message_id) values (?)",
            "message-123"
        );
        if (inserted > 0) {
            System.out.println("{\"event\":\"process-message\",\"messageId\":\"message-123\"}");
        } else {
            System.out.println("{\"event\":\"duplicate-message\",\"messageId\":\"message-123\"}");
        }
        System.exit(0);
    };
}

Verification

az containerapp job execution list \
  --name "$JOB_NAME" \
  --resource-group "$RG" \
  --output table

az containerapp job execution list \
  --name "$EVENT_JOB_NAME" \
  --resource-group "$RG" \
  --output table
Command Why it is used
az containerapp job execution ... Creates, updates, starts, or inspects a Container Apps job.

Next Steps / Clean Up

  • Move the dedup table to Azure SQL or PostgreSQL.
  • Add explicit execution IDs to every structured log line.
  • Review Jobs Operations before production rollout.

See Also

Sources