Czym jest Kubernetes i jak używać go w aplikacjach Java
Author
Aleksander Kamczyc
Trener
blog_date_icon
20 września

Z artykułu dowiecie się więcej o Kubernetes (K8s). W skrócie jest platforma, na której są instalowane aplikacje biznesowe i operacyjne. Dowiecie się dlaczego, według autora, K8s jest odpowiednim rozwiązaniem do pełnienia funkcji jeśli nie platformy, to przynajmniej jej solidnych podwalin. W dalszej części artykułu wyjaśniamy, dlaczego instalując K8s, warto postawić na Infrastructure as Code, na przykładzie skryptu w Terraform „stawiający” K8s na Digital Ocean.

Ponadto opisane zostało przygotowanie aplikacji napisanej w języku Java i wykorzystującej biblioteki ze stajni Spring Framework do zainstalowania jej na K8s. Dowiecie się też więcej na temat sposobów „konteneryzacji” aplikacji Java. Pokazane w artykule przykłady zawierają najlepsze praktyki konfiguracji niektórych artefaktów K8s. W ostatnim rozdziale wymienione zostały aplikacje, które ułatwiają codzienną pracę z Java, Java@K8s i K8s.

Dlaczego Kubernetes?

Cytując kubernetes.io/pl: “Kubernetes, znany też jako K8s, to otwarte oprogramowanie, służące do automatyzacji procesów uruchamiania, skalowania i zarządzania aplikacjami w kontenerach”. To nie jedyny produkt z podobnym opisem, bo innym przykładem może być OpenShift albo Docker Swarm i jeśli trafiłeś tutaj, bo musisz wybrać któryś produkt z tej rodziny, to za Kubernetesem przemawiają następujące argumenty:

  • Bazuje na systemie Borg, który z sukcesem, od wielu lat dźwiga serwisy Google.
  • Ma duży i dynamicznie rozwijający się ekosystem.
  • Szeroko dostępne są serwisy, wsparcie i dodatkowe narzędzia.
  • System sprawdzony na wielu produkcjach.
  • Jest częścią CNCF. Ma bardzo bogatą dokumentację, nawet pewna jej część napisana jest w języku polskim.

Wymagania: Żeby w pełni korzystać z możlwości K8s konieczna jest aplikacja w kontenerze. Twórcy umożliwili pracę tylko z kontenerami. Czy w ten sposób wskazują sposób, w jaki obecnie powinny być robione aplikacje? Być może właśnie tak jest. Poznajcie najważniejsze zalety kontenerów:

  • Efektywne wykorzystanie zasobów maszyny.
  • Kontener buduje się szybko i łatwiej niż VM.
  • Spójność aplikacji na różnych środowiskach: upraszczając - aplikacja działa w ten sam sposób niezależnie czy jest uruchamiana podczas developmentu na laptopie, czy zainstalowana w chmurze.
  • Przewidywalna wydajność, poprzez dedykowanie zasobów.
  • Aplikacja może być uruchomiona na różnych systemach operacyjnych i platformach chmurowych: Ubuntu, Container Optimized System, Windows, dowolnego dostawcy usług chmurowych itd.

Co zapewnia Kubernetes:

  • Jasno opisane API + szeroka adaptacja produktu = duży wybór aplikacji, rozwiązujących problemy operacyjne oraz biznesowe.
  • Samo zainstalowanie aplikacji na produkci nie jest wystarczające. Trzeba dbać o nią – na przykład uruchomiać ponownie, jeśli z jakiegoś powodu przestała działać lub przenieść na inną maszynę, kiedy obecna “niedomaga”.
  • Balansowanie ruchu. Uruchomiając kilka instancji aplikacji, Kubernetes może bilansować ruch między nimi.
  • Umożliwia pracę z różnymi systemami składowania danych w ten sam sposób.
  • Instalacja aplikacji, jej zmiana i wycofywanie jest automatyczne. Używając składni, Kubernetesowi opisuje się stan oczekiwany, a on zajmuje się jego realizacją.
  • Zarządzanie dostępnymi zasobami. Po dostarczeniu klastra maszyn Kubernetesowi, zleca się uruchamianie aplikacji, z kolei Kubernetes zajmuję się wyszukiwaniem odpowiedniej maszyny, aby jak najefektywniej wykorzystać zasoby.
  • Monitoruje aplikacje i automatycznie je restartuje albo wymienia na nowe, jeśli przestają działać. Co więcej, nie kieruje ruchu do aplikacji jeśli nie są one gotowe.
  • Udostępnia narzędzia do zarządzania informacjami poufnymi i konfiguracją.

