Docker Build Hanging?

TLDR; create a .dockerignore file to filter out directories which won’t form part of the built image.

Long version: working on the upcoming Kubernetes course, with a massive deadline looming over me [available at VirtualPairProgrammers by the end of May, Udemy soon afterwards], the last thing I need is a simple docker image build freezing up, apparently indefinitely!

A quick inspection of running processes with Procmon (I’m developing on Windows) showed a massive number of files being read and closed:

This just a small extract from the process log.

This [every file in the repository being visited] is normal behaviour of the docker image build process – I guess I’ve just been working on repositories with a relatively small number of files (Java projects). This being an Angular project, one of the folders is “/node_modules” which contains a masshoohive number of package modules – most of which aren’t actually used by the project but are there as local copies. This directory can be easily regenerated and isn’t considered part of the project’s actual source code. [edit to add, it’s the equivalent of a maven repository in Javaland. The .m2 directory is stored outside your project, so this isn’t a concern there].

Turns out, the /node-modules folder contained 33,335 files whilst the rest of the project contained just 64 files!

Routinely, we .gitingore the /node_modules folder, and of course it makes sense to ignore this directory for the purposes of Docker also.

Simply create a folder in the root of the project, .dockerignore:

$ cat .dockerignore
/node_modules

You might also consider adding .git to this ignore list.

Now my docker image build is taking a few seconds instead of several hours. Perhaps I might meet this deadline after all…

Problems with dockerHost in Maven?

A problem reported against my Docker Module 2 course:

This happens on Windows Toolbox (ie anything below Windows 10 Pro) and possibly Mac toolbox as well.

If your Maven build is failing (with the fabric8 plugin) you need to add additional configuration. On the Docker Toolbox command line type

docker-machine env

Observe the output: you’re looking for the entries for DOCKER_HOST and DOCKER_CERT_PATH. Add the values you find here into your Maven pom, something like this:

<configuration>
<dockerHost>tcp://192.168.99.100:2376</dockerHost> 
 <certPath>C:\Users\Richard\.docker\machine\machines\default</certPath>
...images tag etc here
</configuration>

Of course don’t just copy and paste these values – check they match the output of docker-env.

AllThingsJava Podcast

I’m back on the AllThingsJava podcast again. I had a shocking hangover when recording it so forgive my vague rambling.

Covered on this episode:

  • News – Javascript App gets sued for trademark breach – could it happen to us?
  • News – Java 10 features
  • News – (actually old news but since the last podcast) JavaEE is dead, long live JakartaEE.
  • Matt is planning a new course on Kotlin, coming soon.
  • I’m planning a new course on Kubernetes, coming even sooner. I’ll blog about this new course shortly. I should have done Kubernetes a year ago, I feel somewhat left behind and there are other good courses out there – I’m not sure how I can be different so I’m working on an “angle” right now. Hope I come up with something worth having.
  • There was some talk about whether we should do more on Javascript front ends. I think we should and I’m working on shoring up my skills in this area.

New Podcast – Java vs Scala

A new podcast is now available over at AllThingsJava. I’m not on it, which makes it even better than usual. The guest is Jon Humble, an experienced Scala developer.

We first met him when he presented a very funny talk which pitted Java against Scala in a kind of battle. Neither won because the presenters were too nice to declare a winner, so it was left to the viewer to decide…

…unlike me, Jon knows what he’s talking about so check out the podcast, lots of Kafka and similar buzzword goodness in there.

Java 10 – Local Variable Type Inference

In a previous post I listed the features added to Java 10. Naturally in a six-month update, the list isn’t long and most of them are updates to the runtime (such as very welcome garbage collector updates or internal changes).

But the number 1 headline grabber is the addition of type inference for local variables, and it’s a biggie. Let’s take it for spin.

What is type inference?

You’re all familiar with the following ritual:

 Customer customer = new Customer("Alan");

Which, if you think about it, is a bit absurd. We have to tell the compiler twice what type of object we’re creating. There is a reasoning behind this – technically the left hand side is telling the compiler what type we want the reference to be stored as, whilst the right hand side is the concrete object we’re creating. Sometimes, the difference is important and relevant – when programming to interfaces.

Example:

AbstractCustomer customer = new CreditCustomer("Alan");

