SpringBoot(Thymeleaf)で動的なリダイレクトをするとメモリリークする


この記事の概要

  • SpringBootを使用したWebアプリケーションを運用していた
  • JVMメモリを見てみると単調増加している。。。
  • 調査・対応してみたのでそのときのメモ

環境

  • Java8
  • spring-boot-starter-web:2.1.1
  • spring-boot-starter-thymeleaf:2.1.1

調査

  • まずはどんなオブジェクトが増え続けているのか調べる
  • アプリケーションはkubernetesで管理されている
  • 権限管理の問題でpod内には入れない → jmapでheapdumpが取れない

そこでmanagementエンドポイントを使うことにした

management:
  endpoints:
    web:
      exposure:
        include: "heapdump"
      base-path: "/"
  server:
    port: 9990

上記のようにincludeheapdumpを記載することで以下のように実行するとheampdumpが取得できる!

curl localhost:9990/heapdump -o heap.dump

あとはEclipseのMemoryAnalyzerで調査!

使い方や調査結果は省略させていただきます

原因

  • MemoryAnalyzerで調査したところリダイレクト時にURLをデフォルトでキャッシュしている処理があり、キャッシュが溢れているようだった
  • こんなのがダメ
リダイレクトサンプル
@Controller
public class Sample {
    @GetMapping("/sample")
    public String sample() {
        return "redirect:/hoge/" + UUID.randomUUID().toString();
    }
}
  • org.thymeleaf.spring5.view.ThymeleafViewResolver.createViewが犯人
抜粋
// Process redirects (HTTP redirects)
if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
    vrlogger.trace("[THYMELEAF] View \"{}\" is a redirect, and will not be handled directly by ThymeleafViewResolver.", viewName);
    final String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length(), viewName.length());
    final RedirectView view = new RedirectView(redirectUrl, isRedirectContextRelative(), isRedirectHttp10Compatible());
    return (View) getApplicationContext().getAutowireCapableBeanFactory().initializeBean(view, viewName);
}
  • リダイレクトURLごとにBean生成しているのね。。。

対応

  • ThymeleafViewResolverを継承して以下のようなクラスを作った
import java.util.Locale;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.InternalResourceView;
import org.springframework.web.servlet.view.RedirectView;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;

@Slf4j
public class ThymeleafViewResolverWrapper extends ThymeleafViewResolver {

    public static final String REDIRECT_URL_PREFIX = "redirect:";

    @Override
    protected View createView(final String viewName, final Locale locale) throws Exception {
        // First possible call to check "viewNames": before processing redirects and forwards
        if (!getAlwaysProcessRedirectAndForward() && !canHandle(viewName, locale)) {
            log.trace("[THYMELEAF] View \"{}\" cannot be handled by ThymeleafViewResolver. Passing on to the next resolver in the chain.", viewName);
            return null;
        }
        // Process redirects (HTTP redirects)
        if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
            log.trace("[THYMELEAF] View \"{}\" is a redirect, and will not be handled directly by ThymeleafViewResolver.", viewName);
            final String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length());
            return new RedirectView(redirectUrl, isRedirectContextRelative(), isRedirectHttp10Compatible());
        }
        // Process forwards (to JSP resources)
        if (viewName.startsWith(FORWARD_URL_PREFIX)) {
            // The "forward:" prefix will actually create a Servlet/JSP view, and that's precisely its aim per the Spring
            // documentation. See http://docs.spring.io/spring-framework/docs/4.2.4.RELEASE/spring-framework-reference/html/mvc.html#mvc-redirecting-forward-prefix
            log.trace("[THYMELEAF] View \"{}\" is a forward, and will not be handled directly by ThymeleafViewResolver.", viewName);
            final String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
            return new InternalResourceView(forwardUrl);
        }
        // Second possible call to check "viewNames": after processing redirects and forwards
        if (getAlwaysProcessRedirectAndForward() && !canHandle(viewName, locale)) {
            log.trace("[THYMELEAF] View \"{}\" cannot be handled by ThymeleafViewResolver. Passing on to the next resolver in the chain.", viewName);
            return null;
        }
        log.trace("[THYMELEAF] View {} will be handled by ThymeleafViewResolver and a " +
                "{} instance will be created for it", viewName, getViewClass().getSimpleName());
        return loadView(viewName, locale);
    }
}
  • Bean化しているところを消しただけ
  • 解決!!!