Pojęcie “Infrastructure as Code”

Pojęcie Infrastructure as Code (IaC) nie jest nowe, ale dopiero od niedawna stało się standardem w tworzeniu infrastruktury i jej komponentów. Poprzez infrastrukturę rozumiemy np. utworzenie sieci i podsieci u dostawcy chmury publicznej lub „postawienia” klastra Kubernetes na wirtualkach.

IaC nakazuje tworzyć infrastrukturę wyłącznie jako kod, w narzędziach dedykowanych. Narzędzia cechują się tym, że kod można uruchamiać bezpiecznie wiele razy, nie narażając się na nadpisanie albo usunięcie tego co już działa. Systemy takie powinny też umożliwiać postawienie infrastruktury od nowa.

Rozwiązaniem nierzetelnym i niebezpiecznym, chociaż najszybszym, jest zainstalowanie komponentów i ich skonfigurowanie poprzez kliknięcie albo uruchomienie instalatorów ręcznie. Nie zawsze notujemy, co dzieje się podczas takie go uruchamiania. Dlatego też nie jest to dobre rozwiązanie. Dlaczego jeszcze? Poniżej powody:

  • Jest to nieefektywne czasowo, a co za tym idzie również finansowo.
  • Jest to niebezpieczne, bo może się nam wydawać, że notatki są kompletne. Jednak z uwagi na to, że nigdy nie były powtórzone od początku, to jakiegoś kroku może brakować. W sytuacji kiedy np. trzeba coś zainstalować i jeszcze skonfigurować, to nie używając dedykowanego narzędzia, praktycznie niemożliwym staje się rzetelne udokumentowanie kroków.

Używanie dedykowanych systemów umożliwia wykorzystanie aplikacji i technik, które powstały wokół IaC. Na przykład technika Shift Left zakłada, że najtańszym (nie tylko pod względem finansowym) miejscem na wyłapanie błędów i nieprawidłowości w produkcie technicznym jest faza budowy. Stąd też kładzie się duży nacisk na testowanie i analizowanie produktu jak najbliżej fazy budowania. Przykładami są:

  • Pisanie testów jednostkowych i uniemożliwianie zmiany kodu, póki testy nie pokrywają np. 80% całego kodu.
  • Użycie analizatora wykorzystywanych bibliotek w celu wykrycia tych „z dziurami” bezpieczeństwa.
  • Używanie Snyk, checkov, czyli narzędzi do analizy kodu Terraform (i nie tylko) i wskazywanie miejsc niebezpiecznych albo niezgodnych z tak zwanymi „best practices”. Jeśli stawiając infrastrukturę instalowany jest system operacyjny z dziurą bezpieczeństwa, albo utworzymy publicznie dostępny zasób S3, to te systemy informują o takiej sytuacji.

Kubernetes a Java

Dostosowanie aplikacji, w tym aplikacji Java, do zainstalowania na produkcji jest często kontekstowe, zależne od tego, w jakiej firmie i z kim pracujesz. Nie mniej można wyróżnić praktyki, które są uznane przez większość programistów.

Konfiguracja jest niezależnym artefaktem