Here want to instantiate a specific type of customer, but for the rest of the code, we only want to call the methods that are common to all customers. Polymorphism, blah blah blah. We’re not interested in that today, I just wanted to point out there is a reason for the left-hand-side-right-hand-side apparent duplication.

Here’s the thing – for the most part the two sides of the equation are duplicated. I want a customer and it needs to be of type customer.

Don’t Repeat Yourself is a bad thing and it’s almost like traditional Java wants us to repeat ourselves!

Anyway, stop waffling and cut to the chase. From Java 10 onwards, we can now let the compiler guess – or infer the type that we want…

 var customer = new Customer("Alan");

“var” is one of those rare things in Java, a brand new keyword! Java will now guess (it’s not hard is it?) that the type we want is Customer.

That’s it.

For the rest of the post I’ll describe a more realistic use of var. I’m picking an example from Spark, but don’t worry if you haven’t used Spark before, the example is actually generic.

A Real World Example

As usual with these toy examples, things don’t seem very groundbreaking but I believe this feature will have a major and positive impact on code productivity. Consider this monster from my Spark Training Course:

result = totals.mapToPair(tuple -> new Tuple2<Long, String> (tuple._2, tuple._1 ));

Now, I should know what the return type from this method call is. Erm, well, I should but it’s a bit of a headache. I know that the return class is some kind of “RDD”, I think a JavaRDD. And I can guess from the <Long, String> that the resulting return type will also have the same generic. So, I nervously type into the IDE:

JavaRDD<Long, String> result = totals.mapToPair(tuple -> new Tuple2<Long, String> (tuple._2, tuple._1 ));

Ouch. No! I now have a compile error…the type on the Left Hand Side is wrong. So now we reach a ludicrous situation: the compiler knows what the type should be, and I don’t. And the compiler won’t let me proceed until I guess correctly. (Ok, what I mean is – until I work out the right type).

Often on the Spark course I advise the viewer to simply leave off the return type and then use Quick Fix for the IDE to add in what it thinks is the right type!

Using the “Create Local Variable” Quick Fix should add in the left-hand-side declaration automatically.

I only advise using Quick Fixes if you know what you’re doing – in this case I’m basically asking Eclipse to do a type inference for me. But it’s very hit and miss – in this case, it gets it wrong. Or sort of half right:

For some reason (I care not why) the generics on the left hand side haven’t been inferred so the compiler is still moaning. A second “quick” fix will rectify this…

And – hooray, we finally have the right answer – it seems I had forgotten that the type was actually “JavaPairRDD”:

 JavaPairRDD<Long, String> result = totals.mapToPair(tuple -> new Tuple2<Long, String> (tuple._2, tuple._1 ));

But what a performance! And obviously the compiler knew the right answer all along. It was just teasing us.

So, to the point – when working in real coding situations like this, it can be easy to be unsure of the correct type you need for the declaration – and actually knowing the right type isn’t particularly illuminating. So, in Java 10 I could have avoided all that mess and jumped straight to:

 var result = totals.mapToPair(tuple -> new Tuple2<Long, String> (tuple._2, tuple._1 ));

Much cleaner and I hope in the long run, simpler. Note that none of this destroys strong typing – the type of result is still the same as it was, and we can only call the methods defined in the JavaPairRDD class, exactly as before.

I hope these kind of modern features are going to come thick and fast to future Java versions and that it will prove a compelling reason to upgrade – only time will tell…

[A footnote that after a long discussion Java decided not to implement a var/val keyword pair as in Scala. val would define an immutable value – whilst valuable it was decided this would over-complicate type inference, and it is arguably not all that useful on local variables anyway. That’s a bit disappointing but it’s true that immutability is orthogonal to type inference. I hope that a future version of Java will introduce some strong language level support for immutability.]

Trying Java 10 in Eclipse

If you’re keen to try Java 10 (of course you are!) then you can download the JDK from Oracle at the usual place.

I’m still with Eclipse (for the time being at least), and to use Java 10 (at the time of writing), you need to be on the latest version (currently Oxygen.3 aka 4.7.3) and you need to install (via the Marketplace) a Java 10 plugin.

Java 10 on the Marketplace

After installing the plugin, restart Eclipse and then define a JDK (using the usual tortuous Window -> Preferences -> Java -> Installed JREs and navigate to the folder you downloaded the JDK to).If you haven’t installed the plugin, this will fail because eclipse won’t recognise the new structure and layout of the JDK.

