Learn how to deploy microservices. Part 1. Spring Boot and Docker

Learn how to deploy microservices. Part 1. Spring Boot and Docker

Hey Habr.

In this article, I want to talk about my experience in creating a learning environment for experimenting with microservices. When I learned each new tool, I always wanted to try it not only on the local machine, but also in more realistic conditions. Therefore, I decided to create a simplified microservice application, which can later be "covered" with all sorts of interesting technologies. The main requirement for the project is its maximum functional proximity to the real system.

Initially, I broke the creation of the project into several steps:

  1. Create two services - 'backend' (backend) and 'gateway' (gateway), pack them into docker images and set them up to work together

    Keywords: Java 11, Spring Boot, Docker, image optimization

  2. Development of Kubernetes configuration and system deployment in Google Kubernetes Engine

    Keywords: Kubernetes, GKE, resource management, autoscaling, secrets

  3. Creating a chart with Helm 3 for better cluster management

    Tags: Helm 3, chart deployment

  4. Setting up Jenkins and pipeline for automatic delivery of code to the cluster

    Keywords: Jenkins configuration, plugins, separate configs repository

I plan to devote a separate article to each step.

The focus of this series of articles is not how to write microservices, but how to make them work in a single system. Although all these things are usually outside the responsibility of the developer, I think it is still useful to be familiar with them at least 20% (which, as you know, give 80% of the result). Some unconditionally important topics, such as security, will be left out of this project, since the author understands little about this system is created solely for personal use. I welcome any opinions and constructive criticism.

Creating microservices

The services were written in Java 11 using Spring Boot. Interservice interaction is organized using REST. The project will include a minimum number of tests (so that later there is something to test in Jenkins). The source code for the services is available on GitHub: backend ΠΈ Gateway.

To be able to check the status of each of the services, a Spring Actuator has been added to their dependencies. It will create an /actuator/health endpoint and will return a 200 status if the service is ready to accept traffic, or 504 if there is a problem. In this case, this is a rather fictitious check, since the services are very simple, and in case of some force majeure, they are more likely to become completely unavailable than remain partially operational. But in real systems, Actuator can help diagnose a problem before users start battling about it. For example, if there are problems accessing the database, we can automatically respond to this by stopping processing requests with a broken service instance.

Back end service

The backend service will simply count and return the number of accepted requests.

Controller code:

@RestController
public class RequestsCounterController {

    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/requests")
    public Long getRequestsCount() {
        return counter.incrementAndGet();
    }
}

Controller test:

@WebMvcTest(RequestsCounterController.class)
public class RequestsCounterControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void firstRequest_one() throws Exception {
        mockMvc.perform(get("/requests"))
            .andExpect(status().isOk())
            .andExpect(MockMvcResultMatchers.content().string("1"));
    }
}

Service Gateway

The gateway will forward the request to the backend service, supplementing it with the following information:

  • gateway id. It is needed so that it is possible to distinguish one instance of the gateway from another by the server response
  • Some "secret" that will play the role of a very important password (number of the encryption key of an important cookie)

Configuration in application.properties:

backend.url=http://localhost:8081
instance.id=${random.int}
secret="default-secret"

Backend adapter:

@Service
public class BackendAdapter {

    private static final String REQUESTS_ENDPOINT = "/requests";

    private final RestTemplate restTemplate;

    @Value("${backend.url}")
    private String backendUrl;

    public BackendAdapter(RestTemplateBuilder builder) {
        restTemplate = builder.build();
    }

    public String getRequests() {
        ResponseEntity<String> response = restTemplate.getForEntity(
backendUrl + REQUESTS_ENDPOINT, String.class);
        return response.getBody();
    }
}

Controller:

@RestController
@RequiredArgsConstructor
public class EndpointController {

    private final BackendAdapter backendAdapter;

    @Value("${instance.id}")
    private int instanceId;

    @Value("${secret}")
    private String secret;

    @GetMapping("/")
    public String getRequestsCount() {
        return String.format("Number of requests %s (gateway %d, secret %s)", backendAdapter.getRequests(), instanceId, secret);
    }
}

Run:

We start the backend:

./mvnw package -DskipTests
java -Dserver.port=8081 -jar target/microservices-backend-1.0.0.jar

Starting the gateway:

./mvnw package -DskipTests
java -jar target/microservices-gateway-1.0.0.jar

We check:

$ curl http://localhost:8080/
Number of requests 1 (gateway 38560358, secret "default-secret")