Niezależnie od tego, jak jest zbudowana aplikacja – jar, war, Docker image, konfiguracja w większości przypadków nie powinna być częścią wspomnianego artefaktu. Aplikacja powinna jej szukać na systemie plików albo np. odpytywać serwis konfiguracyjny. Jeśli jest częścią „pakietu", wówczas zmiana konfiguracji będzie wyzwalała proces budowania aplikacji. To oznacza, że:

  • W przypadku developmentu niepotrzebnie wydłuża proces „develop – release – test”.
  • W dojrzałych projektach Continuous Integration pipeline jest rozbudowany i wieloetapowy, co znacznie wydłuża proces.
  • Spowalnia „hot fixing” i „troubleshooting”. W przeciwnym przypadku można byłoby zmienić konfigurację bezpośrednio na środowisku i uruchomiać ponownie aplikację (bez jej przebudowy), która zaczyta nowy config.

Poniżej przykładowa konfiguracja aplikacji Java w Spring Framework - /etc/app/application.yaml:

    app:
      welcome-message: Welcome!
    management:
      server.port: 8090

Natomiast to jest konfiguracja biblioteki logującej - /etc/app/logback.xml

<?xml version="1.0" encoding="UTF-8"?><configuration scan="true" scanPeriod="30 seconds">  <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>  </appender>  <root level="info">    <appender-ref ref="stdout"/>  </root></configuration>

Uruchamiając tę aplikację, trzeba wskazać miejsca, gdzie te dwa pliki konfiguracyjne się znajdują. Można to zrobić poprzez argumenty JVM:

Java … -Dspring.config.location=file:/etc/app/ -Dlogback.configurationFile=/etc/app/logback.xml -Dlogback.statusListenerClass=ch.qos.logback.core.status.OnConsoleStatusListener

Co w przypadku instalacji aplikacji na Kubernetes można zrobić następującym sposobem:

  1. Tworzony jest ConfigMap z treścią z application.yaml, który później zostanie zamontowany pod odpowiednią ścieżkę w kontenerze:
kind: ConfigMapapiVersion: v1metadata:  name: app-configdata:  application.yaml: |-    app:
      welcome-message: Welcome!
    management:
      server.port: 8090
  1. Tworzony jest kolejny ConfigMap z treścią z logback.xml, który również będzie zamontowany w kontenerze:
kind: ConfigMapapiVersion: v1metadata:  name: app-config-logbackdata:  logback.xml: |-    <?xml version="1.0" encoding="UTF-8"?>     <configuration scan="true" scanPeriod="30 seconds">       <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">         <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>       </appender>      <root level="info">        <appender-ref ref="stdout"/>      </root>     </configuration>
  1. Instalując aplikację na Kubernetes, montowane są poniższe pliki konfiguracyjne:
...
spec:
...
  template:
...
    spec:
...
        containers:
        - name: k8s-java-hello-world
          livenessProbe:
            httpGet:
              path: /health
              port: 8090
...
          volumeMounts:
            - mountPath: /etc/app/application.yaml
              name: application-configuration
              subPath: application.yaml
              readOnly: true
            - mountPath: /etc/app/logback.xml
              name: application-configuration-logback
              subPath: logback.xml
              readOnly: true
...
          env:
            - name: "JAVA_TOOL_OPTIONS"
              value: |
                -Dspring.config.location=file:/etc/app/
...
      volumes:
        - configMap:
            name: app-config
          name: application-configuration
        - configMap:
            name: app-config-logback
          name: application-configuration-logback

Udostępnij status aplikacji

Aby Kubernetes mógł wykonać jedną ze swoich odpowiedzialności, należy:

  • Uruchomić ponownie aplikację jeśli ta nie działa prawidłowo (na przykład Spring Context nie wstał).
  • Nie kierować ruchu do aplikacji, jeśli ta jest niegotowa (na przykład Spring Context jeszcze nie wstał albo nie udaje się połączyć z Kafka).

Jedną z możliwości jest dodanie zależności Spring Actuator:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
  <version>${spring-boot.version}</version>
</dependency> 

Aktywacja:

