This is an automated email from the ASF dual-hosted git repository.

ParkGyeongTae pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 79a6530ec0 [ZEPPELIN-6539] Fix DockerClient / container resource leaks 
on interpreter failure paths
79a6530ec0 is described below

commit 79a6530ec0529f007f99b8f20f473dde3ca94704
Author: HyeonUk Kang <[email protected]>
AuthorDate: Thu Jul 16 23:52:02 2026 +0900

    [ZEPPELIN-6539] Fix DockerClient / container resource leaks on interpreter 
failure paths
    
    ### What is this PR for?
    
    The Docker interpreter launcher (DockerInterpreterProcess) can leak 
resources on error paths when interpreters are started/stopped repeatedly by 
notebook workloads.
    
    1. stop() may leak the DockerClient. docker.close() was called after the 
try/catch that kills/removes the container. If killContainer/removeContainer 
throws an unexpected (unchecked) exception, it is not caught and docker.close() 
is skipped, leaking the underlying HTTP socket / file descriptors.
    2. start() may leave an orphaned container. start() runs pull → create → 
start → copy files → exec → wait-for-register. If preparation fails after the 
container is started (e.g. copyRunFileToContainer / execInContainer), the 
running container is not cleaned up
    
    ### What type of PR is it?
    Bug Fix
    
    ### Todos
    - [x] Fix stop() to always close the DockerClient
    - [x] Fix start() to clean up a started container on preparation failure
    - [x] Add unit tests (Mockito interaction tests)
    
    ### What is the Jira issue?
    * [[ZEPPELIN-6539]](https://issues.apache.org/jira/browse/ZEPPELIN-6539)
    
    ### How should this be tested?
    
    Automated unit tests added in DockerInterpreterProcessTest (DockerClient is 
mocked):
    - stop_alwaysClosesDockerClient_evenWhenKillContainerFails
    - start_removesContainer_whenContainerPreparationFails
    
    ### Screenshots (if appropriate)
    
    
    
    ### Questions:
    * Does the license files need to update? - No
    * Is there breaking changes for older versions? - No
    * Does this needs documentation? - No
    
    
    Closes #5302 from hyunw9/ZEPPELIN-6539.
    
    Signed-off-by: ParkGyeongTae <[email protected]>
---
 .../launcher/DockerInterpreterProcess.java         | 50 +++++++++++-
 .../launcher/DockerInterpreterProcessTest.java     | 90 ++++++++++++++++++++++
 2 files changed, 136 insertions(+), 4 deletions(-)

diff --git 
a/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
 
b/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
index 643afb2061..3004ae13f7 100644
--- 
a/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
+++ 
b/zeppelin-plugins/launcher/docker/src/main/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcess.java
@@ -79,7 +79,8 @@ public class DockerInterpreterProcess extends 
RemoteInterpreterProcess {
 
   private AtomicBoolean dockerStarted = new AtomicBoolean(false);
 
-  private DockerClient docker = null;
+  @VisibleForTesting
+  DockerClient docker;
   private final String containerName;
   private String containerHost = "";
   private int containerPort = 0;
@@ -154,9 +155,15 @@ public class DockerInterpreterProcess extends 
RemoteInterpreterProcess {
     return interpreterSettingName;
   }
 
+  // allows a mock DockerClient to be injected in unit tests.
+  @VisibleForTesting
+  DockerClient createDockerClient(String dockerHost) {
+    return DefaultDockerClient.builder().uri(URI.create(dockerHost)).build();
+  }
+
   @Override
   public void start(String userName) throws IOException {
-    docker = DefaultDockerClient.builder().uri(URI.create(dockerHost)).build();
+    docker = createDockerClient(dockerHost);
 
     removeExistContainer(containerName);
 
@@ -220,7 +227,17 @@ public class DockerInterpreterProcess extends 
RemoteInterpreterProcess {
           }
         }
       });
+    } catch (DockerException e) {
+      throw new IOException(e);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new IOException("Docker preparations were interrupted.", e);
+    }
 
+    // Create, start and prepare the container. If anything fails after the
+    // container has been created/started, roll it back so we don't leak an
+    // orphaned container holding resources until the next launch reuses the 
name.
+    try {
       final ContainerCreation containerCreation
           = docker.createContainer(containerConfig, containerName);
       String containerId = containerCreation.id();
@@ -232,10 +249,15 @@ public class DockerInterpreterProcess extends 
RemoteInterpreterProcess {
 
       execInContainer(containerId, dockerCommand, false);
     } catch (DockerException e) {
+      cleanupContainerQuietly();
       throw new IOException(e);
+    } catch (IOException e) {
+      cleanupContainerQuietly();
+      throw e;
     } catch (InterruptedException e) {
       // Restore interrupted state...
       Thread.currentThread().interrupt();
+      cleanupContainerQuietly();
       throw new IOException("Docker preparations were interrupted.", e);
     }
 
@@ -363,10 +385,30 @@ public class DockerInterpreterProcess extends 
RemoteInterpreterProcess {
       Thread.currentThread().interrupt();
     } catch (DockerException e) {
       LOGGER.error(e.getMessage(), e);
+    } finally {
+      docker.close();
     }
+  }
 
