В этой статье мы реализуем в приложении работу со статическими файлами и покажем, как правильно отдавать их клиенту.
Приложение в этой статье не предназначено для использования в production без доработки. Готовое к работе в production приложение мы получим в конце руководства.
Подготовка окружения
Если вы ещё не подготовили своё рабочее окружение на предыдущих этапах, сделайте это в соответствии с инструкциями статьи «Подготовка окружения».
Если ваше рабочее окружение работало, но перестало, или же последующие инструкции из этой статьи не работают — попробуйте следующее:
Запустим приложение Docker Desktop. Приложению понадобится некоторое время для того, чтобы запустить Docker. Если никаких ошибок в процессе запуска не возникло, то проверим, что Docker запущен и корректно настроен:
docker run hello-world
Результат успешного выполнения команды:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
При возникновении проблем обратитесь к документации Docker для их устранения.
Запустим приложение Docker Desktop. Приложению понадобится некоторое время для того, чтобы запустить Docker. Если никаких ошибок в процессе запуска не возникло, то проверим, что Docker запущен и корректно настроен:
docker run hello-world
Результат успешного выполнения команды:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
При возникновении проблем обратитесь к документации Docker для их устранения.
Запустим Docker:
sudo systemctl restart docker
Убедимся, что Docker запустился:
sudo systemctl status docker
Результат выполнения команды, если Docker успешно запущен:
● docker.service - Docker Application Container Engine
Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2021-06-24 13:05:17 MSK; 13s ago
TriggeredBy: ● docker.socket
Docs: https://docs.docker.com
Main PID: 2013888 (dockerd)
Tasks: 36
Memory: 100.3M
CGroup: /system.slice/docker.service
└─2013888 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
dockerd[2013888]: time="2021-06-24T13:05:16.936197880+03:00" level=warning msg="Your kernel does not support CPU realtime scheduler"
dockerd[2013888]: time="2021-06-24T13:05:16.936219851+03:00" level=warning msg="Your kernel does not support cgroup blkio weight"
dockerd[2013888]: time="2021-06-24T13:05:16.936224976+03:00" level=warning msg="Your kernel does not support cgroup blkio weight_device"
dockerd[2013888]: time="2021-06-24T13:05:16.936311001+03:00" level=info msg="Loading containers: start."
dockerd[2013888]: time="2021-06-24T13:05:17.119938367+03:00" level=info msg="Loading containers: done."
dockerd[2013888]: time="2021-06-24T13:05:17.134054120+03:00" level=info msg="Daemon has completed initialization"
systemd[1]: Started Docker Application Container Engine.
dockerd[2013888]: time="2021-06-24T13:05:17.148493957+03:00" level=info msg="API listen on /run/docker.sock"
Теперь проверим, что Docker доступен и корректно настроен:
docker run hello-world
Результат успешного выполнения команды:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
b8dfde127a29: Pull complete
Digest: sha256:9f6ad537c5132bcce57f7a0a20e317228d382c3cd61edae14650eec68b2b345c
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
При возникновении проблем обратитесь к документации Docker для их устранения.
Запустим кластер minikube, уже настроенный в начале статьи “Подготовка окружения”:
minikube start
Выставим Namespace по умолчанию, чтобы не указывать его при каждом вызове kubectl
:
kubectl config set-context minikube --namespace=werf-guide-app
Результат успешного выполнения команды:
😄 minikube v1.20.0 on Ubuntu 20.04
✨ Using the docker driver based on existing profile
👍 Starting control plane node minikube in cluster minikube
🚜 Pulling base image ...
🎉 minikube 1.21.0 is available! Download it: https://github.com/kubernetes/minikube/releases/tag/v1.21.0
💡 To disable this notice, run: 'minikube config set WantUpdateNotification false'
🔄 Restarting existing docker container for "minikube" ...
🐳 Preparing Kubernetes v1.20.2 on Docker 20.10.6 ...
🔎 Verifying Kubernetes components...
▪ Using image gcr.io/google_containers/kube-registry-proxy:0.4
▪ Using image k8s.gcr.io/ingress-nginx/controller:v0.44.0
▪ Using image registry:2.7.1
▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🔎 Verifying registry addon...
🔎 Verifying ingress addon...
🌟 Enabled addons: storage-provisioner, registry, default-storageclass, ingress
🏄 Done! kubectl is now configured to use "minikube" cluster and "werf-guide-app" namespace by default
Убедитесь, что вывод команды содержит строку:
Restarting existing docker container for "minikube"
Если её нет, значит был создан новый кластер minikube вместо использования старого. В таком случае повторите установку рабочего окружения с minikube с самого начала.
Теперь запустите команду в фоновом PowerShell-терминале и не закрывайте его:
minikube tunnel --cleanup=true
Запустим кластер minikube, уже настроенный в начале статьи “Подготовка окружения”:
minikube start --namespace werf-guide-app
Выставим Namespace по умолчанию, чтобы не указывать его при каждом вызове kubectl
:
kubectl config set-context minikube --namespace=werf-guide-app
Результат успешного выполнения команды:
😄 minikube v1.20.0 on Ubuntu 20.04
✨ Using the docker driver based on existing profile
👍 Starting control plane node minikube in cluster minikube
🚜 Pulling base image ...
🎉 minikube 1.21.0 is available! Download it: https://github.com/kubernetes/minikube/releases/tag/v1.21.0
💡 To disable this notice, run: 'minikube config set WantUpdateNotification false'
🔄 Restarting existing docker container for "minikube" ...
🐳 Preparing Kubernetes v1.20.2 on Docker 20.10.6 ...
🔎 Verifying Kubernetes components...
▪ Using image gcr.io/google_containers/kube-registry-proxy:0.4
▪ Using image k8s.gcr.io/ingress-nginx/controller:v0.44.0
▪ Using image registry:2.7.1
▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
▪ Using image docker.io/jettech/kube-webhook-certgen:v1.5.1
▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
🔎 Verifying registry addon...
🔎 Verifying ingress addon...
🌟 Enabled addons: storage-provisioner, registry, default-storageclass, ingress
🏄 Done! kubectl is now configured to use "minikube" cluster and "werf-guide-app" namespace by default
Убедитесь, что вывод команды содержит строку:
Restarting existing docker container for "minikube"
Если её нет, значит был создан новый кластер minikube вместо использования старого. В таком случае повторите установку рабочего окружения с minikube с самого начала.
Если вы непреднамеренно удалили Namespace приложения, то необходимо выполнить следующие команды, чтобы продолжить прохождение руководства:
kubectl create namespace werf-guide-app
kubectl create secret docker-registry registrysecret \
--docker-server='https://index.docker.io/v1/' \
--docker-username='<ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>' \
--docker-password='<ПАРОЛЬ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>'
Результат успешного выполнения команды:
namespace/werf-guide-app created
secret/registrysecret created
Если ничего не помогло, то пройдите статью «Подготовка окружения» с начала, подготовив новое окружение с нуля. Если и это не помогло, тогда, пожалуйста, расскажите о своей проблеме в нашем Telegram или оставьте Issue на GitHub, и мы обязательно вам поможем.
Подготовка репозитория
Обновим существующий репозиторий с приложением:
Выполним следующий набор команд в PowerShell:
cd ~/werf-guide/app
# Чтобы увидеть, какие изменения мы собрались вносить далее в этой статье, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -Recurse -Force ~/werf-guide/guides/examples/java_springboot/030_assets/* .
git add .
git commit -m WIP
# Показать, какие файлы мы собираемся изменить.
git show --stat
# Показать изменения.
git show
Выполним следующий набор команд в Bash:
cd ~/werf-guide/app
# Чтобы увидеть, какие изменения мы собрались вносить далее в этой статье, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -rf ~/werf-guide/guides/examples/java_springboot/030_assets/. .
git add .
git commit -m WIP
# Показать, какие файлы мы собираемся изменить.
git show --stat
# Показать изменения.
git show
Не работает? Попробуйте инструкции на вкладке «Начинаю проходить руководство с этой статьи» выше.
Подготовим новый репозиторий с приложением:
Выполним следующий набор команд в PowerShell:
# Склонируем репозиторий с примерами в ~/werf-guide/guides, если он ещё не был склонирован.
if (-not (Test-Path ~/werf-guide/guides)) {
git clone https://github.com/werf/website $env:HOMEPATH/werf-guide/guides
}
# Скопируем файлы приложения (пока без изменений) в ~/werf-guide/app.
rm -Recurse -Force ~/werf-guide/app
cp -Recurse -Force ~/werf-guide/guides/examples/java_springboot/020_logging ~/werf-guide/app
# Сделаем из директории ~/werf-guide/app git-репозиторий.
cd ~/werf-guide/app
git init
git add .
git commit -m initial
# Чтобы увидеть, какие изменения мы собрались вносить далее в этой статье, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -Recurse -Force ~/werf-guide/guides/examples/java_springboot/030_assets/* .
git add .
git commit -m WIP
# Показать, какие файлы мы собираемся изменить.
git show --stat
# Показать изменения.
git show
Выполним следующий набор команд в Bash:
# Склонируем репозиторий с примерами в ~/werf-guide/guides, если он ещё не был склонирован.
test -e ~/werf-guide/guides || git clone https://github.com/werf/website ~/werf-guide/guides
# Скопируем файлы приложения (пока без изменений) в ~/werf-guide/app.
rm -rf ~/werf-guide/app
cp -rf ~/werf-guide/guides/examples/java_springboot/020_logging ~/werf-guide/app
# Сделаем из директории ~/werf-guide/app git-репозиторий.
cd ~/werf-guide/app
git init
git add .
git commit -m initial
# Чтобы увидеть, какие изменения мы собрались вносить далее в этой статье, заменим все файлы приложения
# в репозитории новыми, уже измененными файлами приложения, которые содержат описанные далее изменения.
git rm -r .
cp -rf ~/werf-guide/guides/examples/java_springboot/030_assets/. .
git add .
git commit -m WIP
# Показать, какие файлы мы собираемся изменить.
git show --stat
# Показать изменения.
git show
Добавление страницы /image
в приложение
Добавим нашему приложению новый endpoint /image
, который будет отдавать страницу, использующую набор статических файлов. JS-, CSS- и медиафайлы будут раздаваться штатными средствами Spring Boot.
Сейчас наше приложение представляет собой простой API, без настроенных средств управления статичными файлами и frontend-кодом.
Чтобы сделать из этого API простое веб-приложение, мы произвели следующие изменения. Для начала подключили к проекту движок шаблонов Thymeleaf, добавив в pom.xml
нужную зависимость.
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Затем создали структуру каталогов, в которых будем хранить статические файлы:
$ tree ./src/main/resources/
./src/main/resources/
├── application.properties
├── log4j2-spring.xml
├── static
│ ├── images
│ │ └── werf-logo.svg
│ ├── javascripts
│ │ └── image.js
│ └── stylesheets
│ └── site.css
└── templates
├── image.html
└── index.html
В каталог static
в соответствующих подкаталогах были положены статические файлы, которые мы будем раздавать. В каталог templates
мы положили шаблоны страниц для Thymeleaf.
Теперь нужно настроить приложение на раздачу файлов из каталога static
. Для этого был создан конфигурационный класс MvcConfig
:
package io.werf.werfguidesapp.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
}
}
В нем мы указали, что содержимое каталога static
может быть свободно доступно извне по endpoint’у /static
.
Теперь добавим новые контроллеры, которые будут обрабатывать запросы по endpoint’ам /
и /image
:
package io.werf.werfguidesapp.controllers;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ImageController {
@GetMapping("/")
public String MainPath() {
return "index";
}
@GetMapping("/image")
public String Image() {
return "image";
}
}
Контроллеры обращаются к файлам шаблонов Thymeleaf, расположение которых мы указывали выше. Создадим их:
<html>
<head>
<title>Werf Guide App</title>
<link rel="stylesheet" href="static/stylesheets/site.css" />
</head>
<body>
<h1>Wef Guide App based on Express</h1>
<p>Welcome to Werf guide. Visit <a href="/image">the image page</a>.</p>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<title>Werf Logo</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet" href="static/stylesheets/site.css" />
<script src="static/javascripts/image.js"></script>
</head>
<body>
<div id="container">
<!-- The "Get image" button; it will generate an Ajax request when clicked. -->
<button type="button" id="show-image-btn">Get image</button>
</div>
</body>
</html>
При нажатии на кнопку Get image должен выполняться Ajax-запрос, с помощью которого мы получим и отобразим SVG-картинку:
window.onload=function(){
var btn = document.getElementById("show-image-btn")
btn.addEventListener("click", _ => {
fetch("/static/images/werf-logo.svg")
.then((data) => data.text())
.then((html) => {
const svgContainer = document.getElementById("container")
svgContainer.insertAdjacentHTML("beforeend", html)
var svg = svgContainer.getElementsByTagName("svg")[0]
svg.setAttribute("id", "image")
btn.remove()
}
)
}
)
}
Также на странице будет использоваться CSS-файл static/stylesheets/site.css.
Приложение обновлено: теперь, в дополнение к уже знакомому по прошлым статьям /ping
, у приложения есть новый endpoint /image
. На последнем отображается страница, использующая для работы разные типы статических файлов.
В начале главы описаны команды, с помощью которых можно увидеть полный, исчерпывающий список изменений, сделанных с приложением в этой статье.
Организация раздачи статических файлов
Сейчас статические файлы отдает сам Spring Boot, используя свои штатные средства. Более верным путем будет использование reverse proxy, наподобие NGINX. Рекомендуется это потому, что производительность и эффективность reverse proxy для раздачи статических файлов на порядки выше, чем у Spring Boot.
Есть несколько способов, как закрыть приложение Spring Boot за reverse proxy в Kubernetes. Мы будем использовать распространенный и простой способ, который, тем не менее, хорошо масштабируется. В нем перед каждым контейнером Spring Boot поднимается NGINX-контейнер (в том же Pod), через который в приложение проксируются все запросы, кроме запросов на статические файлы. Статические файлы отдаёт сам NGINX-контейнер, доставая их из своей файловой системы.
Приступим к непосредственной реализации.
Обновление сборки и деплоя
Начнём с реорганизации сборки приложения. Теперь нам требуется собрать не только образ с приложением, но и образ с NGINX, где должны находиться статические файлы приложения, которые NGINX сможет сразу отдать клиенту:
# Используем многоступенчатую сборку образа (multi-stage build).
# Образ, в котором будет собираться проект.
FROM eclipse-temurin:11-alpine AS builder
# Копируем исходники приложения.
COPY . /src
WORKDIR /src
# Запускаем Maven для сборки проекта.
RUN ./mvnw clean package
# Образ, который будет разворачиваться в кластере.
FROM eclipse-temurin:11-alpine as backend
WORKDIR /app
# Копируем из сборочного образа исполняемый файл проекта.
COPY --from=builder /src/target/*.jar ./app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","./app.jar"]
# NGINX-образ со статическими файлами.
FROM nginx:stable-alpine as frontend
WORKDIR /www
# Копируем статические файлы.
COPY src/main/resources/static /www/static/
# Копируем конфигурацию NGINX.
COPY .werf/nginx.conf /etc/nginx/nginx.conf
Во время сборки в образ с NGINX будет добавлен его конфигурационный файл:
user nginx;
worker_processes 1;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend {
server 127.0.0.1:8080 fail_timeout=0;
}
server {
listen 80;
server_name _;
root /www;
client_max_body_size 100M;
keepalive_timeout 10s;
# По пути /static отдадим ассеты напрямую из файловой системы NGINX-контейнера.
location /static {
expires 1y;
add_header Cache-Control public;
add_header Last-Modified "";
add_header ETag "";
# Когда есть возможность, отдаём заранее сжатые файлы (вместо сжатия на лету).
gzip_static on;
access_log off;
try_files $uri =404;
}
# Ассеты медиафайлов (картинки и т.п.) также отдадим из файловой системы NGINX-контейнера, но
# отключим для них сжатие gzip.
location /static/images {
expires 1y;
add_header Cache-Control public;
add_header Last-Modified "";
add_header ETag "";
access_log off;
try_files $uri =404;
}
# Все запросы, кроме запросов на получение ассетов, отправляются на Spring Boot бэкенд.
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://backend;
}
}
}
Обновим конфигурацию werf.yaml
, чтобы werf собрал и сохранил два образа (backend, frontend) вместо одного:
project: werf-guide-app
configVersion: 1
---
image: backend
dockerfile: Dockerfile
target: backend
---
image: frontend
dockerfile: Dockerfile
target: frontend
Добавим новый NGINX-контейнер в Deployment приложения:
apiVersion: apps/v1
kind: Deployment
metadata:
name: werf-guide-app
spec:
replicas: 1
selector:
matchLabels:
app: werf-guide-app
template:
metadata:
labels:
app: werf-guide-app
spec:
imagePullSecrets:
- name: registrysecret
containers:
- name: backend
image: {{ .Values.werf.image.backend }}
ports:
- containerPort: 8080
env:
- name: LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_WEB
value: INFO
- name: frontend
image: {{ .Values.werf.image.frontend }}
ports:
- containerPort: 80
Теперь Service и Ingress должны обращаться на 80-й порт, а не 8080-й, чтобы все запросы шли через NGINX, а не напрямую на наше приложение:
apiVersion: v1
kind: Service
metadata:
name: werf-guide-app
spec:
selector:
app: werf-guide-app
ports:
- name: http
port: 80
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
kubernetes.io/ingress.class: nginx
name: werf-guide-app
spec:
rules:
- host: werf-guide-app.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: werf-guide-app
port:
number: 80
Проверка
Теперь попробуем переразвернуть приложение:
werf converge --repo <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app
Ожидаемый результат:
...
┌ Concurrent builds plan (no more than 5 images at the same time)
│ Set #0:
│ - ⛵ image backend
│ - ⛵ image frontend
└ Concurrent builds plan (no more than 5 images at the same time)
┌ ⛵ image backend
│ Use cache image for backend/dockerfile
│ name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app:0b759f60b7405e96d44b7139ada9b57a6cf92bab10de79d753b7e6bc-1642676484362
│ id: d9f9545fbd21
│ created: 2022-01-20 14:01:21 +0000 UTC
│ size: 204.0 MiB
└ ⛵ image backend (4.79 seconds)
┌ ⛵ image frontend
│ Use cache image for frontend/dockerfile
│ name: <ИМЯ ПОЛЬЗОВАТЕЛЯ DOCKER HUB>/werf-guide-app:2e2e91751acd1cf77cfdbf57f9af1f21782b6cbc725f8e7c9cd31b3f-1642676498242
│ id: 5e3f3f616bae
│ created: 2022-01-20 14:01:37 +0000 UTC
│ size: 9.6 MiB
└ ⛵ image frontend (4.81 seconds)
┌ Waiting for release resources to become ready
│ ┌ Status progress
│ │ DEPLOYMENT REPLICAS AVAILABLE UP-TO-DATE
│ │ werf-guide-app 1/1 1 1 ↵
│ │
│ └ Status progress
└ Waiting for release resources to become ready (0.03 seconds)
Release "werf-guide-app" has been upgraded. Happy Helming!
NAME: werf-guide-app
LAST DEPLOYED: Thu Jan 20 14:06:30 2022
NAMESPACE: werf-guide-app
STATUS: deployed
REVISION: 50
TEST SUITE: None
Running time 9.47 seconds
Мы не стали приводить здесь полный вывод сборки, т.к Maven в процессе скачивания всех зависимостоей генерирует очень много текста.
Откроем в браузере http://werf-guide-app.test/image и нажмём на кнопку Get image. Ожидаемый результат:
Также обратим внимание на то, какие ресурсы были запрошены и по каким ссылкам (последний ресурс здесь получен через Ajax-запрос):
Теперь наше приложение является не просто API, но веб-приложением, которое имеет средства для эффективного менеджмента статических файлов и JavaScript.
Также наше приложение готово выдерживать приличные нагрузки при большом количестве запросов к статическим файлам, и эти запросы не будут сказываться на работе приложения в целом. Масштабирование же приложения Spring Boot (отвечает за динамический контент) и NGINX (статический контент) происходит простым увеличением количества реплик (replicas
) в Deployment’е приложения.