kind: ConfigMap
...
data:
  application.yaml: |-
    app:
      welcome-message: Welcome!
    management:
      server.port: 8090
      health.binders.enabled: true
      endpoints:
        web:
          base-path: /
          exposure.include: [health]
        enabled-by-default: false
      endpoint:
        health.enabled: true

Wskazanie ścieżki do zasobu Health Kubernetes:

kind: Deployment
...
spec:
...
  template:
...
    spec:
...
        containers:
        - name: k8s-java-hello-world
...
          livenessProbe:
            httpGet:
              path: /health
              port: 8090

Czas by aplikacja opowiedziała o sobie

Bardzo ważna jest wiedza o funkcjonowaniu aplikacji – musimy wiedzieć, czy aplikacja „chodzi”, czy odnotowany jest ruch, jak długo wykonuje zadania biznesowe (np. ile trwa odpowiedź na zapytanie HTTP), ile zadań kończy się błędami, jakie jest zużycie procesora i pamięci, jakie są pauzy GC itd. Dopiero mając tę wiedzę można wnioskować o poprawny UX.

Wyżej użyta biblioteka Spring Actuator instrumentuje wiele popularnych framework’ów, takich jak Spring MVC, RestTemplate, Kafka Client itd. Co oznacza, że nie robiąc praktycznie nic, aplikacja informuje np. o liczbie request’ów HTTP, ich statusie wykonania jak długo trwały. W przypadku Kafka takie informacje jak ilość wysłanych bajtów, LAG per topic itd.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>${spring-boot.version}</version>
</dependency>

Musimy jeszcze zdecydować jakim narzędziem te metryki będą zbierane. Spring Actuator pod spodem używa Micrometer a jego implementację wspiera format rozumiany przez Prometheus. Aplikacje składowe Kubernetes generują metryki właśnie w tym standardzie. Wskazujemy ten standard poprzez dodanie zależności:

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <version>${micrometer-registry-prometheus.version}</version>
</dependency>

Następnie aktywujemy endpoint:

kind: ConfigMap
...
data:
  application.yaml: |-
    app:
      welcome-message: Welcome!
    management:
      server.port: 8090
      health.binders.enabled: true
      endpoints:
        web:
          base-path: /
          path-mapping.prometheus: metrics
          exposure.include: [prometheus, health]
        enabled-by-default: false
      endpoint:
        prometheus.enabled: true
        health.enabled: true
    server:
      tomcat:
        mbeanregistry:
          enabled: true  

Po uruchomieniu aplikacji na lokalnym PC wynik powyższych kroków można zaobserwować poprzez odpytanie endpoint’a:

➜ mvn clean spring-boot:run

➜ curl localhost:8090/health 
{"status":"UP"}%

➜ curl localhost:8090/metrics -s | grep http
tomcat_global_received_bytes_total{name="http-nio-8080",} 0.0
tomcat_connections_current_connections{name="http-nio-8080",} 1.0
tomcat_global_request_max_seconds{name="http-nio-8080",} 0.0
tomcat_connections_keepalive_current_connections{name="http-nio-8080",} 0.0
tomcat_connections_config_max_connections{name="http-nio-8080",} 8192.0
tomcat_threads_busy_threads{name="http-nio-8080",} 0.0
tomcat_global_request_seconds_count{name="http-nio-8080",} 0.0
tomcat_global_request_seconds_sum{name="http-nio-8080",} 0.0

Kolejnym krokiem jest zainstalowanie Prometheus i Grafana i obserwacja:

k8s-blog.webp

Uruchamianie serwisów sieciowych na różnych portach

Obsługa zapytań “biznesowych” jest innym zagadnieniem niż metryki. Dlatego najlepiej, żeby były dostępne na różnych portach. Ułatwi to pracę np. DevOps’om, którzy mogą chcieć wyłączyć autentykację jeśli ruch kierowany jest na port metryk lub zastosować inne reguły bezpieczeństwa.

