diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java
new file mode 100644
index 00000000..856323c2
--- /dev/null
+++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/ContainerRole.java
@@ -0,0 +1,35 @@
+package com.example.dvely.agent.infrastructure.docker;
+
+/**
+ * 컨테이너를 무엇에 쓰는지. 생성 시점에 반드시 정한다.
+ *
+ *
둘은 같은 이미지를 쓰지만 수명도 노출면도 다르다. 그런데 한동안 같은 생성 경로를 구분 없이
+ * 써서, 프리뷰 쪽 격리 정책을 손대면 배포 빌드까지 함께 흔들렸다 — 바꿀 때마다 배포 파이프라인
+ * 전체를 다시 검증해야 한다는 뜻이고, 그래서 아무도 손대지 않게 된다.
+ *
+ * 기본값을 두지 않는 것이 요점이다. 새 호출부가 생기면 무엇인지 고르게 강제한다 — 기본값이
+ * 있으면 고르지 않은 것과 고른 것이 구분되지 않고, 잘못 고른 쪽이 조용히 돈다.
+ */
+public enum ContainerRole {
+
+ /**
+ * 사용자에게 보여줄 결과물을 서빙한다. 게이트웨이가 프록시할 수 있도록 포트를 게시하고,
+ * 세션이 살아 있는 동안 유지된다.
+ */
+ PREVIEW,
+
+ /**
+ * 저장소를 받아 빌드 산출물만 꺼내고 즉시 버린다. 아무것도 서빙하지 않으므로 포트를
+ * 게시하지 않는다 — 게시해 봐야 아무도 연결하지 않고, 루프백이라도 열려 있는 면은 없는 편이 낫다.
+ */
+ BUILD;
+
+ public boolean publishesPort() {
+ return this == PREVIEW;
+ }
+
+ /** 컨테이너 라벨에 넣는 값. 운영자가 `docker ps` 에서 둘을 갈라 볼 수 있어야 한다. */
+ public String label() {
+ return name().toLowerCase(java.util.Locale.ROOT);
+ }
+}
diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
index c22bd11e..252514e6 100644
--- a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
+++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java
@@ -46,6 +46,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -69,6 +70,7 @@ public class DockerContainerService {
private static final String PROJECT_ID_LABEL = "qeploy.projectId";
private static final String CONVERSATION_ID_LABEL = "qeploy.conversationId";
private static final String TASK_ID_LABEL = "qeploy.taskId";
+ private static final String ROLE_LABEL = "qeploy.role";
private static final String LEGACY_AGENT_LABEL = "dvely.agent";
// --- Preview container isolation policy (BI-194). Kept as plain constants rather than
@@ -123,12 +125,13 @@ public DockerContainerService() {
this.dockerClient = dockerClient;
}
- public String createAndStartContainer(Long userId,
+ public String createAndStartContainer(ContainerRole role,
+ Long userId,
String previewSessionId,
Long projectId,
Long conversationId,
String taskId) {
- return createAndStartContainer(userId, previewSessionId, projectId,
+ return createAndStartContainer(role, userId, previewSessionId, projectId,
conversationId, taskId, MEMORY_LIMIT_BYTES);
}
@@ -137,12 +140,14 @@ public String createAndStartContainer(Long userId,
* {@link #JAVA_MEMORY_LIMIT_BYTES} 를 넘긴다. swap 은 메모리와 같게 둬(추가 swap 없음) OOM 이
* 느린 디스크 뒤로 숨지 않고 깨끗하게 kill 되도록 한다.
*/
- public String createAndStartContainer(Long userId,
+ public String createAndStartContainer(ContainerRole role,
+ Long userId,
String previewSessionId,
Long projectId,
Long conversationId,
String taskId,
long memoryBytes) {
+ Objects.requireNonNull(role, "role");
pullImageIfNeeded();
ensurePreviewNetwork();
@@ -164,10 +169,16 @@ public String createAndStartContainer(Long userId,
// same host". If preview containers ever move to a remote/multi-host Docker daemon, this
// loopback bind must be revisited together with the gateway's proxy target — otherwise
// the gateway simply can't reach the container at all.
- portBindings.bind(exposedPort, Ports.Binding.bindIpAndPort(HOST_BIND_IP, 0));
+ // 빌드 컨테이너는 아무것도 서빙하지 않는다 — 저장소를 받아 산출물만 꺼내고 버린다.
+ // 게시해 봐야 연결하는 쪽이 없고(getMappedPort 는 프리뷰 경로만 부른다), 루프백이라도
+ // 열려 있는 면은 없는 편이 낫다.
+ if (role.publishesPort()) {
+ portBindings.bind(exposedPort, Ports.Binding.bindIpAndPort(HOST_BIND_IP, 0));
+ }
Map labels = new HashMap<>();
labels.put(AGENT_LABEL, "true");
+ labels.put(ROLE_LABEL, role.label());
labels.put(USER_ID_LABEL, String.valueOf(userId));
putLabel(labels, PREVIEW_SESSION_ID_LABEL, previewSessionId);
putLabel(labels, PROJECT_ID_LABEL, projectId);
@@ -181,7 +192,7 @@ public String createAndStartContainer(Long userId,
// disabled. Rootfs stays read-write (the agent writes project files into the container)
// and no restart policy is set (a dead container surfaces via the status API instead).
CreateContainerResponse container = dockerClient.createContainerCmd(IMAGE)
- .withExposedPorts(exposedPort)
+ .withExposedPorts(role.publishesPort() ? List.of(exposedPort) : List.of())
.withHostConfig(HostConfig.newHostConfig()
.withPortBindings(portBindings)
.withMemory(memoryBytes)
diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
index 7737ffdc..b3fe6463 100644
--- a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
+++ b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java
@@ -13,6 +13,7 @@
import com.example.dvely.preview.infrastructure.persistence.entity.PreviewSessionEntity;
import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository;
import com.example.dvely.preview.infrastructure.security.PreviewAccessCookies;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
@@ -67,6 +68,7 @@ public PreviewSessionInfo acquire(String taskId) {
String accessToken = UUID.randomUUID().toString().replace("-", "");
long memoryBytes = runtimeConfigService.previewContainerMemoryBytes(task.projectId());
String containerId = dockerService.createAndStartContainer(
+ ContainerRole.PREVIEW,
task.ownerUserId(),
sessionId,
task.projectId(),
diff --git a/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java b/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java
index 4b21942a..55ed9d29 100644
--- a/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java
+++ b/src/main/java/com/example/dvely/preview/application/service/ProjectPreviewService.java
@@ -11,6 +11,7 @@
import com.example.dvely.preview.infrastructure.persistence.repository.SpringDataPreviewSessionRepository;
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
@@ -131,6 +132,7 @@ public ProvisionOutcome provision(Long projectId, Long ownerUserId, boolean forc
try {
long memoryBytes = runtimeConfigService.previewContainerMemoryBytes(projectId);
containerId = dockerService.createAndStartContainer(
+ ContainerRole.PREVIEW,
ownerUserId, sessionId, projectId, null, null, memoryBytes);
hostPort = dockerService.getMappedPort(containerId);
} catch (RuntimeException exception) {
diff --git a/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java
index d47240fa..a478952f 100644
--- a/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java
+++ b/src/main/java/com/example/dvely/provisioning/application/service/DockerImageBuildService.java
@@ -5,6 +5,7 @@
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
import com.example.dvely.provisioning.infrastructure.EcrImageRegistry.EcrAuth;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -73,6 +74,7 @@ private Path prepareContextTar(Long ownerUserId, Long projectId) {
}
String sessionId = "img-" + projectId + "-" + System.currentTimeMillis();
String containerId = dockerService.createAndStartContainer(
+ ContainerRole.BUILD,
ownerUserId, sessionId, projectId, null, null);
try {
sourceClone.cloneInto(containerId, ownerUserId, sourceRepo);
diff --git a/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java b/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java
index 8e3d4b80..99068c26 100644
--- a/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java
+++ b/src/main/java/com/example/dvely/provisioning/application/service/FrontendStaticHostingAdapter.java
@@ -9,6 +9,7 @@
import com.example.dvely.deployment.application.port.out.FrontendStaticHostingPort;
import com.example.dvely.project.domain.repository.ProjectCloudConnectionSettingRepository;
import com.example.dvely.provisioning.infrastructure.S3StaticSiteStore;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -45,6 +46,7 @@ public String publishToS3(PublishRequest request) {
String sessionId = "site-build-" + request.projectId() + "-" + System.currentTimeMillis();
// 프론트 번들러(vite/webpack)는 메모리를 꽤 쓴다 — 1GiB 기본으로는 큰 앱이 OOM 날 수 있어 2GiB.
String containerId = dockerService.createAndStartContainer(
+ ContainerRole.BUILD,
request.ownerUserId(), sessionId, request.projectId(), null, null,
DockerContainerService.JAVA_MEMORY_LIMIT_BYTES);
try {
diff --git a/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java
index 1b1833c3..1cc693ba 100644
--- a/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java
+++ b/src/main/java/com/example/dvely/provisioning/application/service/NativeBuildService.java
@@ -4,6 +4,7 @@
import com.example.dvely.agent.infrastructure.docker.DockerContainerService.ExecResult;
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -51,6 +52,7 @@ public NativeArtifact build(Long ownerUserId, Long projectId) {
String sessionId = "build-" + projectId + "-" + System.currentTimeMillis();
String containerId = dockerService.createAndStartContainer(
+ ContainerRole.BUILD,
ownerUserId, sessionId, projectId, null, null,
DockerContainerService.JAVA_MEMORY_LIMIT_BYTES);
try {
diff --git a/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java b/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java
index 7df28dc6..308cd0e7 100644
--- a/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java
+++ b/src/main/java/com/example/dvely/provisioning/application/service/WebImageBuildService.java
@@ -4,6 +4,7 @@
import com.example.dvely.project.domain.model.Project;
import com.example.dvely.project.domain.repository.ProjectRepository;
import com.example.dvely.provisioning.infrastructure.EcrImageRegistry.EcrAuth;
+import com.example.dvely.agent.infrastructure.docker.ContainerRole;
import java.nio.file.Path;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -78,6 +79,7 @@ private Path prepareWebContextTar(Long ownerUserId, Long projectId, String front
}
String sessionId = "web-" + projectId + "-" + System.currentTimeMillis();
String containerId = dockerService.createAndStartContainer(
+ ContainerRole.BUILD,
ownerUserId, sessionId, projectId, null, null);
try {
sourceClone.cloneInto(containerId, ownerUserId, repo);
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java
index a8a6b156..588e35a6 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServicePortBindingIntegrationTest.java
@@ -71,7 +71,7 @@ void tearDown() {
@Test
void createAndStartContainerPublishesHostPortOnLoopbackOnly() {
- containerId = service.createAndStartContainer(
+ containerId = service.createAndStartContainer(ContainerRole.PREVIEW,
999_000L, "it-session-" + System.nanoTime(), 1L, 1L, "it-task-" + System.nanoTime());
Ports.Binding[] bindings = inspectPortBindings(containerId);
@@ -101,7 +101,7 @@ void createAndStartContainerPublishesHostPortOnLoopbackOnly() {
// is HostIp.
@Test
void restartContainerKeepsHostPortOnLoopbackAfterReallocation() {
- containerId = service.createAndStartContainer(
+ containerId = service.createAndStartContainer(ContainerRole.PREVIEW,
999_001L, "it-session-restart-" + System.nanoTime(), 1L, 1L,
"it-task-restart-" + System.nanoTime());
int portBeforeRestart = service.getMappedPort(containerId);
diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
index 1b7ba3f7..6c7cc611 100644
--- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
+++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java
@@ -161,7 +161,7 @@ void createAndStartContainerAppliesIsolationHostConfig() {
when(createResponse.getId()).thenReturn("container-1");
when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class));
- service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1");
+ service.createAndStartContainer(ContainerRole.PREVIEW, 1L, "session-1", 11L, 21L, "task-1");
ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class);
verify(createCommand).withHostConfig(hostConfigCaptor.capture());
@@ -177,6 +177,59 @@ void createAndStartContainerAppliesIsolationHostConfig() {
assertThat(hostConfig.getNetworkMode()).isEqualTo("qeploy-preview");
}
+ /**
+ * 빌드 컨테이너는 아무것도 서빙하지 않는다 — 저장소를 받아 산출물만 꺼내고 버린다.
+ * 포트를 게시하면 연결하는 쪽도 없이 면만 늘어난다(getMappedPort 는 프리뷰 경로만 부른다).
+ *
+ * 역할 라벨은 운영자가 `docker ps` 에서 둘을 갈라 보기 위한 것이고, 그보다 중요하게는
+ * 뒤따르는 격리 작업(#332)이 프리뷰만 골라 바꿀 수 있게 하는 기준이다.
+ */
+ @Test
+ void buildContainerPublishesNoPortAndIsLabelledAsBuild() {
+ mockNetworkAlreadyExists(true);
+ CreateContainerCmd createCommand = mock(CreateContainerCmd.class, RETURNS_SELF);
+ CreateContainerResponse createResponse = mock(CreateContainerResponse.class);
+ when(dockerClient.createContainerCmd(anyString())).thenReturn(createCommand);
+ when(createCommand.exec()).thenReturn(createResponse);
+ when(createResponse.getId()).thenReturn("container-1");
+ when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class));
+
+ service.createAndStartContainer(ContainerRole.BUILD, 1L, "session-1", 11L, null, null);
+
+ ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class);
+ verify(createCommand).withHostConfig(hostConfigCaptor.capture());
+ assertThat(hostConfigCaptor.getValue().getPortBindings().getBindings()).isEmpty();
+
+ ArgumentCaptor