
Hello, Habr.
In this article, I want to share my experience of creating a learning environment to experiment with microservices. When exploring each new tool, I've always wanted to try it not only on my local machine but also in more realistic conditions. Therefore, I decided to create a simplified microservice application that I could later equip with various interesting technologies. The main requirement for the project is its maximum functional proximity to a real system.
Initially, I divided the project creation into several steps:
Create two services — 'backend' and 'gateway', package them into Docker images, and set up their cooperation.
Keywords: Java 11, Spring Boot, Docker, image optimization
Keywords: Kubernetes, GKE, resource management, autoscaling, secrets
Create a chart with Helm 3 for more efficient cluster management.
Keywords: Helm 3, chart deployment
Configure Jenkins and a pipeline for automatic code delivery to the cluster.
Keywords: Jenkins configuration, plugins, separate configs repository
I plan to dedicate a separate article to each step.
The focus of this series of articles is not on how to write microservices, but on how to make them work in a unified system. Although all these aspects typically lie outside the developer’s responsibility, I believe it is still beneficial to be familiar with them to at least 20% (which, as we know, provides 80% of the outcome). Some undoubtedly important topics, such as security measures, will be left out of this project, as the author knows little about them; the 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. Inter-service communication is organized using REST. The project will include a minimal number of tests (so there will be something to test in Jenkins). The source code of the services is available on GitHub: and .
To check the status of each service, Spring Actuator has been added to their dependencies. It will create the endpoint /actuator/health and return a 200 status if the service is ready to accept traffic, or 504 in case of problems. In this case, it's a rather superficial check, as the services are very simple, and in some force majeure situation, they are more likely to become completely unavailable rather than maintain partial functionality. However, in real systems, Actuator can help diagnose an issue before users start facing problems. For example, if there are issues with database access, we can automatically respond by stopping requests to the malfunctioning service instance.
Backend Service
The backend service will simply count and return the number of received requests.
Controller code:
@RestController
public class RequestsCounterController {
private final AtomicLong counter = new AtomicLong();
@GetMapping("/requests")
public Long getRequestsCount() {
return counter.incrementAndGet();
}
}Test for the controller:
@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"));
}
}Gateway Service
The gateway will forward the request to the backend service, adding the following information:
- Gateway ID. It is needed to distinguish one gateway instance from another by the server's response.
- A 'secret' that will act as a very important password (encryption key for an important cookie).
Configuration in application.properties:
backend.url=http://localhost:8081
instance.id=${random.int}
secret="default-secret"Adapter for communication with the backend:
@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 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);
}
}Starting:
Starting the backend:
./mvnw package -DskipTests
java -Dserver.port=8081 -jar target/microservices-backend-1.0.0.jarStarting the gateway:
./mvnw package -DskipTests
java -jar target/microservices-gateway-1.0.0.jarChecking:
$ curl http://localhost:8080/
Number of requests 1 (gateway 38560358, secret "default-secret")Everything is working. A careful reader will note that nothing prevents us from directly accessing the backend bypassing the gateway (). To fix this, the services must be combined into one network, and only the gateway should be exposed externally.
Both services also share a single file system, spawn threads, and at some point may start interfering with each other. It would be wise to isolate our microservices. This can be achieved by distributing applications across different machines (costly and complex), using virtual machines (resource-intensive, slow to start), or containerization. Unsurprisingly, we choose the third option and as the tool for containerization.
Docker
In short, Docker creates isolated containers, one for each application. To use Docker, you need to write a Dockerfile — an instruction set for building and running the application. After that, you can build an image, upload it to a registry (No. ), and with a single command deploy your microservice in any Dockerized environment.
Dockerfile
One of the most important characteristics of an image is its size. A compact image downloads faster from a remote repository, takes up less space, and your service starts up quicker. Every image is built on a base image, and it's recommended to choose the most minimalist option. A good choice is Alpine — a full Linux distribution with a minimal package set.
To start, let’s try writing a Dockerfile "head-on" (I’ll say upfront that this is a bad approach, 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 use a base image based on Alpine with JDK already installed to build our project. The ADD command adds the current directory src to the image, marks it as the working directory (WORKDIR), and executes the build. The EXPOSE 8080 command signals to Docker that the application in the container will use its port 8080 (this does not make the application accessible externally, but allows access to the application, for example, from another container in the same Docker network).
To package the services into images, you need to run commands from the root of each project:
docker image build . -t msvc-backend:1.0.0As a result, we have an image size of 456 MB (of which the base JDK image took up 340 MB). And considering the number of classes in our project can be counted on one hand. To reduce the size of our image:
- We will use a multi-stage build. In the first stage, we will build the project, in the second, we will install the JRE, and in the third step, we will copy all of this into a new clean Alpine image. Thus, the final image will contain only the necessary components.
- We will utilize Java modularization. Starting from Java 9, you can use the jlink tool to create a JRE only from the required modules.
For the curious, here's a good article on approaches to reducing image sizes. .
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 anshelen@yandex.ru"
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 in the end, it has shrunk to 6 times its original size, totaling 77 MB. Not bad. After this, the prepared images can be uploaded to the image registry so that your images are available for download from the internet.
Running Services Together in Docker
First, our services must be on the same network. Docker has several types of networks, and we are using the most basic one — bridge, which allows us to connect containers running on the same host. Let's create the network with the following command:
docker network create msvc-networkNext, we will run the backend container named ‘backend’ with the image microservices-backend:1.0.0:
docker run -dit --name backend --network msvc-net microservices-backend:1.0.0It is worth noting that the bridge network provides out-of-the-box service discovery for containers by their names. This means that the backend service will be accessible within the Docker network at the address .
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.0In this command, we specify 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 be automatically read by Spring and will override properties from application.properties.
After launching, we call and make sure everything works as it did before.
Conclusion
As a result, we created two simple microservices, packaged them into Docker containers, and launched them together on one machine. However, this system has several drawbacks:
- Poor fault tolerance — everything runs on a single server.
- Poor scalability — with increased load, it would be good to automatically deploy additional service instances and balance the load among them.
- Complexity of launching — we needed to enter at least 3 commands, each with specific parameters (and this is just for 2 services).
To address the aforementioned issues, there are several solutions such as Docker Swarm, Nomad, Kubernetes, or OpenShift. If the entire system is written in Java, one might look into Spring Cloud ().
In I will talk about how I configured Kubernetes and deployed the project in Google Kubernetes Engine.
Source: habr.com