Ułatwi to też konfigurację Istio (problem kubelet odpytujący /health vs mTLS):

data:
  application.yaml: |-
    management:
    # Different port for metrics and healthchecks solve problem Istio vs Kubelet
      server.port: 8090

Stosowanie się do zaleceń - czy to się opłaca?

Kubernetes zaleca użycie ogólnych labelek, które tworzą jego zasoby. Biorąc pod uwagę, jak wiele aplikacji, tworzonych wokół kontraktu i zaleceń, powstaje dla Kubernetes. Takim przykładem jest Kiali, która potrafi zobrazować nasz system, jeśli trzymamy się zaleceń Kubernetes odnośnie labelek:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: k8s-java-hello-world
  labels:
    # K8s standard label - https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
    # Tools around assume such naming convention and building product around it. Ex Istio and Kiali
    app.kubernetes.io/name: k8s-java-hello-world
    # K8s standard label - https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
    # Tools around assume such naming convention and building product around it. Ex Istio and Kiali
    app.kubernetes.io/version: 1.0.0
    # K8s standard label - https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/
    # Tools around assume such naming convention and building product around it. Ex Istio and Kiali
    app.kubernetes.io/part-of: k8s-java-hello-world

k8s-blog-2.webp

Reguła uprawnień na miarę zadań

Ta reguła dotyczy każdego środowiska – uprawnienia powinny być zminimalizowane do tych niezbędnych, a nie root/admin od początku. W przypadku Kubernetes należy ustawić securityContext w specyfikacji, aby kontener nie był uruchamiany spod root:

spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000

Również należy zadbać, aby sam serwis w Kubernetes używał innego konta niż default. Umożliwi to właściwą konfigurację np. IaM u dostawcy chmury, Istio itd.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: k8s-java-hello-world

Aktualizacja bibliotek

Zazwyczaj aplikacja używa biblioteki Spring MVC itd. Właściciele bibliotek od czasu do czasu naprawiają znalezione w nich błędy albo problemy wydajnościowe. Teoretycznie nowsza wersja, to lepszy produkt.

Żeby trzymać rękę na pulsie, wystarczy aktywować plugin w maven albo gradle.

<plugin>
  <groupId>org.codehaus.mojo</groupId>
  <artifactId>versions-maven-plugin</artifactId>
  <version>${versions-maven-plugin.version}</version>
  <executions>
    <execution>
      <phase>verify</phase>
      <goals>
        <goal>display-dependency-updates</goal>
        <goal>display-property-updates</goal>
        <goal>display-plugin-updates</goal>
        <goal>dependency-updates-report</goal>
        <goal>plugin-updates-report</goal>
        <goal>property-updates-report</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Weryfikacja bibliotek pod kątem bezpieczeństwa

OWASP to uznana organizacja zajmująca się bezpieczeństwem w IT – publikuje artykuły na temat znanych dziur w bezpieczeństwie i podpowiada jak im zaradzić. Co więcej, utworzyła i udostępniła wtyczkę, która weryfikuje zależności użyte w naszej aplikacji i bada czy przypadkiem nie znajdują się w bazie bibliotek (National Vulnerability Database), w których wykryto dziurę.

<plugin>
  <groupId>org.owasp</groupId>
  <artifactId>dependency-check-maven</artifactId>
  <version>${dependency-check-maven.version}</version>
  <configuration>
    <failBuildOnCVSS>8</failBuildOnCVSS>
  </configuration>
  <executions>
    <execution>
      <goals>
        <goal>check</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Tworzenie obrazu aplikacji Java w kontenerze

Początkowo jedynym sposobem tworzenia obrazów aplikacji w kontenerze było utworzenie i wypełnienie pliku Dockerfile i uruchomienie aplikacji Docker, która tworzy obraz zgodnie z poleceniami zawartymi w Dockerfile. Stworzenie „dobrego” obrazu wymaga wiedzy z paru technologii:

  • Docker - np. pojęcie warstw obrazu
  • Security – jakiego systemu operacyjnego użyć, jakie aplikacje włączyć/wyłączyć i jak je skonfigurować.

