Docker with Java and Nginx
The platform I work on is built with Spring Boot applications, hosted in a
Docker container and hidden behind an Nginx reverse proxy. All of these
items have been understood and tuned as we have worked and continue to work on
our platform to reduce downtime and responses resulting in an error (5XX
HTTP
status codes).
We chose Docker as our method of deployment in order to take advantage of a simplified host and isolation of each application for security and performance reasons. Our Docker image is very minimal containing only a libc package needed for the JVM and the Nginx static binary. Our entrypoint for the image is the Java executable so our Java application is actually responsible for starting up Nginx to be the reverse proxy for the Spring Boot application.
We wanted and needed Nginx because we were actually not able to see what the embedded Tomcat server would respond with to the client connecting to the service. We did have a request/response filter high in the chain but there were still filters being applied by Tomcat that could/would change the response code (example was a 501 when a garbage request method was sent). Now Nginx simply proxies requests and does a log on the way out of the application after the filter chain has been applied.
In order to have both processes running (Java Spring Boot App and Nginx), when
we are starting up the Java application in the container, it has knowledge of
where the Nginx binary is located in the Docker image so that it can start
Nginx once the application is ready to service requests. We achieved this by
implementing the CommandLineRunner
interface.
package my.custom.nginx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.context.embedded.EmbeddedServletContainerCustomizer;
import org.springframework.boot.context.embedded.tomcat.EmbeddedServletContainerFactory;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Enumeration;
@Configuration
public class JavaNginxConfig impements CommandLineRunner {
private final Logger logger = LoggerFactory.getLogger(JavaNginxConfig.class);
private Process start;
@Bean
public EmbeddedServletContainerCustomizer nginxCustomizer() {
return container -> {
try {
container.setAddress(InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
} catch (UnknownHostException e) {
throw new RuntimeException("Failed to set address for TomcatContainer", e);
}
if (container instanceof TomcatEmbeddedServletContainerCustomizer) {
final TomcatEmbeddedServletContainerFactory tomcat = (TomcatEmbeddedServletContainerFactory) container;
tomcat.addConnectorCustomizers(connector -> connector.setPort(8181));
}
}
}
@Override
public void run(String... args) throws Exception {
try {
configureListenDirectives();
} catch (IOException e) {
logger.error("Error occurred attempting to write the listen directives to the include file. Closing the context.");
context.close();
}
try {
start = new ProcessBuilder("nginx-binary-path/nginx", "-c", "nginx-conf-path/nginx.conf");
} catch (IOException e) {
logger.error("Error occurred attempting to start the Nginx process. Closing the context.");
context.close();
}
}
private void configureListenDirectives() throws IOException {
Path includePath = Paths.get("nginx-conf/listen.include");
Files.deleteIfExists(includePath);
for (Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces(); networkInterfaces.hasMoreElements();) {
for (Enumeration<InetAddress> inetAddresses = networkInterfaces.nextElement().getInetAddresses(); inetAddresses.hasMoreElements();) {
InetAddress inetAddress = inetAddresses.nextElement();
if (!inetAddress.isLoopbackAddress()) {
Files.write(includePath,
("listen " + inetAddress.toString().replaceAll("/", "") + ":8080;").getBytes());
}
}
}
}
}
What we found was that the CommandLineRunner.run()
would not run until the
application was up and ready to service requests, Tomcat was running and
listening on our port inside the container on 8181. The other important piece
of this was that Tomcat was only listening inside the container. Listening on
127.0.0.1
inside the container meant that we did not also bind to Docker IP
(typically 172.17.0.X
) so clients would have to use Nginx in order to
communicate with the Java application. Not having Nginx listening until Tomcat
was ready was also important so the container did not start accepting
connections, Nginx was started and listening but Tomcat was not, and fail with
a 502
(Bad Gateway).
All this work allowed us better logging of our true responses to clients of
each service, prevention of 502
errors that could result from prematurely
starting the Nginx process. We were previously annotating our run
method with
@PostConstruct
which would start Nginx right after the configuration was
created. This was very early in the application startup process and well before
Tomcat was ready and listening to service requests.