Spring Scheduler on AWS: Run Tasks with ShedLock only once, even on multiple Instances
Introduction
A Spring Boot application with @Scheduled behaves as expected on a single instance. As soon as the application scales to multiple instances, however, every task runs on every instance. This can quickly lead to conflicts.
This article shows how to solve this with ShedLock and where the ID of the instance in the lock table comes from.
The code in this article comes from a real project and is reduced to the essentials.
The starting point
My Spring Boot applications run on AWS Elastic Beanstalk and AWS Fargate. The number of instances varies from 1–2 to n, depending on the load. Some of these applications process tasks regularly with the Spring Scheduler:
@Scheduled(cron = "0 */5 * * * *")
public void processPendingJobs() {
// ...
}
With two instances, processPendingJobs() runs twice every five minutes. In the best case, the task processes different jobs on each instance. In the worse case, the same job is executed twice.
To process jobs one after the other, ShedLock is a good fit. Because the instances scale horizontally, the lock has to be stored in a central place. ShedLock provides the JdbcTemplateLockProvider for this.
I deliberately did not want distributed processing, where multiple instances split the jobs among themselves and process them in parallel. This was not necessary for my cases and I wanted to keep the complexity low.
The instance ID
By default, ShedLock writes the hostname to the lock table if you do not explicitly specify the identifier (LockedBy).
On Fargate, for example, the hostname is derived from the private IP and can be reused by another task later.
I did not find a ready-made solution that provides a suitable ID on both Beanstalk and Fargate.
For this reason I developed the dependency aws-app-instance-id (Maven Central), which I have now published. It was previously in a private Maven repository.
The dependency provides a unique ID for the running instance of an application:
- On Beanstalk this is the EC2 instance ID from the Instance Metadata Service (IMDSv2).
- On Fargate there is no EC2 instance. There it is the task ID from the ECS Task Metadata Endpoint.
The dependency is optional. A unique ID, for example a UUID, would also be enough. With the EC2 instance ID or task ID, however, I can find the matching log entries in CloudWatch when an error occurs, because the logs can be filtered by this ID.
Dependency
<dependency>
<groupId>io.github.alaugks</groupId>
<artifactId>aws-app-instance-id</artifactId>
<version>0.1.0</version>
</dependency>
Configuration
The integration into Spring is a configuration class.
For example: On prod and stage the instance ID is determined on AWS. Locally or in the CI/CD on dev and test a mock ID is set, so that nothing depends on AWS locally, when testing or in the CI/CD. Here for Beanstalk:
@Configuration
public class AwsInstanceConfig {
@Bean
@Profile({"prod", "stage"})
public InstanceService awsProdInstanceService() {
return InstanceService
.builder(
BeanstalkInstanceService.builder().build()
)
.fallback(e -> {
log.warn("Could not determine instance ID", e);
return "instance-" + UUID.randomUUID();
})
.build();
}
@Bean
@Profile({"dev", "test"})
public InstanceService mockTestInstanceService() {
return InstanceService
.builder(
MockInstanceService.builder("mock-" + UUID.randomUUID()).build()
)
.build();
}
}
For applications that run on Fargate, I replace BeanstalkInstanceService with FargateInstanceService. If the ID cannot be determined, the fallback uses a random ID that is generated once at startup and stays the same for the lifetime of the application. Further details can be found in the README of the dependency.
ShedLock
ShedLock is not a distributed scheduler. It extends the existing Spring Scheduler with a lock: before a task starts, the instance tries to set a lock for the task. If this succeeds, the task runs, otherwise the instance skips this run. The lock has an expiry date so that a task is not blocked forever if an instance dies in the middle of a run. A LockProvider determines where the lock is stored. Because my instances share a database, I use the JdbcTemplateLockProvider.
Dependencies
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>...</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>...</version>
</dependency>
The latest versions (shedlock-spring and shedlock-provider-jdbc-template) are available on Maven Central.
Database table for JdbcTemplateLockProvider
The table belongs in the database migration (for example Flyway or Liquibase):
CREATE TABLE shedlock
(
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
There is one row per task. name is the name of the lock, lock_until the expiry date and locked_at the time at which the lock was set. locked_by contains the instance that holds the lock.
The scheduler configuration
This is where the two parts come together: the LockProvider gets the instance ID from the InstanceService.
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource, InstanceService instanceService) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.withLockedByValue(instanceService.instanceId())
.build()
);
}
}
withLockedByValue(String lockedBy) determines what is written to locked_by in the database, here the instance ID from aws-app-instance-id. The other configuration methods can be found in the documentation.
A task gets its lock via @SchedulerLock:
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(name = "processPendingJobs", lockAtLeastFor = "PT10S")
public void processPendingJobs() {
// ...
}
The name is the key in the table and must be unique per task. lockAtLeastFor holds the lock for at least this long, even if the task finishes earlier.
After a run, the table looks like this, for example:
| name | lock_until | locked_at | locked_by |
|---|---|---|---|
| processPendingJobs | 2026-10-18 14:05:10.000 | 2026-10-18 14:05:00.000 | i-0abc123def456789 |
ShedLock locks per task and per run. So locked_by shows which instance currently holds the lock, not a permanently responsible instance. On the next run, a different instance can get the lock.