Backend

Docker w środowisku Javy. Problemy i wyzwania

Aplikacje biznesowe są coraz bardziej złożone, wymagamy od nich niezawodności oraz ciągłej dostępności (tak zwany Zero Downtime Deployment). Wspomagając biznes często musimy iść w skomplikowane rozwiązania, np. kilka baz danych (w tym relacyjnych i nie relacyjnych). Wymagamy niskich kosztów oraz wysokiej dostępności w razie potrzeby, czyli auto-skalowania. Jednym z narzędzi jakie musimy użyć jest konteneryzacja przy pomocy narzędzia Docker, która bardzo dobrze integruje się z nowoczesnymi rozwiązaniami chmurowymi.

Andrzej Sydor. Team Leader Senior Software Developer w Objectivity. Posiada ponad dziesięcioletnie doświadczenie w pisaniu aplikacji, głównie w języku Java. Zafascynowany kulturą DevOps i architekturą mikroserwisową, a co za tym idzie rozwiązaniami konteneryzacji Docker, Kubernetes i rozwiązań chmurowych. Szkoli, dzieli się wiedzą i mentoruje w szkołach IT. Miłośnik dobrych meetup’ów.


W tym artykule skupimy się na konteneryzacji serca aplikacji, tak zwanego ’backendu’, który najczęściej jest pisany w Javie lub języku działającym na wirtualnej maszynie Javy (JVM).

Java – zapotrzebowanie pamięci i procesora

Pisząc aplikacje w Javie, jak i w innych językach musimy mieć na uwadze zapotrzebowanie na procesor i pamięć. Szeroka gama frameworków nie ułatwia zadania. Od najprostszych aplikacji, w którym sami zarządzamy wątkami, tworzymy je, dbamy o prawidłowy cykl życia i uważamy, aby nie napisać kodu, którego problemem jest wyciek pamięci. Poprzez proste i lekkie frameworki, jak javalin, Dropwizard czy RESTX, kończąc na rozwiązaniach ciężkich, typu ‘enterprise’ wykorzystujących standard Jave EE lub Spring Framework, mogących korzystać z różnych serwerów, np. webowych Apache Tomcat, Netty, jak i enterprise: WildFly lub IBM WebSphere.

Wybierając jedno z powyższych rozwiązań jesteśmy w stanie skonfigurować serwer oraz przekazać parametry dla JVM’a takie jak maksymalna ilość utworzonych wątków oraz możliwą pamięć do wykorzystania dla stosu i sterty w Javie. Tutaj korzystamy między innymi z parametrów Xmx, Xms i Xss’ Biorąc oczywiście jeszcze pod uwagę konkretną wersje Javy, na której uruchamiamy aplikacje.

Prawidłowe i najbardziej optymalne ustawienia wirtualnej maszyny Javy nie jest trywialną sprawą. W przypadku użycia konteneryzacji dochodzi kolejny poziom skomplikowania. Załóżmy, że aplikacja uruchomiona w kontenerze widzi zasoby hosta, a nie przydzielone danego kontenerowi, co tak naprawdę dzieje się w starszych wersjach Javy. Aplikacyjne zasoby są w stanie wyjść poza ramy ustawione dla kontenera, jak w takim przypadku zareaguje nasza aplikacja? Co się z nią stanie? Konkretnej odpowiedzi nie ma. Każdy przypadek jest indywidualny co cechuje inna wersja Javy czy inny problem napotkany dla systemu.

Jedno jest pewne, jest to niekomfortowa sytuacja dla serwera, jak i dla nas programistów. Nieraz mały problem jest w stanie narobić wielkie szkody. Załóżmy, że mamy kontener, który napotkał taki problem. Ustawiony jest na restart w każdej problemowej sytuacji. Jak to będzie oddziaływać na całość systemu, inne mikroserwisy oraz host’a lub chmurę, na której jest nasza aplikacja? O ile nie będę przedstawiał możliwych rozwiązań, to przedstawię na przykładach problem z jakim nasza aplikacja w kontenerze musi się zmierzyć.

Środowisko testowe / uruchomieniowe

Przykłady będę uruchamiał na środowisku testowym http://play-with-docker.com’. Rezultaty mogą być odbiegać jeśli użyjecie własnych środowisk z zainstalowanym Dockerem lub nawet na środowisku testowym przedstawionym w artykule, nie są mi znane dokładne zasady przydzielania zasobów dla konkretnych instancji.

Testy poprawek Javy

Poniżej kod źródłowy pliku budowania obrazu Dockerfile, który będzie nam pomocny przy testach. Przedstawię dwa testy, jeden z poprawkami w nowszej Javie 11 oraz drugi w starszej Javie 7 dla zobrazowania problemu jaki dotyczył Javy jeszcze jakiś czas temu.

Kod źródłowy w języku Java, który wypisuje ilość dostępnych procesorów oraz maksymalna możliwa pamięć do zarezerwowania widoczna dla danego środowiska uruchomieniowego.