Now you can create a new project – I haven’t changed my default compiler compliance level so I’m getting a promising looking message saying the compliance level is going to be changed for this project…

New Project Window – looks like the compliance level is going to be set properly!

And that’s it. You can now create a class and start using the new features – the next blog post will explore what I think is the best of them, Local Variable Type Inference.

[edit1: I’m far too Eclipse-centric, I do need to address this soon. I can’t see any sign of Java 10 support in NetBeans but JetBrains/IntelliJ as usual are well ahead of the curve, they announced support for Java 10 as far back as last November. ]

[edit2: JetBrains are running a live Java 10 Webinar hosted by Trisha Gee on Thursday, Apr 5 2018, 4:00 PM – 5:00 PM CEST, check it out if you can.]

Java 10 is here…already!

Wow – that was quick. After years of interminable delays to Java 9, all of a sudden the new strict release cycle has kicked in and the first result of that, Java 10 was released on March 20 2018.

In brief, there will be a new release of Java every 6 months from now on and every three years there will be a “long term support” release. Java 11 (due late 2018) is planned to be the first LTS. (Thanks to David Morgan in the comments for spotting my error in early versions of this post!)

Obviously many projects/organizations will insist on only using the LTS releases, so the reality for many Java developers is that adopting Java 11 will probably be long term stretch goal. (By my reckoning, if they keep their promise, Java 17 will be the next LTS after that – it will interesting psychological challenge for the likes of banks to upgrade from 11 to 17 in one jump!).

For anyone not tied to LTS releases, we’re going to get a new set of toys to play with every six months!

Here’s the full set of JEPs (JDK Enhancement Proposals) you can use right now in Java 10. Most of them are under the hood enhancements, cleanups and non-coding related changes. But the headline for Java programmers is the first of them, JEP286 – which I’ll write about in my next blog post. It’s a stunning improvement to Java.

286: Local-Variable Type Inference
296: Consolidate the JDK Forest into a Single Repository
304: Garbage-Collector Interface
307: Parallel Full GC for G1
310: Application Class-Data Sharing
312: Thread-Local Handshakes
313: Remove the Native-Header Generation Tool (javah)
314: Additional Unicode Language-Tag Extensions
316: Heap Allocation on Alternative Memory Devices
317: Experimental Java-Based JIT Compiler
319: Root Certificates
322: Time-Based Release Versioning

Hello, is this thing on?

A year’s gap from blogging – bad Richard.

Blogging sort of tailed off, partly as a result of the ancient and depressing blog platform I was using (every post needed the editing of raw 1990’s style html – <hr> tags anyone?) but also due to a lot of activity and general busy-ness at VirtualPairProgrammers.

In the last year I’ve released courses on:

Plus as a substitute for blogging, I’ve been a regular guest on the All things Java Podcast which was fun but apparantly no-one listens to it. [Edit: the podcasts will continue and I hope I’ll still be a guest on them, but I want to remind myself that blogging is just as if not more important than podcasts]. So it’s back to blogging and more new courses are in the pipeline.

Problems with Spring Boot Eureka EIP Binding? Kill those Zombies.

Warning this is a long blog! Full details of this will be available in next week’s release of Microservice Deployment, from VirtualPairProgrammers!

This week I have been mainly getting Eureka into production. Specifically, I’m deploying to AWS, in a multi availability-zone configuration. I have an Auto Scaling Group firing up two instances, each in a different Availability Zone (AZ).

This has been, to put it mildly, an “interesting challenge”. Obviously Netflix have a massive production load running on it – so we know it works! – but the documentation on how the rest of us should configure it is sketchy at the time of writing.

This blog concentrates on a problem which will be fixed in the forthcoming “Dalston” releases of Spring Cloud, but it may affect those on legacy code bases. (Also Dalston is due for release later in February and I can’t delay the course any longer!) [Edit April 2018 – according to the GitHub Issue that reported this, the bug is still open in the Edgware release train, so this blog post is still relevant].

Note: much of this has been done in a limited timescale under pressure. Ideally when the pressure is off I would spend time examining more of the source code – and I will have misinterpreted or plain got stuff wrong, so do comment or contact me if you know more than me!

Problem: Zombie Instances.