Everything is working. An attentive reader will note that nothing prevents us from accessing the backend directly, bypassing the gateway (http://localhost:8081/requests). To fix this, the services must be combined into one network, and only the gateway should "stick out" outside.
Also, both services share one file system, produce streams and at one moment can begin to interfere with each other. It would be nice to isolate our microservices. This can be achieved by distributing applications on different machines (a lot of money, difficult), using virtual machines (resource intensive, long startup), or using containerization. As expected, we choose the third option and Docker as a tool for containerization.

Docker

In short, docker creates isolated containers, one per application. To use docker, you need to write a Dockerfile - instructions for building and running the application. Next, you can build the image, upload it to the image registry (No. Docker Hub) and deploy your microservice in any dockerized environment in one command.

Dockerfile

One of the most important characteristics of an image is its size. A compact image will download faster from a remote repository, take up less space, and your service will start faster. Any image is built on the basis of the base image, and it is recommended to choose the most minimalistic option. A good option is Alpine, a complete Linux distribution with minimal packages.

To begin with, let's try to write a Dockerfile "on the forehead" (I'll say right away that this is a bad way, don't do it):

FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java","-jar","target/microservices-gateway-1.0.0.jar"]

Here we are using an Alpine based base image with the JDK already installed to build our project. With the ADD command, we add the current src directory to the image, mark it as working (WORKDIR) and start the build. The EXPOSE 8080 command signals to docker that the application in the container will use its port 8080 (this will not make the application accessible from the outside, but will allow the application to be accessed, for example, from another container on the same docker network).

To package services into images, you need to run commands from the root of each project:

docker image build . -t msvc-backend:1.0.0

The result is a 456 MB image (of which the base JDK image occupied 340 MB). And all despite the fact that the classes in our project can be counted on a finger. To reduce the size of our image:

  • We use multi-step assembly. In the first step we will build the project, in the second step we will install the JRE, and in the third step we will copy it all into a new clean Alpine image. In total, only the necessary components will be in the final image.
  • Let's use the modularization of java. Starting with Java 9, you can use the jlink tool to create a JRE from just the modules you need

For the inquisitive, here is a good article on image reduction approaches. https://habr.com/ru/company/ruvds/blog/485650/.

Final Dockerfile:

FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine as builder
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests

FROM alpine:3.10.3 as packager
RUN apk --no-cache add openjdk11-jdk openjdk11-jmods
ENV JAVA_MINIMAL="/opt/java-minimal"
RUN /usr/lib/jvm/java-11-openjdk/bin/jlink 
    --verbose 
    --add-modules 
        java.base,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument 
    --compress 2 --strip-debug --no-header-files --no-man-pages 
    --release-info="add:IMPLEMENTOR=radistao:IMPLEMENTOR_VERSION=radistao_JRE" 
    --output "$JAVA_MINIMAL"

FROM alpine:3.10.3
LABEL maintainer="Anton Shelenkov [email protected]"
ENV JAVA_HOME=/opt/java-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=packager "$JAVA_HOME" "$JAVA_HOME"
COPY --from=builder /src/target/microservices-backend-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app.jar"]

We recreate the image, and as a result, it lost 6 times its weight, amounting to 77 MB. Not bad. After that, ready-made images can be uploaded to the image registry so that your images are available for download from the Internet.

Co-running services in Docker

To begin with, our services must be on the same network. There are several types of networks in docker, and we use the most primitive of them - bridge, which allows you to network containers running on the same host. Create a network with the following command:

docker network create msvc-network

Next, start the backend container named 'backend' with the microservices-backend:1.0.0 image:

docker run -dit --name backend --network msvc-net microservices-backend:1.0.0

It is worth noting that the bridge network provides out of the box service discovery for containers by their names. That is, the backend service will be available inside the docker network at http://backend:8080.

Starting the gateway:

docker run -dit -p 80:8080 --env secret=my-real-secret --env BACKEND_URL=http://backend:8080/ --name gateway --network msvc-net microservices-gateway:1.0.0

In this command, we indicate that we are forwarding port 80 of our host to port 8080 of the container. We use the env options to set environment variables that will automatically be read by spring and override properties from application.properties.

After starting, we call http://localhost/ and make sure that everything works, as in the previous case.

Conclusion

As a result, we created two simple microservices, packaged them in docker containers and launched them together on the same machine. The resulting system, however, has a number of disadvantages:

  • Poor fault tolerance - everything works for us on one server
  • Poor scalability - when the load increases, it would be nice to automatically deploy additional service instances and balance the load between them
  • The complexity of the launch - we needed to enter at least 3 commands, and with certain parameters (this is only for 2 services)

To fix the above problems, there are a number of solutions such as Docker Swarm, Nomad, Kubernetes or OpenShift. If the whole system is written in Java, you can look towards Spring Cloud (good article).

Π’ next part I will talk about how I set up Kubernetes and deployed the project to Google Kubernetes Engine.

Source: habr.com

Add a comment