W międzyczasie powstały aplikacje, które agregują w sobie tę wiedzę i odpowiednio tworzą obrazy:

  1. JIB od Google’a. Narzędzie jest dostępne jako plugin do Maven i Gradle, wykorzystywane jest do skonteneryzowania aplikacji wewnątrz Google. Nie wymaga instalacji Docker. Jedno z ważniejszych cech obrazów tworzonych przez JIB jest „distroless” obraz bazowy. JIB trzyma się zasady, że kontener na produkcji powinien mieć (chociaż lepiej napisać czego nie powinien mieć) odinstalowane/wyłączone aplikacje, które nie kładą nacisku wartość biznesową albo nie są wymagane do funkcjonowania systemu operacyjnego. W ten sposób w tak utworzonym obrazie nie znajdzie się bash ani sh.
<plugin>
  <groupId>pl.project13.maven</groupId>
  <artifactId>git-commit-id-plugin</artifactId>
  <version>${git-commit-id-plugin.version}</version>
  <executions>
    <execution>
      <id>get-the-git-infos</id>
      <goals>
        <goal>revision</goal>
      </goals>
      <phase>validate</phase>
    </execution>
  </executions>
  <configuration>
    <dotGitDirectory>${project.basedir}/.git</dotGitDirectory>
  </configuration>
</plugin>
<plugin>
  <groupId>com.google.cloud.tools</groupId>
  <artifactId>jib-maven-plugin</artifactId>
  <version>${jib-maven-plugin.version}</version>
  <configuration>
    <to>
      <image>
        ${container.registry.url}/${container.name}:${git.commit.id.abbrev}
      </image>
    </to>
    <container>
      <ports>
        <port>8080</port>
      </ports>
      <creationTime>USE_CURRENT_TIMESTAMP</creationTime>
      <jvmFlags>
        <jvmFlag>-server</jvmFlag>
      </jvmFlags>
  1. Cloud Native Buildpacks (CNB). Specyfikacja i jej implementacja utworzona na potrzeby platformy Heroku w 2011. Od tego czasu zyskała adaptację Cloud Foundry, Google App Engine, Gitlab, Knative, Deis, Dokku i Drie. CNB implementuje najlepsze praktyki przy tworzeniu OCI obrazów. Narzędzie dostępne „out of the box” w wielu CI/CD systemach jako krok do dodania do „pipeline”. Należy pobrać aplikację pack i użyć jej np. w poniższy sposób:
export GIT_SHA="eb7c60b"                                                          

pack build --path . --builder paketobuildpacks/builder:base registry.digitalocean.com/learning/k8s-java-hello-world:${GIT_SHA} --publish

base: Pulling from paketobuildpacks/builder
===> ANALYZING
Previous image with name "registry.digitalocean.com/learning/k8s-java-hello-world:eb7c60b" not found
===> RESTORING
===> BUILDING

*** Images (sha256:dc7bbcc2a5fd3d20d0e506126f7181f57c358e5c9b5842544e6fc1302b934afe):
      registry.digitalocean.com/learning/k8s-java-hello-world:eb7c60b
Adding cache layer 'paketo-buildpacks/bellsoft-liberica:jdk'
Adding cache layer 'paketo-buildpacks/maven:application'
Adding cache layer 'paketo-buildpacks/maven:cache'
Adding cache layer 'paketo-buildpacks/maven:maven'
Successfully built image registry.digitalocean.com/learning/k8s-java-hello-world:eb7c60b

Poznaj więcej możliwości Kubernetesa

Jeżeli poszukujesz kompleksowego szkolenia z kontenerów, sprawdź nasze propozycje:

Więcej szkoleń z tej kategorii znajdziesz na stronie: https://www.sages.pl/szkolenia/kategoria/chmura

Przeczytaj także