The main problem I’ve had in preparing the architecture is zombies. Any instance which has been forcibly terminated is not expired from Eureka and remains, indefinitely, as a “Zombie” instance. This is catastrophic because these Zombie instances will continue to be called by clients, even when healthy versions of the instances exist. (We are using Hystrix which can be a hindrance if the fallback hides the problem – I’ve switched off all Circuit Breakers for this exercise).

(nb If an instance closes cleanly, this isn’t a problem because the instance deregisters itself from Eureka and this is fine.)

The instance highlighted above was killed about an hour ago…..

And here’s the live and dead (stopped) instance in the AWS EC2 Console.

Even though the instance is long dead, clients will still be handed both the live and dead instance references. As we’re using Ribbon as a load balancer on the client side, about half the time we get….

Hooray! But half the time we get….

Boo! Recall that I’ve temporarily disabled circuit breakers, so no fallbacks are available, making the crash more obvious.

Possible Solutions:

Self Preservation Mode.

I won’t bother re-describing self preservation mode here as the references 2) and 3) cover this well.

In development where you only have a handful of instances, this mode is a right pain in the neck because a single instance deregistering is interpreted as a network catastrophe, and Eureka stops de-registering instances, on the (for us, bad) assumption that the instance is still there, it’s just that Eureka can’t see it for some reason. So it will continue serving details of this instance to clients.

For me, I don’t think this was the problem. I had other instances registered which stopped the threshold kicking in – *I think*. Just to be sure, I decided to drop the threshold right down (I never saw the red emergency warning so I assume self preservation wasn’t happening). In desperation, I decided to switch this mode off altogether – you get a red angry warning for doing this, but who cares?

The follow properties achieve this:

# Make the number of renewals required to prevent an emergency tiny (probably 0)
eureka.server.renewalPercentThreshold=0.1

# In any case, switch off this annoying feature (for development anyway).
eureka.server.enableSelfPreservation=false

Q: Is the self preservation feature really all that useful? Obviously Netflix thinks so and I believe them. But I’d like to get a handle on what the use is. In the event of a network partition, causing Euerka to expire all of the instances, won’t the clients continue using their own cached instances anyway? The “emergency” doesn’t sound that serious to me, and certainly no worse than clients getting references to zombie instances. I need to investigate this more deeply when the pressure is off.

2) EIP binding causes incorrect metadata – bounce the server.

This was the real reason.

Eureka instances need a fixed IP address or domain name. Usually you would achieve this by setting up a Load Balancer (which in AWS is given a fixed domain name – it’s ip address may vary over time but you are insulated from that). Then you could place the Eureka instances behind the load balancer.

However, Eureka has its own scheme, as described in reference 5).

1) Reserve yourself a set of Elastic IP (EIPs) addresses from AWS, one for each of your Eureka instances (I need two). This gives you an IP address permanently allocated to your account, and we can freely associate them with any of our EC2 instances.

2) Configure your Eureka Server with a comma separated list of all of the EIPs. Eureka insists that you use the full DNS style name:

eureka.client.serviceUrl.defaultZone=http://ec2-35-166-222-19.us-west-2.compute.amazonaws.com:8010/eureka/,http://ec2-35-167-126-96.us-west-2.compute.amazonaws.com:8010/eureka/

Typically, you’d bake the Eureka code base onto an AMI so that you can start up new Eurekas easily from an Auto Scaling Group.

3) Start up an EC2 instance, from the AMI containing your Eureka image. It’s IP will, as usual, be dynamically allocated by AWS, so it will be something like 55.34.23.123 (whatever).

4) Now for the weird bit….as part of the startup process, Eureka will grab one of the EIPs from the list given in step 2), and it will re-bind the IP address of this EC2 instance to that EIP. That’s right, the IP address of this server will be changed, on the fly, during the startup of the instance.

5) The other instance will do the same thing – but in this case it will find that the first address in the list has already been taken, so it then tries the second item in the list. It will successfully bind to this IP address.

And, hey presto you now have two Eurekas, peers of each other, each having the correct IP addresses that we knew about in advance.

For the clients, we can give them the exact same config (this is convenient because we can put this property in the global config server instead of having to repeat it in every single microservice):

eureka.client.serviceUrl.defaultZone=http://ec2-35-166-222-19.us-west-2.compute.amazonaws.com:8010/eureka/,http://ec2-35-167-126-96.us-west-2.compute.amazonaws.com:8010/eureka/