public class App {
	public static void main(String[] args) {
    	Runtime runtime = Runtime.getRuntime();
    	int processors = runtime.availableProcessors();
    	long maxMemory = runtime.maxMemory();
    	System.out.format("Number of processors: %d. ", processors);
    	System.out.format("Max memory: %d bytes. ", maxMemory);
    }
}

Kod źródłowy budujący kontener z prostą aplikacją Javy (jak wyżej) działający na konkretnej siódmej wersji wirtualnej maszyny Javy.

FROM openjdk:7
 
WORKDIR /
 
RUN echo 'public class App{public static void main(String[] args){Runtime runtime = Runtime.getRuntime(); int processors = runtime.availableProcessors(); long maxMemory = runtime.maxMemory(); System.out.format("Number of processors: %d. ", processors); System.out.format("Max memory: %d bytes. ", maxMemory);}}' > App.java
 
RUN ["javac", "App.java"]
 
CMD ["java", "App"]

Komendy, które należy wykonać aby zbudować kontener bazując na Dockerfile znajdującym się w aktualnym folderze. Uruchomienie kontenera i w ostatnim kroku zczytanie logów naszego prostego programu. (za <version> podstaw aktualnie testowaną wersję Javy, zapobiegnie to konflikcie nazw kontenerów)

docker build -t jvm:<version> .
 
docker run -dit --name java<version> --cpus 2 --memory=128m jvm:<version>
 
docker logs java<version>

Uzyskane wyniki dla poszczególnych wersji Javy:

Podsumowując ćwiczenie przez jakie przebrnęliśmy i uzyskane wyniki łatwo zauważyć, że można się spotkać z problemem opisanym prędzej. Najlepszą metodą jest zweryfikowanie i przetestowanie konkretnie używanej wersji wirtualne maszyny Javy. Wersja główna Javy, np. 9, która nie obsługuje poprawnie kontrolowanych przez Dockera parametrów, może uzyskać łatki tzw. Patch’e, które naprawią problem w kolejnych wersjach z oznaczeniem minor.

Wydajność Javy na przykładzie Spring Framework

Aby zobrazować lepiej i zmierzyć w bardziej realny sposób wydajność przebrniemy przez kolejny przykład. Zbudujemy aplikacje webową, używając Spring Framework. Wystawimy REST’owy endpoint, który dla zadanej liczby przekazanej w argumencie wywołania stworzy listę wylosowanych liczb całkowitych. Dla każdego elementu listy znajdzie wszystkie odpowiadające liczby pierwsze.

Opisana wyżej funkcjonalność wyliczania liczb pierwszych i endpoint REST’owy. Zrealizowana w Spring Boot.

@RestController
@SpringBootApplication
public class Prime {
 
    @GetMapping("primes")
    Map<Long, List<Long>> findPrimeFactors(@RequestParam Long number) {
        return LongStream.range(1, number+1)
                .map(n -> ThreadLocalRandom.current().nextLong(100))
                .boxed()
                .distinct()
                .collect(Collectors.toMap(n -> n, n -> findPrimes(n)));
    }
 
    private List<Long> findPrimes(Long number) {
        final List<Long> primeFactors = new ArrayList<>();
        for (long i = 2; i <= number / i; i++) {
            while (number % i == 0) {
                primeFactors.add(i);
                number /= i;
            }
        }
        if (number > 1) {
            primeFactors.add(number);
        }
        return primeFactors;
    }
    
    public static void main(String[] args) {
        SpringApplication.run(Prime.class, args);
    }
}

Dla tego serwisu przygotujemy Dockerfile, który podczas budowania będzie tworzyć projekt ‘maven’owy z wymaganymi przez nas zależnościami Spring Boot’a. Skopiuje przygotowany plik Javy, aby w ostatnim kroku uruchomić całość.

Dockerfile przygotowujący aplikację REST’ową.

FROM maven:3.6.1-jdk-11-slim
 
WORKDIR /app
RUN curl https://start.spring.io/starter.zip -d dependencies=web -d bootVersion=2.1.5.RELEASE -d applicationName=Prime -d baseDir=prime -o prime.zip
RUN unzip prime.zip
 
RUN rm prime/src/main/java/com/example/demo/Prime.java
COPY Prime.java prime/src/main/java/com/example/demo/
 
WORKDIR /app/prime
RUN mvn package
RUN cp target/demo-0.0.1-SNAPSHOT.jar /app.jar
 
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

Do zbadania wydajności użyję narzędzia ab (Apache Bench). Apache Bench umożliwia wykonanie wielu żądań HTTP [przełącznik -n] przez wiele równoległych wątków [przełącznik -c]. Budujemy odpowiedni obraz Docker’a z przedstawionym narzędziem.

Dockerfile z narzędziem Apache Bench i Curl.

