Add MVC actuator endpoint for heap dumps

Add MVC only endpoint to obtain GZip compressed heap dump files.

See gh-5670
This commit is contained in:
Lari Hotari
2016-04-12 21:42:42 -04:00
committed by Phillip Webb
parent e1893f66ce
commit 9f07e94450
7 changed files with 427 additions and 10 deletions

View File

@@ -20,12 +20,15 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import groovy.text.Template;
import groovy.text.TemplateEngine;
@@ -70,6 +73,9 @@ public class EndpointDocumentation {
static final File LOG_FILE = new File("target/logs/spring.log");
private static final Set<String> SKIPPED = Collections.<String>unmodifiableSet(
new HashSet<String>(Arrays.asList("/docs", "/logfile", "/heapdump")));
@Autowired
private MvcEndpoints mvcEndpoints;
@@ -103,28 +109,27 @@ public class EndpointDocumentation {
@Test
public void endpoints() throws Exception {
final File docs = new File("src/main/asciidoc");
final Map<String, Object> model = new LinkedHashMap<String, Object>();
final List<EndpointDoc> endpoints = new ArrayList<EndpointDoc>();
model.put("endpoints", endpoints);
for (MvcEndpoint endpoint : getEndpoints()) {
final String endpointPath = StringUtils.hasText(endpoint.getPath())
? endpoint.getPath() : "/";
if (!endpointPath.equals("/docs") && !endpointPath.equals("/logfile")) {
final String endpointPath = (StringUtils.hasText(endpoint.getPath())
? endpoint.getPath() : "/");
if (!SKIPPED.contains(endpointPath)) {
String output = endpointPath.substring(1);
output = output.length() > 0 ? output : "./";
this.mockMvc.perform(get(endpointPath).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()).andDo(document(output))
.andDo(new ResultHandler() {
@Override
public void handle(MvcResult mvcResult) throws Exception {
EndpointDoc endpoint = new EndpointDoc(docs,
endpointPath);
endpoints.add(endpoint);
}
});
}
}

View File

@@ -31,6 +31,7 @@ import org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping;
import org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMappingCustomizer;
import org.springframework.boot.actuate.endpoint.mvc.EnvironmentMvcEndpoint;
import org.springframework.boot.actuate.endpoint.mvc.HealthMvcEndpoint;
import org.springframework.boot.actuate.endpoint.mvc.HeapdumpMvcEndpoint;
import org.springframework.boot.actuate.endpoint.mvc.LogFileMvcEndpoint;
import org.springframework.boot.actuate.endpoint.mvc.MetricsMvcEndpoint;
import org.springframework.boot.actuate.endpoint.mvc.MvcEndpoint;
@@ -132,6 +133,13 @@ public class EndpointWebMvcManagementContextConfiguration {
return new EnvironmentMvcEndpoint(delegate);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnEnabledEndpoint("heapdump")
public HeapdumpMvcEndpoint heapdumpMvcEndpoint() {
return new HeapdumpMvcEndpoint();
}
@Bean
@ConditionalOnBean(HealthEndpoint.class)
@ConditionalOnEnabledEndpoint("health")

View File

@@ -0,0 +1,233 @@
/*
* Copyright 2012-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint.mvc;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.PlatformManagedObject;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.zip.GZIPOutputStream;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.UsesJava7;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
/**
* {@link MvcEndpoint} to expose heap dumps.
*
* @author Lari Hotari
* @author Phillip Webb
* @since 1.4.0
*/
@ConfigurationProperties("endpoints.heapdump")
@HypermediaDisabled
public class HeapdumpMvcEndpoint extends AbstractMvcEndpoint implements MvcEndpoint {
private final long timeout;
private final Lock lock = new ReentrantLock();
private HeapDumper heapDumper;
public HeapdumpMvcEndpoint() {
this(TimeUnit.SECONDS.toMillis(10));
}
protected HeapdumpMvcEndpoint(long timeout) {
super("/heapdump", true);
this.timeout = timeout;
}
@RequestMapping(method = RequestMethod.GET, produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public void invoke(@RequestParam(defaultValue = "true") boolean live,
HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {
if (!isEnabled()) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
try {
if (this.lock.tryLock(this.timeout, TimeUnit.MILLISECONDS)) {
try {
dumpHeap(live, request, response);
return;
}
finally {
this.lock.unlock();
}
}
}
catch (InterruptedException ex) {
// Ignore
}
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
}
private void dumpHeap(boolean live, HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException, InterruptedException {
if (this.heapDumper == null) {
this.heapDumper = createHeapDumper();
}
File file = createTempFile(live);
try {
this.heapDumper.dumpHeap(file, live);
handle(file, request, response);
}
finally {
file.delete();
}
}
private File createTempFile(boolean live) throws IOException {
String date = new SimpleDateFormat("yyyy-MM-dd-HH-mm").format(new Date());
File file = File.createTempFile("heapdump" + date + (live ? "-live" : ""),
".hprof");
file.delete();
return file;
}
/**
* Factory method used to create the {@link HeapDumper}.
* @return the heap dumper to use
* @throws HeapDumperUnavailableException if the heap dumper cannot be created
*/
protected HeapDumper createHeapDumper() throws HeapDumperUnavailableException {
return new HotSpotDiagnosticMXBeanHeapDumper();
}
/**
* Handle the heap dump file and respond. By default this method will return the
* response as a GZip stream.
* @param heapDumpFile the generated dump file
* @param request the HTTP request
* @param response the HTTP response
* @throws ServletException on servlet error
* @throws IOException on IO error
*/
protected void handle(File heapDumpFile, HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + (heapDumpFile.getName() + ".gz") + "\"");
try {
InputStream in = new FileInputStream(heapDumpFile);
try {
GZIPOutputStream out = new GZIPOutputStream(response.getOutputStream());
StreamUtils.copy(in, out);
out.finish();
}
catch (NullPointerException ex) {
}
finally {
try {
in.close();
}
catch (Throwable ex) {
}
}
}
catch (FileNotFoundException ex) {
}
}
/**
* Strategy interface used to dump the heap to a file.
*/
protected interface HeapDumper {
/**
* Dump the current heap to the specified file.
* @param file the file to dump the heap to
* @param live if only <em>live</em> objects (i.e. objects that are reachable from
* others) should be dumped
* @throws IOException on IO error
* @throws InterruptedException on thread interruption
*/
void dumpHeap(File file, boolean live) throws IOException, InterruptedException;
}
/**
* {@link HeapDumper} that uses {@code com.sun.management.HotSpotDiagnosticMXBean}
* available on Oracle and OpenJDK to dump the heap to a file.
*/
@UsesJava7
protected static class HotSpotDiagnosticMXBeanHeapDumper implements HeapDumper {
private Object diagnosticMXBean;
private Method dumpHeapMethod;
@SuppressWarnings("unchecked")
protected HotSpotDiagnosticMXBeanHeapDumper() {
try {
Class<?> diagnosticMXBeanClass = ClassUtils.resolveClassName(
"com.sun.management.HotSpotDiagnosticMXBean", null);
this.diagnosticMXBean = ManagementFactory.getPlatformMXBean(
(Class<PlatformManagedObject>) diagnosticMXBeanClass);
this.dumpHeapMethod = ReflectionUtils.findMethod(diagnosticMXBeanClass,
"dumpHeap", String.class, Boolean.TYPE);
}
catch (Throwable ex) {
throw new HeapDumperUnavailableException(
"Unable to locate HotSpotDiagnosticMXBean", ex);
}
}
@Override
public void dumpHeap(File file, boolean live) {
ReflectionUtils.invokeMethod(this.dumpHeapMethod, this.diagnosticMXBean,
file.getAbsolutePath(), live);
}
}
/**
* Exception to be thrown if the {@link HeapDumper} cannot be created.
*/
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
protected static class HeapDumperUnavailableException extends RuntimeException {
public HeapDumperUnavailableException(String message, Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -429,8 +429,8 @@ public class EndpointWebMvcAutoConfigurationTests {
this.applicationContext.register(RootConfig.class, BaseConfiguration.class,
ServerPortConfig.class, EndpointWebMvcAutoConfiguration.class);
this.applicationContext.refresh();
// /health, /metrics, /env, /actuator (/shutdown is disabled by default)
assertThat(this.applicationContext.getBeansOfType(MvcEndpoint.class)).hasSize(4);
// /health, /metrics, /env, /actuator, /heapdump (/shutdown is disabled by default)
assertThat(this.applicationContext.getBeansOfType(MvcEndpoint.class)).hasSize(5);
}
@Test

View File

@@ -83,7 +83,7 @@ public class HalBrowserMvcEndpointDisabledIntegrationTests {
public void endpointsDoNotHaveLinks() throws Exception {
for (MvcEndpoint endpoint : this.mvcEndpoints.getEndpoints()) {
String path = endpoint.getPath();
if ("/actuator".equals(path)) {
if ("/actuator".equals(path) || endpoint instanceof HeapdumpMvcEndpoint) {
continue;
}
path = path.length() > 0 ? path : "/";

View File

@@ -122,7 +122,7 @@ public class HalBrowserMvcEndpointVanillaIntegrationTests {
@Test
public void endpointsEachHaveSelf() throws Exception {
Set<String> collections = new HashSet<String>(
Arrays.asList("/trace", "/beans", "/dump"));
Arrays.asList("/trace", "/beans", "/dump", "/heapdump"));
for (MvcEndpoint endpoint : this.mvcEndpoints.getEndpoints()) {
String path = endpoint.getPath();
if (collections.contains(path)) {

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2012-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint.mvc;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPInputStream;
import org.fusesource.hawtbuf.ByteArrayInputStream;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.ManagementServerPropertiesAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for {@link HeapdumpMvcEndpoint}.
*
* @author Phillip Webb
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class HeapdumpMvcEndpointTests {
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Autowired
private TestHeapdumpMvcEndpoint endpoint;
@Before
public void setup() {
this.context.getBean(HeapdumpMvcEndpoint.class).setEnabled(true);
this.mvc = MockMvcBuilders.webAppContextSetup(this.context).build();
}
@After
public void reset() {
this.endpoint.reset();
}
@Test
public void invokeWhenDisabledShouldReturnNotFoundStatus() throws Exception {
this.endpoint.setEnabled(false);
this.mvc.perform(get("/heapdump")).andExpect(status().isNotFound());
}
@Test
public void invokeWhenNotAvailableShouldReturnServiceUnavailableStatus()
throws Exception {
this.endpoint.setAvailable(false);
this.mvc.perform(get("/heapdump")).andExpect(status().isServiceUnavailable());
}
@Test
public void invokeWhenLockedShouldReturnTooManyRequestsStatus() throws Exception {
this.endpoint.setLocked(true);
this.mvc.perform(get("/heapdump")).andExpect(status().isTooManyRequests());
}
@Test
public void invokeShouldReturnGzipContent() throws Exception {
MvcResult result = this.mvc.perform(get("/heapdump")).andExpect(status().isOk())
.andReturn();
byte[] bytes = result.getResponse().getContentAsByteArray();
GZIPInputStream stream = new GZIPInputStream(new ByteArrayInputStream(bytes));
byte[] uncompressed = FileCopyUtils.copyToByteArray(stream);
assertThat(uncompressed).isEqualTo("HEAPDUMP".getBytes());
}
@Import({ JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class,
EndpointWebMvcAutoConfiguration.class, WebMvcAutoConfiguration.class,
ManagementServerPropertiesAutoConfiguration.class })
@Configuration
public static class TestConfiguration {
@Bean
public HeapdumpMvcEndpoint endpoint() {
return new TestHeapdumpMvcEndpoint();
}
}
private static class TestHeapdumpMvcEndpoint extends HeapdumpMvcEndpoint {
private boolean available;
private boolean locked;
private String heapDump;
TestHeapdumpMvcEndpoint() {
super(TimeUnit.SECONDS.toMillis(1));
reset();
}
public void reset() {
this.available = true;
this.locked = false;
this.heapDump = "HEAPDUMP";
}
@Override
protected HeapDumper createHeapDumper() {
return new HeapDumper() {
@Override
public void dumpHeap(File file, boolean live)
throws IOException, InterruptedException {
if (!TestHeapdumpMvcEndpoint.this.available) {
throw new HeapDumperUnavailableException("Not available", null);
}
if (TestHeapdumpMvcEndpoint.this.locked) {
throw new InterruptedException();
}
FileCopyUtils.copy(TestHeapdumpMvcEndpoint.this.heapDump.getBytes(),
file);
}
};
}
public void setAvailable(boolean available) {
this.available = available;
}
public void setLocked(boolean locked) {
this.locked = locked;
}
}
}