공유 Docker 서버에 배포하기
사용 시기
- 서버가 전용(dedicated)이 아닌 경우: 이미 다른 사람이나 다른 프로젝트의 배포가 실행 중입니다.
- 앱에 네이티브 의존성(
better-sqlite3,sharp,canvas) 또는 브라우저(Chromium)가 있는 경우. - 여러 도메인을 게시하는 리버스 프록시(Caddy 또는 nginx)가 이미 존재하는 경우.
EADDRINUSE, OOM 또는 디스크 가득 참이 다른 스택에 영향을 미칠 수 있는 경우.
황금률: 새 앱이 다른 앱을 중단시키는 원인이 되어서는 안 됩니다. 따라서 모든 것이 미리 제한되고 측정됩니다.
0. 빌드 전: 디스크 측정 및 확보
Chromium 빌드는 약 2GB의 이미지를 다운로드합니다. 디스크 사용량이 78%이면 빌드가 실패하거나 서버가 한계에 도달하고, 다음 prune이 프로덕션 이미지를 삭제할 수 있습니다.
ssh SERVIDOR 'docker system df; df -h /'
일반적으로 버려져 있어도 안전하게 확보할 수 있는 항목:
docker builder prune -af # 빌드 캐시 (보통 가장 큼)
docker image prune -af # 사용하지 않는 이미지
docker container prune -f # 중지된 컨테이너
전후에 컨테이너 수를 세어 아무것도 중단되지 않았는지 확인합니다:
docker ps -q | wc -l # 숫자를 저장하고 나중에 비교
실제 사례 측정: 78% → 32% 회복, 41개의 프로덕션 컨테이너는 그대로 유지됨.
1. 네이티브 모듈 및 Node 버전
다른 Node 메이저 버전용으로 컴파일된 .node 파일은 명확한 오류와 함께 실패하지 않습니다. 내부 assert와 쓸모없는 네이티브 스택으로 프로세스를 중단시킵니다.
일반적인 증상:
# node.exe[61084]: void __cdecl node::RemoveEnvironmentCleanupHook(Isolate *, CleanupHook, void *)
# Assertion failed: (env) != nullptr
----- Native stack trace -----
원인: Node 24에서 better-sqlite3@11. 지원은 better-sqlite3@12에서 제공됩니다.
규칙:
- 통합 전에 네이티브 모듈 버전을 Node 메이저 버전과 확인합니다.
- 빌드
Dockerfile에서 toolchain(python3 make g++)을 설치하여 사전 컴파일된 바이너리가 없을 경우 즉석에서 컴파일할 수 있도록 합니다. - 런타임에서는 toolchain이 필요하다고 가정하지 않습니다: 더 작은 최종 이미지가 좋습니다.
- 모듈이 워크스페이스의 여러 패키지에서 가져오는 경우, 다른 작업을 수행하지 않는 프로세스에서 테스트하십시오: 지연된 GC로 인한 크래시는 몇 시간 후에 나타날 수 있습니다.
2. 무거운 프로세스와 응답 프로세스 분리
HTTP 서버와 동일한 프로세스에서 Chromium과 네이티브 모듈을 실행하면 크래시가 발생하고, 긴 작업 중에는 이벤트 루프가 차단됩니다.
api : HTTP + SSE, 낮은 메모리, HTTP 헬스체크
worker : 큐 + 브라우저, 높은 메모리, 포트 없음, HTTP 헬스체크 없음
공유 저장소(SQLite, Redis, Postgres)를 통해 통신합니다. API는 worker가 살아 있는지 알 필요가 없습니다.
헬스체크는 HTTP를 제공하는 서비스에만 정의됩니다. Dockerfile에서 worker로 상속하면 포트를 열지 않기 때문에 영원히 unhealthy 상태가 됩니다:
worker:
healthcheck:
disable: true
3. 서비스별 메모리 제한
api:
mem_limit: 400m
worker:
mem_limit: 1600m
mem_reservation: 400m
volumes:
- /dev/shm:/dev/shm:rw,size=256m # Chromium은 /dev/shm 사용; 64 MB면 중단됨
tmpfs:
- /tmp:size=256m
security_opt:
- no-new-privileges:true
또한 코드에서 작업량을 줄이십시오: 스크레이퍼는 로컬보다 서버에서 DETAIL_MAX / MAX_PAGES 값을 더 낮게 설정해야 합니다. 언젠가 서버에 RAM이 부족하다면, 거의 항상 그 숫자가 원인이며 Docker가 아닙니다.
실제 검색 전후 측정:
docker stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}' <api> <worker>
free -m | head -2
4. 빌드: 실제로 도움이 되는 스테이지 및 캐시
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS build # npm 패키지에 버전 고정
...
FROM mcr.microsoft.com/playwright:v1.63.0-noble AS runtime
- 이미지 버전을 설치된 npm 패키지 버전에 고정합니다. 일치하지 않으면 Playwright가 브라우저를 찾는 위치와 다릅니다.
- 먼저
package.json을 복사한 다음 코드를 복사하여COPY . .가install레이어를 무효화하지 않도록 합니다. - 런타임에서 시작 스크립트가
pnpm/npm을 사용하는 경우 필요합니다 →corepack enable. node_modules가 빌드에서 복사되고 권한이 일치하지 않으면 pnpm이 시작할 때마다 "복구"를 시도하고 실패합니다. 이미지의.npmrc에서verify-deps-before-run=false로 비활성화합니다.- 런타임에서 루트가 아닌 사용자. Playwright 이미지에는 이미
pwuser가 있습니다.
pnpm-lock.yaml은 커밋되어야 하며 빌드 설치는 --frozen-lockfile로 수행해야 합니다.
5. 이미 존재하는 프록시를 통해 게시
여러 도메인을 게시하는 Caddy가 이미 있는 경우 다른 프록시를 시작하거나 다른 공개 포트를 열지 마십시오: 기존 Caddyfile에 블록을 추가하고 Docker 네트워크에 연결합니다.
services:
api:
networks: [edge_proxy] # 외부 네트워크, 이미 생성됨
networks:
edge_proxy:
external: true
name: edge_proxy
네트워크와 Caddyfile 위치 찾기:
docker inspect <proxy> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} rw={{.RW}}{{println}}{{end}}'
호스트에서 파일을 편집합니다: 바인드 마운트가 rw=false인 경우 컨테이너 내부에서 쓰면 Resource busy 오류가 발생하거나 재시작 시 손실됩니다.
안전한 절차 (절대 맹목적으로 편집하지 마십시오):
cp "$CADDYFILE" "$CADDYFILE.bak-$(date +%Y%m%d-%H%M%S)" # 1. 백업
# 2. 블록 추가
docker exec <proxy> caddy validate --config /etc/caddy/Caddyfile # 3. 검증
docker exec <proxy> caddy reload --config /etc/caddy/Caddyfile # 4. 재로드 (무중단)
validate가 실패하면 백업을 복원합니다. 재로드는 기존 연결을 끊지 않습니다.
Basic Auth
prospector.ejemplo.dev {
basic_auth {
leo $2a$14$... # bcrypt, 절대 평문 사용 금지
}
reverse_proxy api:3000 {
flush_interval -1 # SSE에 필수
}
encode zstd gzip
header {
X-Frame-Options DENY
-Server
}
}
- 서버에서 해시 생성:
docker exec <proxy> caddy hash-password --plaintext '...'. - 평문 암호는 복구 또는 교체를 위해 루트 전용 파일(
/root/<app>-password.txt, 600)에만 저장하고 절대 리포지토리에 저장하지 마십시오. - 앱이 SSE를 사용하는 경우 앞단에 Cloudflare 프록시를 사용하지 마십시오: 100초 타임아웃과 버퍼링으로 인해 진행이 중단됩니다. DNS 전용(회색 구름)을 사용하면 원본이 실제 인증서를 발급할 수 있습니다.
basic_auth는 프록시를 통과하는 트래픽만 보호합니다. 앱이 다른 포트로도 노출되면 보호되지 않은 상태로 남습니다: 이를 문서화하십시오.
6. Windows에서 배포
rsync는 Windows에 없습니다. 작동하고 크로스 플랫폼인 방법은 SSH 파이프를 통한 tar입니다:
const tar = spawn('tar', ['-cf', '-', '--exclude', './node_modules', ...excludes, '.'], { cwd: ROOT });
const ssh = spawn('ssh', [HOST, `mkdir -p ${DIR} && tar -xf - -C ${DIR}`], { stdio: ['pipe', 'inherit', 'inherit'] });
tar.stdout.pipe(ssh.stdin);
Windows에서 pnpm(.cmd 파일)을 확인하려면 spawn에 shell: true가 필수이지만, 그러면 출력 리디렉션을 위해 파일 핸들을 전달할 수 없습니다: LOG_FILE 환경 변수를 사용하고 애플리케이션 자체가 로그를 기록하도록 합니다.
7. 공유 서버를 만지기 전에
-
docker ps -q | wc -l저장, 마지막에 비교하기 위해 - 확보 후 충분한 여유 디스크 공간
- 모든 새 서비스에
mem_limit설정 - 무거운 프로세스는 자체 컨테이너에, 포트 없이
- HTTP를 제공하는 서비스에만 헬스체크
- 새 네트워크가 아닌 프록시의 외부 네트워크 선언
- Caddyfile 백업 및 재로드 전
caddy validate - 파일에 로그 기록 (프로세스에 콘솔이 없음)
- 멱등성 배포 스크립트:
check / push / build / up / logs / stats / backup - 백업이 테스트된 그리고 복원된 볼륨, 단순히 생성된 것만이 아님
빠른 진단
docker stats --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}' <api> <worker>
docker logs --tail 80 <worker>
docker exec <api> node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>r.text()).then(console.log)"
free -m && df -h /