-    // Close the docker client
-    docker.close();
+  // Best-effort removal of a container that was (partially) created during 
start().
+  private void cleanupContainerQuietly() {
+    try {
+      docker.killContainer(containerName);
+    } catch (InterruptedException e) {
+      LOGGER.warn("Interrupted while killing container {} during cleanup", 
containerName, e);
+      Thread.currentThread().interrupt();
+    } catch (DockerException e) {
+      LOGGER.warn("Failed to kill container {} during cleanup", containerName, 
e);
+    }
+
+    try {
+      docker.removeContainer(containerName);
+    } catch (InterruptedException e) {
+      LOGGER.warn("Interrupted while removing container {} during cleanup", 
containerName, e);
+      Thread.currentThread().interrupt();
+    } catch (DockerException e) {
+      LOGGER.warn("Failed to remove container {} during cleanup", 
containerName, e);
+    }
   }
 
   // Because docker can't create a container with the same name, it will cause 
the creation to fail.
diff --git 
a/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
 
b/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
index 1e9ad7d0bd..ea5b5bd84a 100644
--- 
a/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
+++ 
b/zeppelin-plugins/launcher/docker/src/test/java/org/apache/zeppelin/interpreter/launcher/DockerInterpreterProcessTest.java
@@ -16,11 +16,16 @@
  */
 package org.apache.zeppelin.interpreter.launcher;
 
+import com.spotify.docker.client.DockerClient;
+import com.spotify.docker.client.exceptions.DockerException;
+import com.spotify.docker.client.messages.ContainerConfig;
+import com.spotify.docker.client.messages.ContainerCreation;
 import org.apache.zeppelin.conf.ZeppelinConfiguration;
 import org.apache.zeppelin.conf.ZeppelinConfiguration.ConfVars;
 import org.apache.zeppelin.interpreter.InterpreterOption;
 import org.junit.jupiter.api.Test;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -30,14 +35,99 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 class DockerInterpreterProcessTest {
 
   protected static ZeppelinConfiguration zConf = 
spy(ZeppelinConfiguration.load());
 
+  private DockerInterpreterProcess newProcess() {
+    ZeppelinConfiguration conf = spy(ZeppelinConfiguration.load());
+    Properties properties = new Properties();
+    properties.setProperty(
+        ConfVars.ZEPPELIN_INTERPRETER_CONNECT_TIMEOUT.getVarName(), "5000");
+    return new DockerInterpreterProcess(
+        conf,
+        "interpreter-container:1.0",
+        "test_intp_group",
+        "sh",
+        "shell",
+        properties,
+        new HashMap<>(),
+        "zeppelin.server.hostname",
+        12320,
+        5000, 10);
+  }
+
+  // stop() must always close the DockerClient, even when killing the container
+  // fails, so the underlying HTTP socket / file descriptors are never leaked.
+  @Test
+  void stop_alwaysClosesDockerClient_evenWhenKillContainerFails() throws 
Exception {
+    DockerInterpreterProcess intp = newProcess();
+    DockerClient mockDocker = mock(DockerClient.class);
+    intp.docker = mockDocker;
+    doThrow(new 
RuntimeException("unexpected")).when(mockDocker).killContainer(anyString());
+
+    assertThrows(RuntimeException.class, intp::stop);
+
+    verify(mockDocker, times(1)).close();
+  }
+
+  // When start() fails after the container has been started (e.g. file copy / 
exec
+  // fails), the container must be cleaned up instead of being left orphaned.
+  @Test
+  void start_removesContainer_whenContainerPreparationFails() throws Exception 
{
+    DockerInterpreterProcess intp = spy(newProcess());
+    DockerClient mockDocker = mock(DockerClient.class);
+    doReturn(mockDocker).when(intp).createDockerClient(anyString());
+
+    // No pre-existing container to remove.
+    when(mockDocker.listContainers(any())).thenReturn(Collections.emptyList());
+    // Container is created and started successfully...
+    when(mockDocker.createContainer(any(ContainerConfig.class), anyString()))
+        
.thenReturn(ContainerCreation.builder().id("test-container-id").build());
+    // ...but preparing it (the first exec inside the container) fails.
+    doThrow(new DockerException("exec failed"))
+        .when(mockDocker).execCreate(anyString(), any(String[].class), any());
+
+    assertThrows(IOException.class, () -> intp.start("user1"));
+
+    // The container was started, so start() must roll it back before 
returning.
+    verify(mockDocker).startContainer("test-container-id");
+    verify(mockDocker).killContainer(anyString());
+    verify(mockDocker).removeContainer(anyString());
+  }
+
+  @Test
+  void start_removesContainer_evenWhenKillFailsDuringCleanup() throws 
Exception {
+    DockerInterpreterProcess intp = spy(newProcess());
+    DockerClient mockDocker = mock(DockerClient.class);
+    doReturn(mockDocker).when(intp).createDockerClient(anyString());
+
+    when(mockDocker.listContainers(any())).thenReturn(Collections.emptyList());
+    // Container is created...
+    when(mockDocker.createContainer(any(ContainerConfig.class), anyString()))
+        
.thenReturn(ContainerCreation.builder().id("test-container-id").build());
+    // ...but fails to start, so it is created-but-not-running.
+    doThrow(new DockerException("start 
failed")).when(mockDocker).startContainer(anyString());
+    // Killing a non-running container fails, but removeContainer must still 
fire.
+    doThrow(new DockerException("not 
running")).when(mockDocker).killContainer(anyString());
+
+    assertThrows(IOException.class, () -> intp.start("user1"));
+
+    verify(mockDocker).removeContainer(anyString());
+  }
+
   @Test
   void testCreateIntpProcess() throws IOException {
     DockerInterpreterLauncher launcher

Reply via email to