인계 문서입니다. 설계는 실측으로 확정됐고 구현만 남았습니다. 처음 열었을 때 적었던 방법은 틀린 것으로 판명나 아래에서 대체했습니다(경위는 첫 코멘트).
🔥 Issue 개요
프리뷰 컨테이너는 사용자가 연결한 저장소를 실제로 빌드해 돌립니다. npm install 의 postinstall 스크립트와 빌드 스크립트는 저장소가 정하는 코드이고, 지금 그것이 컨테이너 안에서 root(uid 0)로 실행됩니다.
cap-drop ALL + no-new-privileges 라 컨테이너 탈출로 바로 이어지지는 않습니다. 지금 안전한 진짜 이유는 바인드 마운트가 없기 때문인데, 나중에 누가 마운트를 하나 붙이는 순간 root 라는 사실이 갑자기 중요해집니다. 그때 기억나지 않을 종류의 전제입니다.
급하지 않습니다. 실제로 위험했던 경로(컨테이너 → 호스트의 Qeploy API)는 PR #333 에서 닫았습니다. 이건 심층 방어입니다.
🔒 확정된 제약 — 이것이 설계를 결정합니다
cap-drop ALL 이 DAC_OVERRIDE 를 뗍니다. 그래서 이 컨테이너의 root 는 파일 권한을 우회하지 못합니다.
$ docker exec <preview> grep CapEff /proc/self/status
CapEff: 00000000000000c1 # CHOWN(0) + SETGID(6) + SETUID(7) 뿐. DAC_OVERRIDE(1) 없음
$ docker exec <preview> sh -c 'chown -R node:node /workspace; echo x > /workspace/app/by-root.txt'
sh: can't create /workspace/app/by-root.txt: Permission denied # exit=1
# 비교 — DAC_OVERRIDE 를 주면 된다
$ docker run --cap-drop ALL --cap-add DAC_OVERRIDE ... 'chown -R 1000:1000 /workspace; echo y > /workspace/app/x'
exit=0
결론: 워크스페이스는 주인이 하나여야 합니다. 중간에 node 로 넘기면 그 뒤의 root 명령(git push·diff·씨딩)이 전부 막힙니다. 절반만 옮기는 설계는 없습니다 — 그렇게 시도했다가 되돌린 것이 첫 코멘트에 있습니다.
✅ 목표 상태는 동작합니다 (실측)
적대적 postinstall 을 심어 확인했습니다.
| 확인 |
결과 |
| 저장소 코드의 uid |
1000 (root 아님) |
/etc/shadow 읽기 |
거부 |
/etc · /usr/local/bin 쓰기 |
거부 |
apk add |
거부 |
| 워크스페이스 쓰기 |
허용 (필요) |
| 빌드 산출물 생성 |
정상 |
git config --global · credential.helper · git init/commit · 실제 clone · npm install 전부 node 로 됩니다(git 을 root 로 미리 깔아 둔 상태에서).
처음 측정에서 이것들이 실패해 권한 문제로 읽을 뻔했는데, git 이 안 깔려 있어서였습니다. 설치 후 다시 재면 전부 통과합니다.
🎯 설계 — 이 순서로 하는 것이 중요합니다
1단계. 프리뷰 컨테이너와 빌드 컨테이너를 분리한다 (이것부터)
지금 createAndStartContainer 와 exec 를 둘이 함께 씁니다.
preview/PreviewSessionService 프리뷰
preview/ProjectPreviewService 프리뷰
provisioning/NativeBuildService 빌드 (Gradle)
provisioning/DockerImageBuildService 빌드 (이미지)
provisioning/WebImageBuildService 빌드
provisioning/FrontendStaticHostingAdapter
분리하지 않고 기본 사용자를 뒤집으면 배포 파이프라인까지 영향을 받습니다. 분리해 두면 빌드 경로는 한 줄도 안 바뀌므로 재검증할 것이 없어집니다 — 이 작업에서 위험을 가장 크게 줄이는 한 걸음입니다.
2단계. 프리뷰 컨테이너의 워크스페이스를 처음부터 node 소유로 만든다
컨테이너 생성 시 root 가 mkdir -p /workspace/app && chown -R node:node /workspace. 중간에 넘기지 않습니다.
node 는 node:20-alpine 에 이미 있는 uid 1000 이라 전용 이미지가 필요 없습니다. (/ 가 root 소유라 node 스스로는 /workspace 를 못 만듭니다 — 그래서 root 가 만들어 넘깁니다.)
3단계. exec 기본 사용자를 node 로 뒤집고, root 가 필요한 것만 표시한다
방향이 중요합니다. 워크스페이스를 만지는 곳을 하나씩 찾아 node 로 바꾸면 40곳을 훑어야 하고, 하나 빠뜨리면 그 경로만 조용히 깨집니다. 반대로 기본을 뒤집으면 root 가 필요한 목록이 짧습니다.
apk add ... (4곳)
- nginx 기동·종료 (
PreviewRuntimeLauncher, FrontendStaticHostingAdapter)
- 컨테이너 생성 시
mkdir + chown
HOME=/home/node 를 함께 주세요. 없으면 npm 캐시와 git config --global 이 root 홈을 쓰려다 죽습니다.
4단계. (별건) egress 허용목록
이 저장소에 이미 답이 있습니다 — 코딩 에이전트가 internal 네트워크 + tinyproxy CONNECT 허용목록으로 돕니다(CodingAgentProperties.Egress). 같은 방식을 프리뷰에 적용하면 되지만 허용목록이 넓어야 하고(npm·yarn·pnpm·bun 레지스트리, GitHub, Maven Central, Gradle 배포처) 빌드를 깨뜨릴 위험이 실질적이라, 3단계와 분리해서 실제 프로젝트로 태워 보며 하는 편이 낫습니다.
⚠️ 함정 — 실측으로 확인한 것들
컨테이너는 재사용됩니다. /tmp/qeploy-build.log 가 이전 실행에서 root 소유로 남아 있으면 node 의 tee 가 거기서 죽습니다. 빌드가 아니라 로깅 때문에 실패하고, 원인은 로그에 안 남습니다.
정리 전 tee → exit=1
정리 후 tee → exit=0
빌드가 git 자격증명을 물려받지 않게 됩니다. git config --global 로 설정한 credential.helper 는 root 의 것이라, 빌드를 node 로 돌리면 저장소의 빌드 스크립트가 우리 GitHub 자격증명에 닿지 못합니다. 보안상 이득이지만 private git 의존성을 쓰는 프로젝트는 설치가 깨질 수 있습니다 — 그런 사례가 나오면 자격증명을 어느 사용자에 둘지 따로 정해야 합니다.
JAVA_FULLSTACK 경로가 가장 덜 검증된 곳입니다. PreviewRuntimeLauncher 가 프리뷰 컨테이너 안에서 nginx·openjdk 를 돌리는데, 여기는 제가 재보지 못했습니다. nginx 는 root 로 두면 되지만(워크스페이스를 읽기만 하므로), 실제로 그런지는 돌려 봐야 합니다.
📋 이미 되어 있는 것 (다시 하지 마세요)
| 항목 |
상태 |
cap-drop ALL + no-new-privileges |
적용 |
| 메모리·swap·CPU·PID 상한 |
적용 |
컨테이너 간 통신 차단(enable_icc=false) |
적용 |
| 게시 포트 루프백 전용 |
적용 |
| docker socket · 바인드 마운트 |
없음 |
| exec 타임아웃 10분 |
적용 |
| MySQL 루프백 바인딩 |
적용 |
| Qeploy API 루프백 바인딩 |
적용 (PR #333) |
✅ 완료 기준
📎 참고
🔥 Issue 개요
프리뷰 컨테이너는 사용자가 연결한 저장소를 실제로 빌드해 돌립니다.
npm install의 postinstall 스크립트와 빌드 스크립트는 저장소가 정하는 코드이고, 지금 그것이 컨테이너 안에서 root(uid 0)로 실행됩니다.cap-drop ALL+no-new-privileges라 컨테이너 탈출로 바로 이어지지는 않습니다. 지금 안전한 진짜 이유는 바인드 마운트가 없기 때문인데, 나중에 누가 마운트를 하나 붙이는 순간 root 라는 사실이 갑자기 중요해집니다. 그때 기억나지 않을 종류의 전제입니다.🔒 확정된 제약 — 이것이 설계를 결정합니다
cap-drop ALL이DAC_OVERRIDE를 뗍니다. 그래서 이 컨테이너의 root 는 파일 권한을 우회하지 못합니다.결론: 워크스페이스는 주인이 하나여야 합니다. 중간에 node 로 넘기면 그 뒤의 root 명령(git push·diff·씨딩)이 전부 막힙니다. 절반만 옮기는 설계는 없습니다 — 그렇게 시도했다가 되돌린 것이 첫 코멘트에 있습니다.
✅ 목표 상태는 동작합니다 (실측)
적대적
postinstall을 심어 확인했습니다./etc/shadow읽기/etc·/usr/local/bin쓰기apk addgit config --global·credential.helper·git init/commit· 실제clone·npm install전부 node 로 됩니다(git 을 root 로 미리 깔아 둔 상태에서).🎯 설계 — 이 순서로 하는 것이 중요합니다
1단계. 프리뷰 컨테이너와 빌드 컨테이너를 분리한다 (이것부터)
지금
createAndStartContainer와exec를 둘이 함께 씁니다.분리하지 않고 기본 사용자를 뒤집으면 배포 파이프라인까지 영향을 받습니다. 분리해 두면 빌드 경로는 한 줄도 안 바뀌므로 재검증할 것이 없어집니다 — 이 작업에서 위험을 가장 크게 줄이는 한 걸음입니다.
2단계. 프리뷰 컨테이너의 워크스페이스를 처음부터 node 소유로 만든다
컨테이너 생성 시 root 가
mkdir -p /workspace/app && chown -R node:node /workspace. 중간에 넘기지 않습니다.node는node:20-alpine에 이미 있는 uid 1000 이라 전용 이미지가 필요 없습니다. (/가 root 소유라 node 스스로는/workspace를 못 만듭니다 — 그래서 root 가 만들어 넘깁니다.)3단계. exec 기본 사용자를 node 로 뒤집고, root 가 필요한 것만 표시한다
방향이 중요합니다. 워크스페이스를 만지는 곳을 하나씩 찾아 node 로 바꾸면 40곳을 훑어야 하고, 하나 빠뜨리면 그 경로만 조용히 깨집니다. 반대로 기본을 뒤집으면 root 가 필요한 목록이 짧습니다.
apk add ...(4곳)PreviewRuntimeLauncher,FrontendStaticHostingAdapter)mkdir+chownHOME=/home/node를 함께 주세요. 없으면npm캐시와git config --global이 root 홈을 쓰려다 죽습니다.4단계. (별건) egress 허용목록
이 저장소에 이미 답이 있습니다 — 코딩 에이전트가 internal 네트워크 + tinyproxy CONNECT 허용목록으로 돕니다(
CodingAgentProperties.Egress). 같은 방식을 프리뷰에 적용하면 되지만 허용목록이 넓어야 하고(npm·yarn·pnpm·bun 레지스트리, GitHub, Maven Central, Gradle 배포처) 빌드를 깨뜨릴 위험이 실질적이라, 3단계와 분리해서 실제 프로젝트로 태워 보며 하는 편이 낫습니다.컨테이너는 재사용됩니다.
/tmp/qeploy-build.log가 이전 실행에서 root 소유로 남아 있으면 node 의tee가 거기서 죽습니다. 빌드가 아니라 로깅 때문에 실패하고, 원인은 로그에 안 남습니다.빌드가 git 자격증명을 물려받지 않게 됩니다.
git config --global로 설정한credential.helper는 root 의 것이라, 빌드를 node 로 돌리면 저장소의 빌드 스크립트가 우리 GitHub 자격증명에 닿지 못합니다. 보안상 이득이지만 private git 의존성을 쓰는 프로젝트는 설치가 깨질 수 있습니다 — 그런 사례가 나오면 자격증명을 어느 사용자에 둘지 따로 정해야 합니다.JAVA_FULLSTACK 경로가 가장 덜 검증된 곳입니다.
PreviewRuntimeLauncher가 프리뷰 컨테이너 안에서 nginx·openjdk 를 돌리는데, 여기는 제가 재보지 못했습니다. nginx 는 root 로 두면 되지만(워크스페이스를 읽기만 하므로), 실제로 그런지는 돌려 봐야 합니다.📋 이미 되어 있는 것 (다시 하지 마세요)
cap-drop ALL+no-new-privilegesenable_icc=false)✅ 완료 기준
npm installpostinstall·빌드 스크립트)가 uid 0 이 아닌 사용자로 실행된다apk add·nginx 등 플랫폼 명령이 그대로 동작한다📎 참고
DockerContainerService.createAndStartContainer·exec·execWithExitCodeCodingAgentProperties.Egress— 이미 동작하는 egress 허용목록 구현state.md§4.25