The client can now choose either of these URLs to work with as its Eureka server. According to the docs (although I haven’t had time to verify this), the client will favour a Eureka server in it’s own availability Zone – this is presumably for optimal performance. However this isn’t critical, if that server isn’t available it will fall over to another one from the list, ie from a different AZ. This will incur slower performance, although the AZs have low latency connections with other AZs in the same region.

(Note that the Eureka replicas aren’t a master/slave arrangement, its peer-peer so there’s no “main” Eureka server. For a client in AZ us-west-2b, their preferred server is in that zone but it’s just a preference).

So that’s how Eureka is designed to bootstrap itself, but it seems this is the root of the Zombie problem.

Here’s my server, which after booting up grabbed itself the EIP 35.167.126.96:

But check out the instance info at the bottom:

The public-ipv4 and public-hostname are wrong – these were correct when the instance was booting up, but after the EIP bind, these values are now redundant.

So what? Well, I’m not sure. The public hostname is important to Eureka, and I can see how it would prevent the servers from replicating with each other – because Eureka uses the hosts names to find its replicas:

This Eureka instance thinks its peers are unavailable, because it’s looking for host names containing the 35-xxx-xxx-xx series of numbers. But as shown above, the hostnames are stuck on the “old values of “54-xxx.xxx-xxx”.

Confusion: I would understand if this stopped replication working – actually replication is working fine despite the UI above indicating unavailable replicas.

Now, I do not understand why (again, I need to take time to step through source code), but what is demonstrably true is that this misconfiguration causes the zombie instances. Maybe someone out there can explain this.

A quick fix for the problem is to simply log on to the Eureka instances and restart the spring boot application. This will causes their instance data to be refreshed, this time to the correct values:

The instance info after a restart of the Eureka App – as an EIP bind wasn’t necessary this time, the IP address hasn’t changed and the values populated on startup are now correct.

And the replication is now correct – we can see the “other” Eureka instance listed as available.

But the real difference (at last, I get to the point!!!) is that instance de-registration is now working. Let’s start up two instances of the microservice:

Above: two instances running in EC2

Above: correctly registered in Eureka….

Now I’m going to kill a random instance and start a stopwatch:

This time, success! After 2mins42s (more on the timings later) I saw the cancellation run through the server log:

Which is confirmed on the UI:

Note that this does not mean that the clients will now have clean, non-zombie references. Eureka actually sends a Json document back to clients which it caches (I think for 30seconds), so it may take up to 30 seconds before the zombie disappears from data sent to clients. And then, Ribbon on the client side has a cache as well. So many caches and often I’ve banged my head thinking everything is unreliable when actually it just takes times for the caches to align.

A minute later and every refresh on my webpage is showing a map again. Joy.

So, I cannot explain why the bad instance information is preventing Zombie expiration, but it does. Any ideas???

Refreshing the Instance Info Automatically

Trouble is, we can’t rely on being able to “bounce” the Eureka app every time it starts up. I did consider adding additional steps to my Ansible script; I could:

– fire up Eureka from AMI

– Wait for port 8010 to respond on the EIP

– Restart Eureka App

But that wouldn’t work for when the server is started from an Auto Scaling Group – and that’s a requirement, if Eureka EC2 instance terminates for any reason, it needs to be auto started again. The ASG will just restore the AMI which will cause an EIP bind to happen, but unless we do something clever (a script that triggers after an ASG event? My head’s hurting) the instance information will be in that bad state.

So, thanks to the GitHub issue at reference 1) (EIP publicip association not correctly updated on fresh instance), Niklas Herder (to whom I owe a drink, or something) suggested using a timer in the main class of the Eureka App to automatically refresh the instance’s info if that info has changed. It works perfectly.