FROM alpine
 
ENV TERM linux
RUN apk --no-cache add apache2-utils curl

Całość musimy połączyć siecią, aby obrazy widziały się oraz poinformować, że przynależą do tej samej sieci.

Komendy niezbędne dla uruchomienia naszego przykładu.

# tworzymy sieć
docker network create test-net
 
# budujemy obrazy (przy pomocy konkretnych Dockerfile'i)
docker build -t prime .
docker build -t ab .
 
# uruchamiamy kontenery z informacją o siecie
docker run -dit --name ab --network test-net ab
docker run -dit --name prime1 -p 8081:8080 --network test-net --cpus 1 prime
docker run -dit --name prime8 -p 8088:8080 --network test-net --cpus 8 prime

W kolejnym kroku warto sprawdzić czy na pewno kontenery mogą się komunikować ze sobą.

Zestaw komend testujących sieć pomiędzy kontenerami.

# wejście w tryb interaktywny kontenera Apache Bench
docker exec -it ab sh
 
# proste odpytanie kontenerów 'prime's
curl http://prime1:8080/primes?number=10
curl http://prime8:8080/primes?number=10

Omówmy uzyskane wyniki załączone poniżej. Odpalone zostały testy, dokładnie tysiąc żądań do serwera, konkretnie naszej opisanej metody wyliczającej liczby pierwsze. Po maksymalnie pięćdziesiąt żądań równolegle. W taki sposób zostały przetestowane obydwa kontenery, czyli z ograniczeniem do jednego wątku i drugi z większą ilością wątków. Łatwo zauważyć, że kontener z ograniczeniem jest prawie o połowę wolniejszy jeśli chodzi o odpowiedzi. Oczywiście współczynników może być więcej, np. jak sam serwer Tomcat’a radzi sobie z różną ilością wątków, który jest używany standardowo przez Spring Boot Framework. Każde podejście trzeba ocenić jednak indywidualnie i przeprowadzić testy wydajnościowe ale często może się okazać, że jest to lepsze rozwiązanie. Czyli zwiększenie zasobów konkretnego kontenera, niż tworzenie kilku instancji.

Wyniki testu kontenera z ograniczeniem ilości wątków do jednego.

ab -n 1000 -c 50 http://prime1:8080/primes?number=1000000
 
Server Hostname:    	prime1
Server Port:        	8080
Document Path:      	/primes?number=1000000
Concurrency Level:  	50
Time taken for tests:   32.882 seconds
Complete requests: 	 1000
Requests per second:	30.41 [#/sec] (mean)
Time per request:   	1644.109 [ms] (mean)
 
Wyniki testu kontenera „praktycznie” bez ograniczenia,  ilości wątków osiem.
ab -n 1000 -c 50 http://prime8:8080/primes?number=1000000
 
Server Hostname:    	prime8
Server Port:        	8080
Document Path:      	/primes?number=1000000
Concurrency Level:  	50
Time taken for tests:   19.525 seconds
Complete requests:  	1000
Requests per second:	51.22 [#/sec] (mean)
Time per request:   	976.245 [ms] (mean)

Co dalej – Java, Docker…

Oczywiście temat jest bardzo obszerny. Przybliżę tylko kierunki, które można obrać w danym temacie. Przykład jest bardzo prosty, ma zilustrować tylko problem. Sytuacja jest dosyć prosta przy aplikacjach monolitycznych, jeden kontener, jedna wersja wirtualnej maszyny Javy.

W podejściu mikroserwisów, dodatkowo możemy mieć wiele aplikacji, każda działająca na innej wersji wirtualnej maszyny, a nawet napisanych w różnych językach działających na JVM’ie, np. Java, Scala, Kotlin, Groovy… Poziom skomplikowania rośnie.

Kolejnym elementem układanki jest orkiestrator. Do wyboru mamy kilka, będąc w temacie blisko Docker’a musimy zwrócić uwagą na Docker Swarm oraz Docker Enterprise. Jednak największym graczem wydaje się Kubernetes. Przez ostatni czas zyskuje duży udział rynku, wspierany przez największe platformy typu AWS, Azure czy Google Cloud Platform. Orkiestrator zapewnia m.in. zarządzanie, wdrażanie i auto skalowanie aplikacji.


Zdjęcie główne artykułu pochodzi z unsplash.com.

najwięcej ofert html

Posiada ponad dziesięcioletnie doświadczenie w pisaniu aplikacji, głównie w języku Java. Zafascynowany kulturą DevOps i architekturą mikroserwisową, a co za tym idzie rozwiązaniami konteneryzacji Docker, Kubernetes i rozwiązań chmurowych. Szkoli, dzieli się wiedzą i mentoruje w szkołach IT. Miłośnik dobrych meetup’ów.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/docker-w-srodowisku-javy-problemy-i-wyzwania/" order_type="social" width="100%" count_of_comments="8" ]