![]() |
VOOZH | about |
We’re so glad you’re here. You can expect all the best TNS content to arrive Monday through Friday to keep you on top of the news and at the top of your game.
Check your inbox for a confirmation email where you can adjust your preferences and even join additional groups.
Follow TNS on your favorite social media networks.
Become a TNS follower on LinkedIn.
Check out the latest featured and trending stories while you wait for your first TNS newsletter.
@Scheduled in the background of the application. This works fine if only one instance of the application is running.
However, applications are increasingly becoming containerized and are being run in container orchestration platforms, such as Kubernetes, to take advantage of horizontal scaling so that multiple instances of an application are running. This creates a problem in the way scheduled tasks have been used historically: Because scheduled tasks are run in the background of the application, we have duplicated (and possibly competing) scheduled tasks as we horizontally scale the application.
To address this problem of scaling Java scheduled tasks in Kubernetes, I’ve created a new pattern that works with three popular open source dependency injection frameworks: Spring Boot, Micronaut, and Guice with Java Spark. Let’s walk through the scenario below to understand the pattern.
@Service
public HelloService {
public String sayHello() {
return "Hello World!";
}
}
@Scheduled, like so:
@Component
@Slf4j
public class ScheduledTasks {
private final HelloService helloService;
@Autowired
public ScheduledTasks(HelloService helloService) {
this.helloService = helloService;
}
@Scheduled(cron = "0 8 * * MON-FRI")
public void runHelloService() {
String hello = this.helloService.sayHello();
log.info(hello);
}
}
@RestController
public MyController {
private final HelloService helloService;
@Autowired
public MyController(HelloService helloService) {
this.helloService = helloService;
}
@PostMapping("/hello")
public ResponseEntity<String> sayHello() {
String hello = this.helloService.sayHello();
return ResponseEntity.ok(hello);
}
}
CronJob resource that will call this new endpoint on a set schedule:
apiVersion: batch/v1 kind: CronJob metadata: name: hello spec: schedule: "0 8 * * MON-FRI" jobTemplate: spec: template: spec: containers: - name: hello image: busybox:1.28 imagePullPolicy: IfNotPresent command: - /bin/sh - -c - curl -X POST http://path.to.the.java.api/hello restartPolicy: OnFailure
@SpringBootApplication
public class SpringBootEntryPoint {
public static void main(String[] args) {
ConfigurableApplicationContext applicationContext = SpringApplication.run(SpringBootEntryPoint.class, args);
/*
* If an alternative entry point environment variable exists, then determine if there is business logic that is mapped to
* that property. If so, run the logic and exit. If an alternative entry point property does not exist, then
* allow the application to run as normal.
*/
Optional.ofNullable(System.getenv("alternativeEntryPoint"))
.ifPresent(
arg -> {
int exitCode = 0;
try(applicationContext) {
if (arg.equals("sayHello")) {
String hello = applicationContext.getBean(HelloService.class).sayHello();
System.out.println(hello);
}
else {
throw new IllegalArgumentException(
String.format("Did not recognize alternativeEntryPoint, %s", arg)
);
}
}
catch (Exception e) {
exitCode = 1;
e.printStackTrace();
}
finally {
System.out.println("Closing application context");
}
/*
If there is an alternative entry point listed, then we always want to exit the JVM so the
spring app does not throw an exception after we close the applicationContext. Both the
applicationContext and JVM should be closed/exited to prevent exceptions.
*/
System.out.println("Exiting JVM");
System.exit(exitCode);
});
}
}
public class MicronautEntryPoint {
public static void main(String[] args) {
ApplicationContext applicationContext = Micronaut.run(MicronautEntryPoint.class, args);
/*
* If an alternative entry point environment variable exists, then determine if there is business logic that is mapped to
* that property. If so, run the logic and exit. If an alternative entry point property does not exist, then
* allow the application to run as normal.
*/
Optional.ofNullable(System.getenv("alternativeEntryPoint"))
.ifPresent(
arg -> {
int exitCode = 0;
try(applicationContext) {
if (arg.equals("sayHello")) {
String hello = applicationContext.getBean(HelloService.class).sayHello();
System.out.println(hello);
}
else {
throw new IllegalArgumentException(
String.format("Did not recognize alternativeEntryPoint, %s", arg)
);
}
}
catch (Exception e) {
exitCode = 1;
e.printStackTrace();
}
finally {
System.out.println("Closing application context");
}
/*
If there is an alternative entry point listed, then we always want to exit the JVM so the
spring app does not throw an exception after we close the applicationContext. Both the
applicationContext and JVM should be closed/exited to prevent exceptions.
*/
System.out.println("Exiting JVM");
System.exit(exitCode);
});
}
}
Micronaut#run).
Here is the same pattern using Guice and Java Spark:
public class GuiceEntryPoint {
private static Injector injector;
public static void main(String[] args) {
GuiceEntryPoint.injector = Guice.createInjector(new GuiceModule());
/*
* If an alternative entry point environment variable exists, then determine if there is business logic that is mapped to
* that property. If so, run the logic and exit. If an alternative entry point property does not exist, then
* allow the application to run as normal.
*/
Optional.ofNullable(System.getenv("alternativeEntryPoint"))
.ifPresent(
arg -> {
int exitCode = 0;
try {
if (arg.equals("sayHello")) {
String hello = injector.getInstance(HelloService.class).sayHello();
System.out.println(hello);
}
else {
throw new IllegalArgumentException(
String.format("Did not recognize alternativeEntryPoint, %s", arg)
);
}
}
catch (Exception e) {
exitCode = 1;
e.printStackTrace();
}
finally {
System.out.println("Closing application context");
}
/*
If there is an alternative entry point listed, then we always want to exit the JVM so the
spring app does not throw an exception after we close the applicationContext. Both the
applicationContext and JVM should be closed/exited to prevent exceptions.
*/
System.out.println("Exiting JVM");
System.exit(exitCode);
});
/*
Run the Java Spark RESTful API.
*/
injector.getInstance(GuiceEntryPoint.class)
.run(8080);
}
void run(final int port) {
final GoodByeService goodByeService = GuiceEntryPoint.injector.getInstance(GoodByeService.class);
port(port);
get("/", (req, res) -> {
return goodByeService.sayHello();
});
}
}
Injector rather than from an ApplicationContext object like in Spring and Micronaut, and that there is a run method that contains all the controller endpoints rather than there being a controller class.
You can see these code samples and run them by following the directions in this repo’s README.
In each of these examples, you’ll notice that I control whether the alternative entry point’s logic is invoked by checking if an environment variable exists and, if it does exist, what its value is. If the environment variable does not exist or its value is not what we expect, then the HelloService bean will not be retrieved from the ApplicationContext or the Injector (depending on the framework being used) and will not be executed. While this is not exactly an alternative entry point, it functions in a similar way. Instead of using multiple main methods like traditional alternative entry points, this pattern uses a single main method and uses environment variables to control the logic that is executed.
Note that when using Spring and Micronaut, the applicationContext is closed using try with resources, regardless of whether the service method call executes successfully or throws an Exception. This guarantees that if an alternative entry point is specified, it will always result in the application exiting. This will prevent the Spring Boot application from continuing to run to service HTTP requests with the controller API endpoints.
Last, we always exit the JVM if an alternative entry point environment variable is detected. This prevents Spring Boot from throwing an Exception because the ApplicationContext is closed but the JVM is still running.
Effectively, this solution allows dependency injection to occur before the entry point routing logic occurs.
This solution allows us to write a Kubernetes CronJob resource that uses the same docker image that we would use if we were to run the Spring Boot application as an API, but we simply add an environment variable in the spec as seen below.
apiVersion: batch/v1 kind: CronJob metadata: name: my-service spec: schedule: "0 8 * * MON-FRI" jobTemplate: spec: template: spec: containers: - name: hello-service image: helloImage:1.0.0 # This is the Java API image with the second entry point. imagePullPolicy: IfNotPresent env: - name: alternativeEntryPoint value: "helloService" restartPolicy: OnFailure
CronJob, we can guarantee that only one scheduled task is running at any given time (provided that the task is scheduled with sufficient time between invocations). In addition, we did not expose HelloService through an API endpoint or need to use shell scripting — everything was implemented in Java. We also eliminated duplicated scheduled tasks instead of managing them.
I like to visualize this pattern as making a jar act like a Swiss Army knife: Each entry point is like a tool in the Swiss Army knife that runs the jar’s logic in a different way. Just as a Swiss Army knife has different tools, like a screwdriver, knife, scissors, etc., so does this pattern make a jar act on its embedded business logic as a RESTful API, scheduled task, etc.
👁 Image@Scheduled method. Moreover, Java Spark cannot schedule tasks. On the other hand, the pattern described in this article (I’ll call it the Swiss Army knife pattern) works across more frameworks than just Spring.
But even if your project does use Spring, one of the main disadvantages I see in using @Scheduled in general is that we’re requiring the Spring app to run 24/7 in order for the Spring task scheduler to run and invoke the @Scheduled task based on the cron schedule. This would require a Kubernetes pod that’s running 24/7 with the Spring app running inside it. I see this use of resources (and probably money) as unnecessary because Kubernetes provides its own task scheduler that we can take advantage of by creating a CronJob resource. Kubernetes resources will only be used for the life of the CronJob rather than having a pod running at all times with the @Scheduled task inside it.
In other words, I liken the @Scheduled and CronJob options to this: We wouldn’t spin up an EC2 instance and create a cronjob on the EC2 instance that invokes a Lambda function because we can invoke a Lambda function with a CloudWatch cron rule. One of the reasons why we don’t do this is because the EC2 instance would be more expensive compared to the free CloudWatch rule. Like the EC2 instance in this example, I see a @Scheduled pod as an unnecessary provisioning of resources because we already have a scheduling tool available in Kubernetes’ CronJob (which is like CloudWatch cron rules).