I’ve modified his suggested code slightly to remove anything we don’t need, and I’m left with this….

 @Bean
 public EurekaInstanceConfigBean eurekaInstanceConfigBean(InetUtils utils) {
  final EurekaInstanceConfigBean instance = new EurekaInstanceConfigBean(utils)
  {
   @Scheduled(initialDelay = 30000L, fixedRate = 30000L)
   public void refreshInfo() {
    AmazonInfo newInfo = AmazonInfo.Builder.newBuilder().autoBuild("eureka");
    if (!this.getDataCenterInfo().equals(newInfo)) {
     ((AmazonInfo) this.getDataCenterInfo()).setMetadata(newInfo.getMetadata());
     this.setHostname(newInfo.get(AmazonInfo.MetaDataKey.publicHostname));
     this.setIpAddress(newInfo.get(AmazonInfo.MetaDataKey.publicIpv4));
     this.setDataCenterInfo(newInfo);
     this.setNonSecurePort(8010);
    }
   }         
  };
  AmazonInfo info = AmazonInfo.Builder.newBuilder().autoBuild("eureka");
  instance.setHostname(info.get(AmazonInfo.MetaDataKey.publicHostname));
  instance.setIpAddress(info.get(AmazonInfo.MetaDataKey.publicIpv4));
  instance.setDataCenterInfo(info);
  instance.setNonSecurePort(8010);

  return instance;
 }

It’s a hack, sure, but it works! Forgive the hardcode of the 8010, I haven’t got around to polishing this off yet.

Just to prove it, I’ll run through the exercise again:

After baking the new “refresh” code into an AMI, I’ve started an Auto Scale Group which has triggered the startup of two new Eureka Servers. Notice their IPs are in the 54.xx.xx.xx range.

… a few minutes later and their IPs have changed:

The all important instance info is up to date:

And all is well with the world. This time I killed an instance and after 2mins 57seconds the registration was cancelled.

This problem will be fixed when the Spring Eureka migrates to the underlying Eureka implementation version 1.6. The Dalton release train will be the first release containing the fix. We’re still on Camden and for “reasons” I want to stay with that for now (mainly, I don’t want to delay and I don’t want to change what we have). But all of the above may not be an issue for you.

In the next blog post, I’ll address the other pain in the neck with Eureka, which is slow registration and de-registration.

I’m indebted to the following resources

 

 

    1. github.com/spring-cloud/spring-cloud-netflix/issues/373 – Bertrand Renuart has described many of the internals excellently – this would form a good basis for an improved set of official documents.

 

    1. At the time of writing, Abhijit Sarkar is clearly going through similar pain as me and he’s writing up an excellent blog “Spring Cloud Netflix Eureka – The Hidden Manual”. blog.abhijitsarkar.org/technical/netflix-eureka/. This seems to be a work in progress, I hope his work will become the actual manual before long.

 

    1. The Spring Cloud Eureka documentation has a few references to production settings.

 

    1. This is the only guide I know of to EIP binding, although the settings given here don’t seem to work properly in Spring Cloud Eureka.

 

There is also the Netflix Wiki at https://github.com/Netflix/eureka/wiki which is a reasonable start, but many of the details are vague and confusing. Some details are not relevant to the Spring Cloud version of Eureka. The document starts by advising to create multiple properties for each AZ; I’ve failed to get Spring to pick this up and in all examples I’ve only seen a list of addresses (comma separated) on the eureka.client.serviceUrl.defaultZone property. I’ve gone with that and it seems ok but I’m interested to find out more.

Spring Boot Crashing Due to Unsatisfied Depedency?

We’ve just had a report of a possible bug on our Microservices course – your web application might fail to start up with something like the following in the Stacktrace:

Launcher.java:49) [spring-boot-devtools-1.4.1.RELEASE.jar:1.4.1.RELEASE]
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: 
Error creating bean with name 'positionTrackingExternalService': Unsatisfied dependency expressed through field 'remoteService'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'com.virtualpairprogrammers.services.RemotePositionMicroserviceCalls': FactoryBean threw exception on object creation; nested exception is java.lang.NullPointerException

This will happen if you use Spring boot 1.4 – the original course code used Boot 1.3.

So, as a quick (and unsatisfactory) fix, you can drop the version of boot down to 1.3. However, the root course appears to be classloader related (I haven’t had time to fully investigate it, although the bug report here gives a big clue) and is triggered by the presence of the devtools dependency. We added that back on the first course to enable automatic container reloading.

Boot maintains two classloaders, and on a change to the code, it only needs to restart one – this is a speed optimisation. I have no idea why this break has happened in Boot 1.4, but if you have this problem, the solution is to remove the spring-boot-devtools dependency from your POM and all will be well. Of course, you’ll now need to manually bounce the server each time you make changes.

If I get time to investigate this more thoroughly, I’ll make further posts but hopefully for now this will stop anybody hitting a brick wall on the course!