이틀 전 개발기에서 되던 것이 서버에서 안 되는 지점들 — Ubuntu 배포을 남겼다. 그 문서를 다시 읽으며 "이건 왜 이렇게 했지"를 하나씩 물었고, 답을 확인하려고 서버에 명령을 쳐보다가 문서에 절 제목으로 박아둔 함정을 그대로 밟았다. 운영 DB가 멈추고 채팅과 관리자 콘솔이 함께 내려갔다.
복구는 5분이었지만, 그 5분에 도달하기까지 필요했던 지식이 앞의 질문들과 정확히 겹쳤다. 그래서 질문과 사고를 나누지 않고 한 글에 담는다. 앞부분은 도커 운영의 기초를 되짚는 문답이고, 7절이 그 지식이 실제로 쓰인 장애 기록이다.
1. 서버를 처음 볼 때 던지는 질문들
GPU가 있나
문서에는 "GPU 없음(CPU 임베딩)"이라고만 적혀 있었다. 근거는 임베딩 서버 설치 로그의
WARNING: No NVIDIA/AMD GPU detected 한 줄이었는데, 독립적으로 확인하려면 이렇게 본다.
lspci | grep -Ei 'vga|3d|display' # 드라이버와 무관하게 하드웨어를 본다
nvidia-smi # 드라이버 + 장치
ls /dev/dri /dev/nvidia* 2>/dev/null
순서가 중요하다. nvidia-smi가 없다는 것만으로 판단하면 장치는 있는데 드라이버만 없는 경우와
구별되지 않는다. lspci가 QEMU나 virtio 같은 가상 디스플레이만 뱉으면 연산용 GPU는 없는
것이고, 클라우드 VM은 대개 이 형태다.
이 판단이 사소해 보이지만 설정으로 이어진다. GPU가 없으면 임베딩이 CPU로 돌고, 그래서 문서 업로드 경로의 리버스 프록시에 넉넉한 읽기 타임아웃이 필요해진다. 하드웨어 사실 하나가 nginx 설정 한 줄이 되는 셈이다.
데이터는 호스트 어디에 있나
컨테이너에게 직접 묻는 것이 가장 확실하다.
docker inspect <pg> \
-f '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
compose 파일에는 ${POSTGRES_DATA_PATH}:/var/lib/postgresql처럼 변수로만 적혀 있어서 실제 값은
.env에 있다. 그래서 파일을 읽는 것보다 컨테이너를 조회하는 쪽이 빠르다.
여기서 눈여겨볼 것이 하나 있다. 컨테이너 안의 마운트 지점은 /var/lib/postgresql인데 실제
데이터 디렉터리는 그보다 한 단계 아래다. 상위를 마운트하는 것은 메이저 버전 업그레이드 때 두
버전이 같은 볼륨 안에 공존할 수 있게 하려는 upstream 설계다. 이 층 차이 때문에, 백업을 복원해
부팅해볼 때는 볼륨을 데이터 디렉터리 쪽에 직접 물려 층을 맞춰야 했다.
그리고 이것이 named volume이 아니라 bind mount라는 점이 뒤에 나올 복구 전략의 근거가 된다.
호스트 디렉터리라 tar로 그대로 복사되고, 이미지를 갈아끼워도 데이터는 제자리에 남는다.
무엇이 떠 있고, 누가 띄웠나
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
docker ps -a --format '{{.Names}}\t{{.Status}}' # 멈춘 것 포함
docker ps --format '{{.Names}}' | grep -i rag # 필터
전역 목록과 프로젝트 단위 목록을 구분해서 써야 한다.
docker compose ps # 이 프로젝트 서비스만
이 서버에는 compose 프로젝트가 셋 이상 돌고 있다. docker compose ps만 보면 다른 프로젝트가
포트를 점유한 것을 못 본다. 실제로 검색 MCP 서버를 올릴 때 기본 포트가 이미 다른 컨테이너에
쓰이고 있어 비켜 간 일이 있었다. 포트 충돌 조사는 전역 docker ps나 ss -lntp로 해야 한다.
docker compose ps의 출력에는 NAME과 SERVICE가 따로 나오는데, 둘이 다를 수 있다.
| 열 | 값 | 출처 |
|---|---|---|
| NAME | rag-mcp-company |
compose의 container_name: 명시 |
| SERVICE | rag-mcp-company |
compose의 서비스 키 |
container_name을 지정하지 않으면 이름은 <프로젝트>-<서비스>-<번호> 형태로 자동 생성된다.
docker-postgres-1 같은 이름이 그래서 나온다. 에이전트 설정이나 nginx upstream에 이름을 박아야
하는 컨테이너는 예측 가능하게 고정해두는 편이 낫다.
스크립트에는 이름을 하드코딩하지 않는 것이 안전하다. 프로젝트 디렉터리 이름이 바뀌면 자동 생성 이름도 바뀌기 때문이다.
PG=$(docker ps --format '{{.Names}}' | grep -i postgres | head -1)
"누가 띄웠나"는 컨테이너 라벨에 답이 있다.
docker inspect <컨테이너> -f '{{index .Config.Labels "com.docker.compose.project.working_dir"}}'
어느 디렉터리에서 이것을 띄웠는지가 컨테이너 안에 기록돼 있다. 서버를 처음 조사할 때 가장
신뢰할 만한 출발점이다. 디렉터리 이름을 추측해 find로 헤매는 것보다 정확하다.
2. 무엇이 어디에 열려 있나
ss와 docker ps 출력 읽기
ss -lntp | awk '$4 ~ /^(0\.0\.0\.0|\*|\[?::)/ {print $4, $6}'
출력을 읽을 때 알아둘 것이 셋 있다.
소유 프로세스가 docker-proxy로 찍힌다. 도커가 게시한 포트마다 중계 프로세스를 하나 띄우기
때문이다. 실제 앱 이름이 아니라 이 이름이 보이면 "도커가 게시한 포트"라는 뜻이다.
포트마다 두 줄이 나온다. IPv4(0.0.0.0)와 IPv6([::])에 각각 하나씩이고 PID도 다르다.
docker ps에서 0.0.0.0:8765->8765/tcp, [::]:8765->8765/tcp처럼 두 개가 찍히는 것과 같은 얘기다.
SSH만 소유자가 둘로 나온다. 최근 Ubuntu는 SSH를 소켓 활성화로 돌린다. systemd가 22번을 대신 듣고 있다가 접속이 오면 데몬을 깨우는 구조라 둘 다 표시된다. 정상이다.
docker ps의 PORTS 열에는 네 가지 상태가 있다.
| 표기 | 누가 닿나 |
|---|---|
127.0.0.1:8766->8765/tcp |
호스트 자신만 |
0.0.0.0:8765->8765/tcp |
모든 인터페이스. 공인 IP로 인터넷에서 접근 가능 |
[::]:8765->8765/tcp |
위와 같고 IPv6 |
8065/tcp (화살표 없음) |
게시 안 됨. 도커 네트워크 안에서만 |
마지막 경우가 채팅 앱 컨테이너다. nginx가 컨테이너 이름으로 붙으니 호스트에 열 이유가 없다.
서버는 IP를 여러 개 갖는다
이것이 모든 바인딩 이야기의 출발점이다.
| 주소 | 무엇 | 누가 닿나 |
|---|---|---|
127.0.0.1 |
루프백 | 호스트 프로세스만 |
172.17.0.1 |
도커 브리지에서의 호스트 주소 | 호스트 + 컨테이너 |
| 공인 IP | 물리 랜카드 | 전 세계 |
루프백은 커널이 만든 가상 네트워크 장치다. 물리 랜카드가 아니라 소프트웨어로 존재하고, 이 주소로 보낸 패킷은 랜카드까지 가지 않고 커널 안에서 되돌아온다. 그래서 랜선을 뽑아도 동작하고, 외부에서 이 주소로 보내면 그건 보낸 쪽 자신을 가리키므로 남의 서버에 닿을 방법이 없다. 이름 그대로 고리처럼 되돌아오는 경로다.
프로그램이 포트를 열 때는 "어느 주소로 들어오는 것을 받을지"를 정해야 한다. 건물에 출입구가
셋인데 어느 문에서 손님을 받을지 고르는 것과 같다. 0.0.0.0은 IP처럼 생겼지만 실제 주소가
아니라 "지정하지 않음 = 전부"를 뜻하는 특수값이고, 바인딩 쪽에서만 의미가 있다.
0.0.0.0으로 열면 공인 NIC도 포함되므로 인터넷에서 직접 접근 가능해진다. 세 가지가 따라온다.
인증이 있으면 문은 열려 있고 자물쇠가 지킨다. 인증이 없으면 누구나 쓴다. 그리고 인터넷 전체
포트를 훑는 자동 스캐너가 상시 돌고 있어, 공인 IP에 열린 포트는 며칠 안에 발견된다.
127.0.0.1 바인딩이 방화벽보다 강한 이유가 여기 있다. 이것은 규칙이 아니라 커널이 구조적으로
막는 것이다. 외부에서 온 패킷은 목적지가 루프백이면 전달 자체가 되지 않는다. 설정 실수, 규칙
순서, 우회 경로 같은 변수가 존재할 수 없다. 방화벽이 "문은 열어두고 경비를 세우는" 것이라면
루프백 바인딩은 "그 문을 만들지 않는" 것이다.
열려야 하는 포트와 실수로 열린 포트
점검하다 보면 열린 포트가 두 종류로 갈린다.
프로토콜상 반드시 열려야 하는 것이 있다. 채팅 서버의 통화 기능이 그렇다. 이 포트만 유일하게 TCP와 UDP를 함께 게시하는데, 그게 단서다. 통화 중 음성과 영상 패킷은 UDP로 흐르고, 브라우저가 서버에 직접 붙어 주고받아야 한다. HTTP 리버스 프록시가 대신 받아줄 수 없는 종류의 트래픽이라 nginx 뒤로 숨길 방법이 없다. TCP 쪽은 UDP가 막힌 사내망 클라이언트를 위한 폴백이다.
이런 포트는 바인딩을 좁힐 수 없고, 보호는 다른 층이 맡는다. 통화 참여에 세션 토큰이 필요한 식이다.
실수로 열린 것은 성격이 다르다. 도커 네트워크로만 통신하면 되는 서비스인데 호스트에도
공개돼 있는 경우인데, 원인을 따라가면 대개 하나로 수렴한다. -p 8765:8765처럼 바인딩 주소를
생략하면 도커가 0.0.0.0을 채워 넣는다. 명시적으로 공개하기로 결정한 것이 아니라 적지 않아서
공개된 형태다.
이 둘을 뭉뚱그리면 안 된다. 정리할 때 판단 기준이 완전히 다르다.
방화벽이 답이 아닌 이유
Ubuntu의 기본 방화벽 도구는 커널 방화벽을 사람이 쓸 만하게 감싼 껍데기다. 규칙을 추가하고 활성화하는 식으로 쓴다.
그런데 이 상황에서는 두 가지 이유로 쓰지 않았다.
켜는 순간이 위험하다. 기본 정책이 전면 차단이라 활성화하는 순간 이미 열려 있던 웹 포트와 채팅 포트가 함께 막힌다. SSH를 먼저 허용하지 않으면 접속이 끊겨 서버에서 쫓겨난다.
도커에는 애초에 통하지 않는다. 도커는 iptables의 자체 체인에 직접 규칙을 넣는데, 그것이 방화벽이 관리하는 체인보다 먼저 평가된다. 나중에 방화벽을 켜도 이미 게시된 포트는 막히지 않는다.
그래서 방화벽 대신 바인딩 주소를 좁히는 방법을 골랐다. 다만 앞서 말한 대로 만능은 아니다. 정말로 외부에서 닿아야 하는 포트는 좁힐 수 없다.
밖에서 확인하기, 그리고 SSH 터널
서버 안에서 보는 "무엇이 열려 있나"와 밖에서 보는 "무엇에 닿나"는 다르다. 후자가 진실이다.
# 개발기에서 (본인 소유 호스트에만)
nmap -Pn -p 22,80,443,3000,8443 <서버>
for P in 22 80 443 3000 8443; do
nc -z -G 3 <서버> "$P" 2>/dev/null && echo "OPEN $P" || echo "closed $P"
done
-Pn을 붙이는 것이 중요하다. 많은 서버가 ICMP를 막아두는데, 그러면 스캐너가 "호스트가 죽었다"고
판단해 포트 검사 자체를 건너뛴다.
포트가 열린 것과 아무나 쓸 수 있는 것은 또 다르다. HTTP라면 응답 코드까지 봐야 한다.
200이면 인증 없이 쓰이는 것이고, 401이나 403이면 최소한 인증은 걸려 있다.
이 점검은 회귀 테스트로도 쓸모가 있다. 루프백에만 묶어둔 포트들이 밖에서 닫혀 있음을
확인하는 것은, 그 설정이 의도대로 먹었다는 증거다. 나중에 누가 .env를 고쳐 뚫리면 이 스캔이
잡아낸다.
DB에 외부에서 접속해야 할 때는 포트를 여는 대신 SSH 터널을 쓴다. 서버 설정을 전혀 바꾸지 않는다.
ssh -N -L 15432:127.0.0.1:5433 <계정>@<서버>
psql "postgresql://<앱계정>:<비밀번호>@127.0.0.1:15432/admin_db"
터널이 우아한 이유는 인증을 SSH에 위임하기 때문이다. 이미 키로 검증된 경로를 재사용하니
DB 쪽에 새 공격 표면을 만들지 않는다. 로컬 포트를 원래 포트와 다르게 잡는 것도 의도적이다.
개발기의 로컬 DB와 충돌하지 않고, 무엇보다 "지금 보는 것이 로컬인가 운영인가"를 헷갈리지 않게
해준다. -N은 셸을 열지 말고 터널만 유지하라는 뜻이다.
3. 컨테이너는 어떻게 서로를 찾나
내장 DNS와 컨테이너 이름
에이전트 컨테이너가 검색 MCP 서버를 http://rag-mcp-company:8765/mcp로 부른다. 다른 컨테이너인데
어떻게 되느냐는 질문의 답은, 다른 컨테이너라서 된다는 것이다.
도커는 사용자 정의 네트워크마다 내장 DNS를 돌린다. 컨테이너 안의 /etc/resolv.conf에
nameserver 127.0.0.11이 들어 있고, 여기에 같은 네트워크의 컨테이너 이름이 전부 등록돼 있다.
docker exec hermes cat /etc/resolv.conf
docker exec hermes getent hosts rag-mcp-company # 172.x.x.x 로 해석됨
각 컨테이너는 네트워크상의 독립된 호스트고, 이름은 그 호스트명 역할을 한다. 사내망의 서버 두 대가 호스트명으로 서로를 부르는 것과 구조가 같다.
조건은 같은 네트워크에 있을 것 하나다.
docker inspect hermes rag-mcp-company \
-f '{{.Name}}: {{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
반대로 호스트에서는 컨테이너 이름을 쓸 수 없다. 내장 DNS는 컨테이너 네트워크 공간 안에만 존재하고 호스트 resolver에는 등록되지 않는다. 우회로가 둘 있는데, 컨테이너 IP를 직접 쓰는 방법은 재생성 때마다 IP가 바뀌므로 스크립트에 박으면 안 된다. 실무에서는 게시된 포트를 쓰거나, 아예 네트워크 안에서 일회용 컨테이너를 띄운다.
docker run --rm --network mattermost curlimages/curl -s http://rag-mcp-company:8765/healthz
이름 기반이라 IP가 바뀌어도 아무 문제가 없다는 점이 중요하다. 설정에 IP를 적어뒀다면 컨테이너를 한 번 재생성할 때마다 여러 프로필의 설정을 고쳐야 했을 것이다. 이름을 쓰는 것은 읽기 좋아서가 아니다.
포트 게시는 컨테이너 통신과 무관하다
여기서 자주 헷갈리는 것이 정리된다.
| 설정 | 호스트에서 | 인터넷에서 | 컨테이너끼리 |
|---|---|---|---|
| 게시 안 함 | ✗ | ✗ | ○ |
127.0.0.1:8766:8765 |
○ | ✗ | ○ |
0.0.0.0:8766:8765 |
○ | ○ | ○ |
세 경우 모두 마지막 열이 ○다. 게시는 오직 "호스트 바깥 세계에 문을 낼 것인가"만 정한다.
그래서 검색 MCP 서버를 루프백에만 게시하기로 한 판단은 기능을 하나도 줄이지 않는다. 에이전트는 컨테이너 이름으로 붙고, 잃는 것은 원하지도 않았던 인터넷 접근뿐이다. 비용 0의 보안 개선인 셈이다. 남긴 루프백 게시는 호스트에서 손으로 찔러보기 위한 진단 통로다.
같은 이미지로 인스턴스를 둘 띄운 구성에서 포트가 이렇게 보인다.
rag-mcp-company 127.0.0.1:8766->8765/tcp
rag-mcp-cost 127.0.0.1:8767->8765/tcp
컨테이너 안쪽은 둘 다 8765다. 같은 이미지라 코드가 같은 포트를 듣고, 컨테이너마다 네트워크 공간이 따로라 겹쳐도 충돌하지 않는다. 호스트 쪽만 달라야 한다. 호스트는 공간이 하나라 같은 포트를 둘이 못 쓴다.
격리가 기본이다
한 서버에서 컨테이너를 여러 개 띄운다고 자동으로 서로 통하는 것이 아니다. compose 프로젝트마다 자기 네트워크를 새로 만든다.
docker network ls
for c in $(docker ps --format '{{.Names}}'); do
echo "$c: $(docker inspect $c -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}')"
done
for n in $(docker network ls --format '{{.Name}}'); do
echo "[$n] $(docker network inspect "$n" -f '{{range .Containers}}{{.Name}} {{end}}')"
done
두 번째와 세 번째는 같은 사실을 반대 방향에서 본다. 컨테이너 기준으로만 보면 아무도 쓰지 않는 네트워크가 안 보이므로, 네트워크 기준 조회도 함께 해야 전체 그림이 나온다.
다른 프로젝트가 기존 네트워크에 합류하려면 명시적으로 선언한다.
networks:
default:
name: ${MM_NETWORK}
external: true # 내가 만들지 않는다. 이미 있는 것을 쓴다
external: true가 핵심이다. 값이 틀리면 없는 네트워크를 새로 만들지 않고 "network not found"로
즉시 실패한다. 조용히 잘못된 곳에 붙는 것보다 낫다. 변수로 뺀 이유는 개발기와 서버의 네트워크
이름이 다르기 때문이다.
인과는 이 순서다. 채팅 스택 compose가 네트워크를 만들고, 검색 MCP compose가 external로 참여하고, 그래야 내장 DNS에 서로 등록되어 이름으로 통신할 수 있다.
이 편리함에는 대가가 있다. 네트워크의 소유권이 한쪽 프로젝트에만 있어서 소유자의 생명주기가 얹혀 있는 쪽 전체에 영향을 준다. 더 견고하게 하려면 어느 프로젝트에도 속하지 않는 네트워크를 따로 만들고 양쪽 다 external로 붙는 방법이 있다. 그러면 어느 쪽의 정리 명령도 네트워크를 지우지 못한다.
격리가 기본인 것은 안전장치이기도 하다. 무관한 컨테이너가 서로를 이름으로 부를 수 있으면 하나가
뚫렸을 때 옆으로 번지기 쉽다. 필요한 것만 명시적으로 같은 네트워크에 넣는다가 원칙이고,
external: true는 그 명시를 강제하는 장치다.
컨테이너 없이 네트워크만 남는 경우
목록을 보면 컨테이너가 하나도 없는 네트워크가 나온다. 두 종류가 섞여 있다.
도커 내장 네트워크 셋(bridge, host, none)은 설치하면 자동으로 생기고 삭제할 수 없다.
비어 있는 것이 이상 상태가 아니다. 특히 bridge가 비어 있다는 것은 모든 컨테이너가 명시적으로
네트워크를 지정했다는 뜻이라 오히려 좋은 신호다.
이름 있는 네트워크가 비어 있다면 잔재다. 원인은 대개 셋이다. 정리할 때 down 대신 stop과
rm을 썼거나, 손으로 만들고 방치했거나, 프로젝트가 다른 이름으로 재구성됐거나. 네트워크를
지우는 것은 down뿐이다.
출처는 라벨로 확인한다.
docker network inspect <이름> -f '{{json .Labels}}' # compose 라벨이 있으면 그쪽 소산
docker network rm <이름>
docker network prune # 안 쓰는 것 전부
네트워크 삭제는 안전한 축이다. 사용 중이면 도커가 거부하고, 데이터를 담고 있지 않은 순수 설정이라
같은 이름으로 다시 만들면 그만이다. 도커의 rm 계열은 위험도가 꽤 다르다.
| 명령 | 지우는 것 | 위험도 |
|---|---|---|
docker network rm |
네트워크 (설정만) | 낮음 |
docker rm |
컨테이너 | 낮음 (이미지로 재생성) |
docker image rm |
이미지 | 중간 (재빌드·pull 필요) |
docker volume rm |
볼륨, 즉 데이터 | 높음. 복구 불가 |
마지막 행이 실제로 중요했다. 백업 복원 리허설을 할 때 실험용 볼륨을 지우는 명령을 쓰는데, 이름이 한 글자만 어긋나 운영 볼륨을 가리켰다면 데이터가 사라진다. 그래서 리허설용 이름을 확실히 구분했다.
다만 이 서버의 운영 DB는 named volume이 아니라 bind mount라 docker volume rm의 사정권 밖이다.
그 자체가 안전장치이긴 한데, 대신 rm -rf로는 날아가므로 위험이 사라진 것이 아니라 옮겨간
것에 가깝다.
네트워크 설정은 Dockerfile에 없다
"PostgreSQL의 네트워크 설정이 어느 파일에 있나"를 찾다가 pgvector용 Dockerfile밖에 안 보인 적이 있었다. 당연하다. Dockerfile에는 네트워크 설정을 넣을 수 없다.
실제 위치는 nginx를 얹는 compose 파일 맨 끝의 세 줄이다.
networks:
default:
name: mattermost
PostgreSQL 서비스 정의에는 networks:가 없다. compose는 네트워크를 지정하지 않은 서비스를 전부
프로젝트의 default에 넣는데, 위 설정이 그 default의 이름을 바꿔치기하는 것이다. 그래서 DB도
자동으로 따라간다. 이 구조가 7절 사고의 직접 원인이 된다.
4. 호스트와 컨테이너의 경계
172.17.0.1의 정체
도커 기본 브리지에서 호스트가 갖는 IP다. 같은 주소를 보는 쪽에 따라 다르게 부른다.
| 보는 쪽 | 부르는 이름 |
|---|---|
| 호스트 | "내 브리지 인터페이스 주소" |
| 컨테이너 | "내 게이트웨이" |
물리적으로는 하나의 주소다. "게이트웨이"는 컨테이너 입장에서의 역할 이름일 뿐이다. 호스트에서도 당연히 접근된다.
ip -4 addr show docker0 # inet 172.17.0.1/16
curl http://172.17.0.1:3001/login
이 주소가 필요했던 이유는 선택지가 셋뿐이었기 때문이다.
| 주소 | 호스트 프로세스 | 컨테이너 | 인터넷 |
|---|---|---|---|
127.0.0.1 |
○ | ✗ | ✗ |
0.0.0.0 |
○ | ○ | 노출 |
172.17.0.1 |
○ | ○ | ✗ |
세 번째만 조건을 만족한다. 사설 대역이라 인터넷에서 라우팅되지 않으면서 양쪽 다 닿는다.
고정값이 아니라는 점은 짚어둘 만하다. 기본값일 뿐이고 도커 데몬 설정으로 바꿀 수 있으며, 사내망이 같은 대역을 쓰면 충돌 회피를 위해 실제로 바꾸기도 한다. 외운 값을 쓰지 말고 확인해야 한다. 문서에 IP를 적을 때 어떻게 얻었는지도 함께 적는 것이 좋은 이유다.
host-gateway와 host.docker.internal
compose에 이렇게 선언한다.
extra_hosts:
- "host.docker.internal:host-gateway"
이건 이름:값 형식이다. host-gateway는 도커가 실제 IP로 치환하는 자리표시자 키워드이고,
host.docker.internal은 그 IP에 붙이는 이름이다. 결과적으로 컨테이너의 /etc/hosts에 한 줄이
들어간다. 마법이 아니라 이름 등록이다.
docker exec nginx_mattermost cat /etc/hosts | grep host.docker.internal
# 172.17.0.1 host.docker.internal
여기서 오해 하나를 바로잡았다. host.docker.internal은 개발기 전용이 아니다.
| 개발기(Docker Desktop) | Linux | |
|---|---|---|
| 기본 제공 | 자동 | 없음 |
| 쓰려면 | 그냥 사용 | extra_hosts 명시 필요 |
원래 Docker Desktop 관례였는데, Docker 20.10부터 Linux에서도 같은 이름을 직접 등록할 수 있게 됐다. 같은 이름을 쓰는 이유는 이식성이다. 설정 파일 한 벌이 양쪽에서 동작한다. 검색 MCP의 임베딩 서버 주소를 아예 적지 않고 기본값에 맡길 수 있었던 것도 이 덕이다.
다만 이름이 같아서 설정은 이식되는데 동작 조건은 다르다는 함정이 생긴다. 개발기에서 통과한 compose가 서버에서 조용히 실패한다. 이름이 달랐다면 오히려 일찍 발견됐을 문제다.
선언 위치는 grep으로 찾고, 실제 적용 여부는 컨테이너에서 확인한다.
grep -rn 'host-gateway' --include='*.yml' .
docker inspect <컨테이너> -f '{{.HostConfig.ExtraHosts}}'
docker exec <컨테이너> getent hosts host.docker.internal
필요한 컨테이너에만 붙이는 것이 맞다. DB 컨테이너에는 없는데, 호스트 프로세스를 부를 일이 없기
때문이다. 무심코 전부에 넣으면 "이 컨테이너가 호스트와 통신하나"를 판단할 단서가 사라진다.
extra_hosts가 몇 군데 있는지가 곧 호스트와 컨테이너의 경계를 몇 번 넘고 있는지의 지표다.
게이트웨이는 네트워크마다 다른데 왜 하나로 통하나
네트워크마다 게이트웨이 IP가 다르다. 호스트는 브리지마다 IP를 하나씩 갖는다.
docker network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}' # 172.17.0.1
docker network inspect mattermost -f '{{(index .IPAM.Config 0).Gateway}}' # 172.18.0.1 등
그런데 host-gateway는 컨테이너가 어느 네트워크에 있든 항상 기본 브리지 주소로 해석된다.
그래도 동작하는 이유는 이렇다.
[다른 네트워크의 컨테이너 : 172.18.0.5]
→ 172.17.0.1 로 보냄
→ 기본 경로는 자기 게이트웨이(172.18.0.1) = 호스트
→ 호스트가 받아보니 172.17.0.1도 자기 주소 → 로컬 처리
이름이 "내 네트워크의 게이트웨이"처럼 읽히지만 실제 뜻은 "호스트로 가는 문"이다. 여러 네트워크에 걸친 컨테이너들이 같은 주소를 쓸 수 있게 하려는 선택이다.
그래서 임베딩 서버를 기본 브리지 주소에 묶은 것이 맞았다. 사용자 정의 네트워크의 게이트웨이에
묶었다면 그 네트워크 컨테이너만 닿고 host-gateway로 오는 요청은 실패했을 것이다. 게다가
사용자 정의 네트워크의 서브넷은 생성 순서에 따라 바뀔 수 있어 고정값으로 쓰기에 위험하다.
nginx를 컨테이너로 두면 무엇이 달라지나
요청 흐름을 따라가면 명확해진다.
[브라우저] https://mattermost.example.com/api/v4/system/ping
↓ 인터넷
[호스트 0.0.0.0:443] ← docker-proxy
↓
[nginx 컨테이너 :443]
│ TLS 복호화 → proxy_pass http://mattermost-app:8065
│ ↓ 내장 DNS가 컨테이너 이름을 IP로 해석
↓
[채팅 앱 컨테이너 :8065] ← 평문 HTTP
핵심은 채팅 앱의 8065가 게시돼 있지 않다는 것이다. 인터넷에서는 절대 못 닿고, 오직 같은 네트워크의 nginx만 부를 수 있다.
분리해서 얻는 것이 다섯 가지다. 공개 진입점이 하나뿐이라 공격 표면이 줄고, TLS 설정과 인증서 갱신이 한 곳에 모이고, 두 컨테이너를 독립적으로 교체할 수 있고, 공식 이미지를 손대지 않아도 되고, 뒤에 여러 서비스를 달 수 있다. 마지막 항목이 실제로 증명됐다. 관리자 콘솔을 붙일 때 서버 블록 파일 하나를 추가하고 포트만 열었을 뿐, 채팅 쪽은 아무것도 바뀌지 않았다.
TLS 종료라는 말이 여기서 나온다. 암호화 구간이 nginx에서 끝난다는 뜻이다.
[브라우저] ══암호화══> [nginx] ──평문──> [앱]
인터넷 서버 내부
관리자 콘솔의 프로덕션 실행 명령은 평문 HTTP만 서빙한다. TLS 기능이 아예 없고, 직접 하려면 커스텀 서버를 짜서 인증서를 다뤄야 하는데 그럴 이유가 없다. 인터넷 구간만 암호화하고 서버 내부 구간은 평문으로 충분하다.
분리의 대가는 경계를 넘을 때 잃는 정보를 헤더로 복원해야 한다는 것이다. 앱은 자기가 평문으로
요청받았다고 믿는데 브라우저는 HTTPS로 접속했다. 이 간극을 X-Forwarded-Proto 같은 헤더로
메우지 않으면, 앱이 링크를 평문으로 생성하거나 보안 접두 쿠키를 쓰지 못한다고 판단한다. 후자는
에러 없이 로그인 화면으로만 되돌아오는 형태로 나타나서 원인 찾기가 특히 나쁘다.
그렇다면 호스트에 nginx가 있으면 어떻게 되나. 충돌한다. 포트는 선착순이라 먼저 잡은 쪽이
이기고, 나중 쪽은 bind: address already in use로 죽는다. 우아한 조정 메커니즘이 없다.
이 서버가 컨테이너 nginx로 동작하는 이유는 설정으로 우선순위를 정해서가 아니라 호스트에
경쟁자가 없기 때문이다. ss 출력에서 80과 443의 소유자가 nginx가 아니라 docker-proxy인
것이 그 증거다.
만약 호스트 nginx를 쓰기로 했다면 구조가 뒤집힌다. 관리자 콘솔은 루프백 바인딩으로 충분해지지만, 대신 채팅 앱의 포트를 호스트에 게시해야 하고 nginx 설정이 compose 밖으로 나간다. 어느 쪽이든 동작하지만, 현재 방식이 compose 하나로 전체를 기술할 수 있어 재현성이 좋다.
systemd라는 다른 세계
/etc/systemd/system/을 열면 관리자 콘솔 유닛과 임베딩 서버 유닛, 그리고 그 드롭인 디렉터리가
보인다. nginx 유닛은 없다. 컨테이너는 systemd가 아니라 도커 데몬이 관리하고, 재시작 정책도
systemd가 아니라 compose의 restart:가 정한다.
[호스트 systemd] [도커 데몬]
docker.service ─관리→ nginx
<관리자 콘솔> DB
<임베딩 서버> 채팅 앱, 검색 MCP
docker.service가 이 목록에 없는 것도 이유가 있다. 패키지가 설치한 유닛은 다른 디렉터리에 있고,
/etc/systemd/system/은 관리자가 직접 만들거나 덮어쓴 것만 담는 자리다. 그래서 이 디렉터리를
ls 하는 것만으로 "이 서버에서 사람이 손댄 서비스가 무엇인지"가 드러난다. 인수인계 때 유용하다.
드롭인 디렉터리(<유닛>.service.d/)는 원본을 통째로 고치는 대신 위에 얹는 방식이다. 패키지를
업그레이드해도 살아남는다. 임베딩 서버의 바인딩 주소를 이 방식으로 바꿨다. 편집기를 여는 명령
대신 파일을 직접 써도 되는데, 그 명령이 만드는 파일이 정확히 그 경로다. 대신 데몬 리로드를
직접 불러야 한다.
systemd로 관리한다는 말은 구체적으로 이런 것들을 얻는다는 뜻이다.
| 없으면 | systemd가 해주는 것 |
|---|---|
| 터미널 닫으면 종료 | 백그라운드 상주 |
| 죽으면 그대로 끝 | Restart=on-failure 자동 재시작 |
| 부팅 시 수동 실행 | enable로 자동 기동 |
| 로그 파일 직접 관리 | journald 수집 |
로그 조회는 journalctl -u <유닛>이다. 앱이 표준 출력으로 내보내는 모든 것을 journald가 받아
저장하므로 앱이 로그 파일을 따로 만들 필요가 없다.
journalctl -u <유닛> -n 50 --no-pager
journalctl -u <유닛> -f # 실시간
journalctl -u <유닛> --since today -p err # 에러만
컨테이너의 docker logs와 역할이 같다. 다만 journald는 로그를 바이너리로 저장해서 cat으로는
못 읽는 대신, 유닛·시간·우선순위로 인덱싱돼 있어 필터가 즉시 먹는다. 부팅을 넘어 남는지는
설정에 달렸으니, 장애 후 로그가 비어 있다면 그것부터 확인한다.
이 서버에는 로그 체계가 셋 공존한다. journald, docker logs, 그리고 에이전트 컨테이너 안에
파일로 쌓이는 프로필별 로그다. 컨테이너 로그로는 감독자 수준만 보이고 프로필별 에러 로그는
안 나온다.
한 가지 더. 자동 재시작 설정에 미묘한 함정이 있다. Restart=on-failure는 비정상 종료에만
재시작한다. 앱이 종료 코드 0으로 얌전히 죽으면 되살아나지 않는다. 컨테이너의
restart: unless-stopped와 대비되는 지점이라, 필요하면 Restart=always가 안전하다.
프로세스 관리자를 따로 둘 필요가 있나
Node 앱을 돌리니 별도 프로세스 관리자가 필요하냐는 질문이 나왔다. 이 구성에서는 필요 없다.
자동 재시작, 부팅 기동, 로그 수집, 상태 확인은 systemd가 이미 한다. 별도 도구가 더 주는 것은 주로 클러스터 모드와 무중단 리로드, 그리고 대시보드다. 이 트래픽 규모에서 클러스터는 필요 없고, 무거운 계산은 임베딩 서버가 별도 프로세스로 담당한다.
무중단 리로드는 이 상황의 실제 통증을 풀지 못한다. 배포 중 잠깐 오류가 보이는 원인은 프로세스 재시작 방식이 아니라 빌드 산출물 디렉터리가 제자리에서 교체되기 때문이다. 워커를 여러 개 띄워도 전부 같은 디렉터리를 읽으므로 동일하게 영향을 받는다. 진짜 해법은 디렉터리 원자 교체이고, 그건 컨테이너화가 자연스럽게 해결한다.
"앱이 여러 개면 얘기가 다르냐"에 대해서도 답은 같다. 개수는 별도 도구를 정당화하지 않는다.
앱마다 유닛 파일을 하나씩 두면 되고, 비슷한 앱이 많으면 템플릿 유닛(<이름>@.service)도 있다.
별도 도구가 실제로 유리한 경우는 따로 있다. root 권한이 없어 시스템 유닛 디렉터리에 쓸 수 없거나, systemd가 없는 환경이거나, 단일 앱에 멀티코어 워커가 필요하거나, 팀이 npm 생태계에 익숙한 경우다.
이 서버는 스택이 섞여 있어서 systemd가 유리하다. 임베딩 서버는 Node가 아니다. 별도 도구를
도입하면 감독자가 두 겹이 되고 "이 서비스는 어느 쪽으로 관리하지"를 매번 판단해야 한다. systemd
하나면 언어와 무관하게 같은 명령으로 다룰 수 있고, 기동 순서 의존(After=), 자원 제한
(MemoryMax), 샌드박싱 같은 것도 함께 온다.
진짜 질문은 다른 데 있을 수 있다. 앱이 계속 늘어나는 상황이라면 선택지는 "어느 프로세스 관리자냐"가 아니라 "컨테이너로 갈 때가 됐나"에 가깝다. 판단 기준을 하나 꼽자면 앱들이 서로 다른 런타임 버전을 요구하기 시작하는 시점이다. 지금은 하나로 통일돼 있어 문제가 없지만, 갈리기 시작하면 호스트에 여러 버전을 공존시켜야 하고 유닛에 절대 경로를 박는 방식이 지저분해진다.
5. 이미지와 데이터
Dockerfile과 compose의 경계
| Dockerfile | docker-compose.yml | |
|---|---|---|
| 만드는 것 | 이미지 (틀) | 컨테이너 (실행 인스턴스) |
| 시점 | 빌드 타임 | 런타임 |
| 개수 | 이미지 하나당 하나 | 여러 서비스를 한 파일에 |
무엇이 어디에 속하는지는 이렇게 갈린다.
| 항목 | Dockerfile | compose |
|---|---|---|
| 설치할 패키지, 파일 복사 | ○ | |
| 실행 명령 | ○ (기본값) | ○ (덮어쓰기) |
| 포트 게시 | ○ | |
| 볼륨 마운트 | ○ | |
| 네트워크 | ○ | |
| 환경변수 | 기본값만 | ○ (실행 시 결정) |
| 재시작 정책, 의존 관계 | ○ |
경계가 이래야 하는 이유는 같은 이미지를 다르게 여러 번 실행할 수 있어야 하기 때문이다. 검색 MCP 이미지 하나로 컨테이너 둘을 띄우고 기본 프로젝트 환경변수만 다르게 줬는데, 그 값이 Dockerfile에 박혀 있었다면 이미지를 두 개 만들어야 했다.
헷갈리기 쉬운 것이 둘 있다. EXPOSE는 포트를 열지 않는다. "이 이미지는 이 포트를 씁니다"라는
문서용 메타데이터일 뿐이고, 실제 게시는 compose의 ports:가 한다. 그리고 비밀번호를
Dockerfile의 ENV에 넣으면 이미지 레이어에 영구히 박혀 docker history로 노출된다.
이 경계는 소스 관리 원칙과도 이어진다. 층이 셋인 셈이다. Dockerfile은 모든 환경 공통, compose는
공통 구조, .env는 환경별 값. 뒤로 갈수록 변하기 쉬운 것을 담는다.
build와 image를 함께 쓰는 이유
DB에 pgvector를 얹으면서 compose가 이렇게 바뀌었다.
build:
context: ./postgres-pgvector
args:
POSTGRES_IMAGE_TAG: ${POSTGRES_IMAGE_TAG}
image: mattermost-postgres-pgvector:${POSTGRES_IMAGE_TAG}
원래는 image: 한 줄로 공개 레지스트리에서 받아왔는데, pgvector가 들어간 이미지는 세상에 없으니
직접 만들어야 했다. 그런데 build:만 쓰면 compose가 이름을 자동 생성한다. image:를 함께 적어
예측 가능한 태그를 붙여둔 덕에 이미지를 컨테이너 밖에서 독립적으로 다룰 수 있었다. 빌드
결과 검증도, 복원 리허설도 이 이름으로 이미지를 직접 불렀다.
태그에 버전 변수를 넣은 것도 의도적이다. .env에서 메이저 버전을 올리면 이미지 이름도 따라가서
새 이미지가 옛것을 덮어쓰지 않고 둘 다 남는다. 문제가 생기면 한 줄로 되돌아갈 수 있다.
up -d 시점의 동작은 이렇다.
해당 태그의 이미지가 로컬에 있나?
├ 있음 → 그대로 사용 (빌드 안 함)
└ 없음 → Dockerfile로 빌드하고 그 이름으로 태그 → 사용
함정은 Dockerfile을 고쳐도 재빌드되지 않는다는 것이다. compose는 "이름표가 붙은 이미지가 있나"만 보고 내용이 최신인지는 보지 않는다.
docker compose build <서비스> # 명시적 재빌드
docker compose up -d --build <서비스> # 한 번에
pgvector 작업에서 빌드를 별도 단계로 분리한 것이 이 때문이다. 자동에 맡기지 않고 빌드 시점을 통제해서, 결과를 검증한 뒤에야 교체로 넘어갔다.
이미지 태그를 <이름>:local처럼 짓는 것도 봤는데, 도커에게 특별한 의미는 없다. "레지스트리에서
받은 것이 아니라 여기서 빌드한 것"을 표시하는 관례다. :latest로 지으면 공개 저장소에 있는
것처럼 보여 헷갈린다.
데이터는 컨테이너 밖에 있다
앞서 확인한 대로 DB 데이터는 bind mount다. 이것이 두 가지를 가능하게 한다.
이미지 교체와 데이터 마이그레이션이 분리된다. pgvector 작업의 롤백이 "compose 파일 되돌리고
up -d" 두 줄로 끝난 이유이고, 7절 복구에서 컨테이너를 통째로 지울 수 있었던 이유이기도 하다.
덤프와 복원 방식이었다면 롤백이 훨씬 무거웠을 것이다.
비밀이 담긴 파일에는 권한을 조인다.
chmod 600 .env.local
600 = rw- --- ---
644 = rw- r-- r-- ← 기본 umask. 서버의 모든 사용자가 읽을 수 있다
DB 접속 문자열, 세션 서명 키, 초기 관리자 비밀번호가 들어 있는 파일이 644면 곤란하다.
백업이 세 종류인 이유
pgvector 작업에서 백업을 셋 떴다. 중복이 아니라 실패 모드별 대응이다.
논리 (pg_dumpall) |
물리 (pg_basebackup) |
콜드 (tar) | |
|---|---|---|---|
| 내용 | SQL 텍스트 | 데이터 디렉터리 바이트 + WAL | 정지 상태의 디렉터리 |
| 크기 | 475K | 60M | 11M |
| 복원 | SQL 재생, 인덱스 재구축 | 풀고 바로 부팅 | 풀고 바로 부팅 |
| 이식성 | 높음 (버전·플랫폼 무관) | 낮음 (같은 메이저·플랫폼) | 낮음 |
| 중단 | 없음 | 없음 | 필요 |
크기가 100배 넘게 차이 나는 이유는 물리 백업이 인덱스 실물, 빈 공간, WAL까지 담는 반면 논리는 데이터만 텍스트로 뽑아 압축하기 때문이다.
복원 리허설에 물리 백업을 쓴 것이 핵심이다. 검증하려던 것은 "데이터가 살아 있나"가 아니라 "새 이미지가 기존 데이터 디렉터리를 그대로 읽고 부팅하는가"였다. 진짜 위험이 거기 있었다. Alpine 계열을 유지하지 않으면 문자열 정렬 규칙이 바뀌어 인덱스가 깨지는데, 이 클러스터는 정렬 버전 정보가 비어 있어 PostgreSQL이 경고조차 못 한다.
논리 백업으로 리허설했다면 이 위험을 못 잡는다. SQL을 재생하면 파일이 전부 새로 만들어지니 기존 파일과 새 바이너리의 호환성 문제가 애초에 발생하지 않는다. 통과해도 아무것도 증명하지 못하는 것이다.
그럼 논리 백업은 왜 떴나. 물리 백업이 못 쓰게 됐을 때의 탈출구다. 새 이미지가 기존 데이터를 못 열면 물리 백업도 똑같이 못 연다. 같은 형식이기 때문이다. 그때 남는 것이 SQL 텍스트다.
물리 백업이 무중단인 것은 WAL 스트리밍 옵션 덕이다. 복사하는 동안에도 DB는 계속 쓰이므로 사본은
시점이 어긋난 상태인데, 그 사이 발생한 로그를 함께 받아두면 복원 시 재생해서 일관된 시점으로
맞출 수 있다. 반면 콜드 백업의 단순 tar에는 그런 장치가 없어서 반드시 멈춰야 한다. 멈추지 않고
뜨면 앞부분과 뒷부분의 시점이 어긋난 찢어진 사본이 나온다.
덧붙여, 논리 백업 검증에서 완료 표식 개수를 센 것이 중요했다. 덤프가 중간에 끊겨도 압축 파일 자체는 유효할 수 있어서, 압축 무결성 검사만으로는 "내용이 완결됐다"를 보장하지 못한다.
6. compose 명령이 실제로 하는 일
사고의 절반은 명령의 의미를 정확히 모른 채 쓴 데서 왔다.
stop / start — 전원만 건드린다
컨테이너는 존재한 채로 멈추고, start하면 정확히 같은 컨테이너가 돌아온다. 이미지도 설정도
네트워크 연결도 그대로다.
쓸 곳은 "정지 상태 자체가 필요한 작업"이다. 콜드 백업, 데이터 디렉터리 이동, 자원 회수, 기동 순서 제어, 장애 격리 실험 같은 것들이다.
compose로 띄운 컨테이너를 반드시 compose로 멈춰야 하는 것은 아니다. 도커 데몬은 하나라
docker stop <이름>도 똑같이 동작한다. 다만 compose 쪽은 서비스 이름으로 다룰 수 있어 실제
컨테이너 이름이 바뀌어도 스크립트가 안 깨지고, 의존 순서를 지킨다.
up -d — 선언한 상태로 맞춘다
명령형이 아니라 선언형이다. "이렇게 해라"가 아니라 "이 상태가 되게 해라"에 가깝다.
현재 설정과 compose 파일이 요구하는 설정을 비교
├ 같으면 → 멈춰 있던 컨테이너를 그냥 start
└ 다르면 → 기존 컨테이너를 지우고 새로 생성
비교는 컨테이너에 박힌 설정 해시 라벨로 한다. 이미 맞는 상태에서 실행하면 아무 일도 일어나지 않으므로 안전하게 반복할 수 있다.
여기서 나온 오해 세 가지를 짚어둔다.
"재생성하면 컨테이너가 여러 개 생기나?" 아니다. 재생성은 삭제 후 생성이라 개수가 늘지 않는다. compose는 프로젝트·서비스·번호 라벨로 자기 것을 식별하고, 이름도 같아서 중복 생성 자체가 불가능하다. 여러 개를 원하면 스케일 옵션으로 명시적으로 요청해야 한다.
"교체하려면 먼저 stop 해야 하나?" 아니다. up -d가 알아서 멈추고 지우고 만든다. pgvector
교체에서 stop을 넣었던 것은 그 사이에 콜드 백업을 뜰 정지 구간이 필요해서였다. 백업이
없었다면 stop도 필요 없었다.
"서비스 이름을 지정해야 하나, 전체를 해도 되나?" 둘 다 된다. 선언형이라 이미 맞는 서비스는 건드리지 않는다. 그래도 이름을 지정하는 편이 낫다. 다른 서비스의 설정이 어느 시점에 바뀌어 있었다면, 전체를 치는 순간 의도하지 않은 재생성이 함께 일어나 DB 교체 작업이 갑자기 사이트 전체 재시작이 된다. 범위를 좁히는 이유는 안전만이 아니라 진단 가능성이다. 하나만 건드렸으니 문제가 생겼을 때 원인이 명확하다.
down — 삭제가 목적이다
up -d의 삭제는 교체 과정의 중간 단계이고, down의 삭제는 최종 상태다.
up -d <서비스> |
down |
|
|---|---|---|
| 대상 | 그 서비스만 | 프로젝트 전체 |
| 네트워크 | 손대지 않음 | 삭제 시도 |
| 설정이 안 바뀌었으면 | 삭제조차 안 함 | 무조건 삭제 |
| 끝난 뒤 | 새 컨테이너가 떠 있음 | 아무것도 없음 |
-v를 붙이면 named volume까지 지운다. 이 서버의 운영 DB는 bind mount라 사정권 밖이지만, 착각하기
쉬운 지점이다. 다른 환경에서 named volume을 쓰고 있다면 같은 명령이 데이터를 날린다.
이 서버 구성에서 채팅 스택의 down은 사실상 전체 서비스 정지다. 게다가 다른 프로젝트
컨테이너들이 그 네트워크에 얹혀 있어, 네트워크 삭제는 실패하면서 서비스만 내려가는 최악의
조합이 된다. 정상 운영 중에 쓸 일이 없다.
restart — 이름이 가장 헷갈린다
stop + start일 뿐이고 설정을 다시 읽지 않는다. .env나 compose를 고친 뒤 restart를
치면 아무것도 반영되지 않는다. 그럴 때는 up -d다.
제 몫을 하는 경우는 "프로세스만 초기화하고 싶을 때"다. 메모리 누수가 의심되거나, 설정이 compose가 아니라 마운트된 파일 안에 있어 컨테이너 정의는 그대로인 경우다. 다만 nginx처럼 더 가벼운 리로드 수단이 있는 소프트웨어도 있다.
--remove-orphans — 파일 세트가 틀리면 흉기가 된다
"이 프로젝트 라벨을 달고 있는데 compose 파일에 정의가 없는 컨테이너"를 지운다. 고아의 주인은 compose 프로젝트다. 부모(서비스 정의)가 사라졌는데 자식(컨테이너)만 남은 상태를 말한다.
서비스 이름을 바꾸거나 삭제한 뒤 정리할 때 쓴다. 붙이지 않으면 경고만 뜨고 컨테이너는 남는다.
문제는 "정의가 없다"의 판정이 지금 읽은 파일 세트에 달려 있다는 것이다. 7절에서 정확히 이 일이 벌어진다.
실행 위치도 인자다
docker compose는 현재 디렉터리에서 compose 파일을 찾고, 프로젝트 이름을 디렉터리 이름에서
가져온다. 컨테이너 이름이 <프로젝트>-<서비스>-<번호> 형태인 것도 여기서 온다.
그래서 compose 파일을 다른 디렉터리로 복사해 실행하면 compose는 그것을 완전히 별개의 프로젝트로 본다. 볼륨 경로는 같은 곳을 가리키는데 프로젝트만 둘이 되는, 즉 같은 데이터 디렉터리를 두 인스턴스가 여는 상황이 만들어진다.
이 위험은 낯설지 않다. 에이전트를 운영하면서 세운 원칙 중에 "같은 상태 디렉터리에 게이트웨이 두 개를 띄우지 말 것"이 있다. SQLite가 단일 writer만 허용하고, 두 번째 프로세스가 PID 파일을 덮어써서 정지 명령이 엉뚱한 프로세스를 죽이기 때문이다. "같은 상태 저장소를 두 프로세스가 연다"는 하나의 안티패턴이고, 여기서도 같은 얼굴로 나타난다.
디렉터리에 의존하지 않으려면 프로젝트 이름을 명시하거나 .env에 넣는다.
설정 파일은 언제 읽히나
.env는 실시간으로 읽히지 않는다. docker compose 명령을 칠 때마다 CLI가 읽어서 compose
파일의 변수를 치환하고, 그 결과가 컨테이너 생성 시점에 박힌다. 컨테이너는 .env를 보지 않는다.
| 하는 일 | .env 반영 |
|---|---|
up -d |
다시 읽고, 값이 바뀌었으면 재생성 |
config / ps |
읽음 (표시용) |
restart |
기존 설정 그대로 재시작 |
docker restart <이름> |
compose를 안 거침 |
도는 중에 .env 편집 |
아무 일도 안 일어남 |
COMPOSE_FILE도 같은 자리에서 읽힌다. 그래서 셸 환경변수로 export하면 CLI 파싱 시점에 적용되고,
우선순위가 높으면서 디렉터리를 옮겨도 따라온다. 다른 프로젝트 디렉터리에서 없는 파일을 찾다
죽는 사고가 여기서 나온다.
파일 이름이 docker-compose.override.yml이면 별도 지정 없이 자동으로 겹쳐진다. 규약 이름이기
때문이다. 오히려 COMPOSE_FILE을 지정하면 자동 탐색이 꺼져서 모든 파일을 직접 나열해야 한다.
문제는 채팅 스택의 두 번째 파일이 upstream이 붙인 임의 이름이라는 것이다. 규약 이름이 아니므로 자동 로드되지 않고, 명시적으로 지정할 수밖에 없다. 우리 저장소가 아니라 이름을 바꿀 수도 없다. 이 비대칭이 다음 절의 무대다.
7. 그리고 실제로 밟았다
증상
DB 컨테이너 하나만 다시 올리려고 했다.
cd /root/docker
docker compose up -d postgres
WARN[0000] Found orphan containers (nginx_mattermost) for this project.
If you removed or renamed this service in your compose file,
you can run this command with the --remove-orphans flag to clean it up.
[+] up 2/4
⠿ Network... Creating 0.5s
✔ Contain... Stopped 0.4s
⠿ Network... Removing 0.0s
✘ network... Error response from daemon: error while removing network:
network mattermost has active endpoints (8건)
up 2/4. 절반쯤 진행하다 멈췄다. 그리고 이 시점에 DB는 정지 상태였고, 그 위에 얹힌 채팅
서버와 관리자 콘솔이 함께 내려가 있었다.
에러 한 줄로 끝나지 않은 것이 이 사고의 성격이다. 경고가 하나 있고, 진행 표시가 절반쯤 차 있고, 마지막 줄은 "네트워크를 못 지웠다"는 얼핏 사소해 보이는 메시지다. 어디에도 "DB가 멈췄습니다"라고 쓰여 있지 않다.
원인
명령을 친 셸에 COMPOSE_FILE이 설정돼 있지 않았다. 그래서 compose가 기본 파일 하나만 읽었고,
네트워크 이름을 정하는 세 줄이 두 번째 파일에만 있으므로 이름이 갈렸다.
| 파일 세트 | 네트워크 이름 |
|---|---|
| 기본 + nginx | mattermost |
| 기본만 | docker_default (프로젝트명 기반 자동 생성) |
compose 입장에서는 아무 모순이 없다. 선언된 상태가 달라졌으니 그쪽으로 맞추려 했을 뿐이다. 전이 과정이 이랬다.
docker_default네트워크를 만든다- DB를 옮기기 위해 컨테이너를 멈춘다
- 비워질
mattermost네트워크를 정리하려 한다 - 다른 컨테이너 여덟 개가 아직 붙어 있어 삭제가 거부된다 → 중단
4번의 실패가 오히려 피해를 막았다. 네트워크가 실제로 지워졌다면 채팅 앱, 에이전트, 검색 MCP가 전부 통신 불능이 됐을 것이다. 정지 범위가 DB 하나로 끝난 것은 운이었다.
첫 줄의 경고도 같은 뿌리다. nginx 서비스는 두 번째 파일에만 정의돼 있으므로, 그 파일을 읽지 않은
compose 눈에는 고아로 보인다. 친절하게 --remove-orphans를 붙이라고 제안까지 해준다. 그
말을 따랐다면 nginx가 삭제되면서 사이트 전체가 사라졌을 것이다.
여담으로, upstream이 함께 제공하는 "nginx 없는" 변형 파일로 갈아타도 같은 사고가 난다. 그 파일에도 네트워크 이름 세 줄이 없다. 파일 이름이 그럴듯해 보여도 무엇이 들어 있는지가 기준이다.
진단
docker ps -a --filter name=postgres --format '{{.Names}}\t{{.Status}}'
<pg> Exited (0) 4 minutes ago
Exited (0). 종료 코드 0은 정상 종료라 크래시가 아니고 데이터 정합성 걱정은 없다. 여기까지는
"멈췄으니 다시 켜면 되겠다"로 읽힌다.
파일 세트를 되돌리고 다시 시도하자 새로운 에러가 나왔다.
Error response from daemon: container 6e595cf7... is not connected to
the network docker_default
compose는 이제 "docker_default는 설정에 없으니 정리하자"고 판단하고 DB를 거기서 분리하려 했는데,
애초에 연결된 적이 없어서 실패한 것이다. 앞선 실행이 네트워크를 만들기만 하고 컨테이너를
옮기는 데는 실패한 채 중단됐기 때문이다.
결정적인 단서는 다음 명령에서 나왔다.
docker inspect <pg> -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
(빈 줄)
어떤 네트워크에도 붙어 있지 않았다. 기존 네트워크에서 떼어내는 데는 성공하고 새 네트워크에 붙이는 데는 실패한, 어느 쪽도 아닌 상태였다.
이 값은 docker ps에 나오지 않는다. 목록 명령은 상태를 Exited (0)까지만 보여주고, 네트워크적으로
고립돼 있다는 사실은 전혀 드러내지 않는다.
부분 실패 뒤에는 목록이 아니라 속성을 본다.
docker ps는 "무엇이 있나"를 답하고docker inspect는 "그것이 어떤 상태인가"를 답한다. 명령이 중간에 죽은 뒤라면 후자가 필요하다.
복구
docker rm -f <pg>
docker network rm docker_default
docker compose up -d postgres
docker start를 쓰지 않은 이유가 있다. 네트워크 연결은 컨테이너를 만들 때 결정되고 start는
그것을 다시 계산하지 않는다. 6절의 "stop/start는 전원, up -d는 정체성"이라는 경계가 여기서
실증된 셈이다. 지금 고쳐야 하는 것은 정체성이므로 재생성이 필요했다.
컨테이너를 지우는 것이 위험해 보이지만 여기서는 아니다. 5절에서 확인한 대로 데이터 디렉터리가 bind mount라 컨테이너 수명과 무관하다.
검증
docker inspect <pg> -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
docker exec <pg> psql -U <계정> -d <채팅DB> -c \
"select (select count(*) from users) as users, (select count(*) from posts) as posts,
(select count(*) from channels) as channels, (select count(*) from teams) as teams;"
docker exec <pg> psql -U <계정> -d <채팅DB> -tAc \
"select name||' '||default_version from pg_available_extensions where name='vector';"
docker logs <pg> 2>&1 | grep -icE 'collation|PANIC'
mattermost
17 | 1220 | 23 | 2
vector 0.8.1
0
이틀 전 기록해둔 기준값은 17 / 1193 / 23 / 2였다. posts만 1193에서 1220으로 늘었다.
정상이라고 판단할 수 있는 근거는 네 값을 함께 기록해뒀다는 데 있다. posts는 사람들이 대화하면 계속 늘어나는 값이고, 나머지 셋은 관리 행위 없이는 변하지 않는 값이다. 후자가 그대로라는 것이 무손실의 증거다.
기준값 대조에서 "정확히 일치"만 정상으로 보면 오판한다. posts 하나만 기록해뒀다면 "1193이 아닌데?" 하고 한참 헤맸을 것이다. 기준값은 여러 개를 함께, 그리고 각각이 어떤 성질의 값인지 알 수 있게 남겨야 쓸모가 있다.
서비스 레벨도 확인했다.
curl -s -o /dev/null -w "chat HTTP %{http_code}\n" https://mattermost.example.com/api/v4/system/ping
curl -s -o /dev/null -w "admin HTTP %{http_code}\n" https://mattermost.example.com:3000/login
docker exec rag-mcp-company python -c "
import psycopg, os
with psycopg.connect(os.environ['DATABASE_URL']) as c:
print('DB:', c.execute('select current_database()').fetchone()[0])
"
chat HTTP 200
admin HTTP 200
DB: admin_db
검색 MCP는 별도 재시작 없이 스스로 재접속했다. 구현에 따라 갈리는 부분이라 확인이 필요했다. 커넥션을 오래 물고 있는 프로세스는 죽은 소켓을 붙잡고 있을 수 있다.
조치
기존 절차는 이랬다.
cd /root/docker
export COMPOSE_FILE=docker-compose.yml:docker-compose.nginx.yml
docker compose up -d postgres
unset COMPOSE_FILE
두 가지 문제가 있다. 사람의 기억에 의존한다. 셸 세션 수명이라 재접속하면 사라지고, 한 번만 잊으면 이번 사고가 난다. 그리고 범위가 너무 넓다. 값이 셸에 붙어 있으니 다른 프로젝트 디렉터리까지 따라간다.
그래서 프로젝트 .env에 넣었다.
echo 'COMPOSE_FILE=docker-compose.yml:docker-compose.nginx.yml' >> /root/docker/.env
이제 값이 디렉터리에 묶인다. 그 디렉터리에서는 별도 지정 없이 두 파일이 적용되고, 다른 디렉터리는 영향받지 않는다.
| 방식 | 유효 범위 | 위험 |
|---|---|---|
export COMPOSE_FILE=... |
셸 세션 전체 | 잊으면 사고, 다른 디렉터리 오염 |
-f a.yml -f b.yml 매번 |
명령 하나 | 타이핑 누락 |
.env에 COMPOSE_FILE= |
그 디렉터리 | 없음 |
범위가 문제의 모양과 일치해야 한다. "이 프로젝트는 파일 두 개로 구성된다"는 프로젝트의 속성이지 셸의 속성이 아니다. 셸 변수는 너무 넓고, 매번 타이핑은 너무 좁다.
한 가지 주의는 그 .env가 git 추적 대상인지 확인해야 한다는 것이다. upstream 클론이라 추적
파일이면 다음 pull에서 충돌한다.
8. 이 세션이 남긴 것
안전한 쪽이 기본값이 아니다
세션 내내 같은 규칙이 반복해서 나왔다.
- 도커에서 바인딩 주소를 생략하면
0.0.0.0이 채워진다 - 프로덕션 실행 명령이 호스트 옵션을 생략하면
0.0.0.0에 바인딩한다 - 임베딩 서버는 반대로 기본이 루프백이라 컨테이너에서 닿지 않는다
앞의 둘은 "적지 않으면 열리고", 마지막은 "적지 않으면 안 통한다". 방향은 반대지만 공통점이 있다. 기본값이 의도와 무관하게 정해져 있고, 명시하지 않으면 그쪽으로 간다. 의도치 않은 노출을 발견했을 때 대부분은 누가 결정한 것이 아니라 누락의 결과였다.
이번 작업이 이걸 명시적으로 피한 방식이 검색 MCP의 게시 주소를 .env 변수로 뺀 것이다. 값을
적어야만 동작하게 만들면 생략으로 인한 공개가 구조적으로 생기지 않는다.
부분 이전은 손해다
관리자 콘솔과 임베딩 서버가 호스트 프로세스로 남아 있어서 172.17.0.1이 계속 등장한다. 콘솔을
컨테이너로 옮기면 브리지 주소 지정이 사라지고, extra_hosts 선언이 필요 없어지고, DB 포트를
호스트에 게시할 이유 자체가 없어진다.
그런데 임베딩 서버가 호스트에 남아 있는 한 브리지 주소는 사라지지 않는다. 복잡도가 절반만 준다. 반대로 임베딩 서버만 먼저 옮기면 이번엔 호스트의 콘솔이 닿아야 하므로 포트를 게시해야 하고, 문제가 방향만 뒤집힌 채 그대로 남는다.
두 세계 사이의 다리가 필요한 이유는 경계가 존재하기 때문이라, 경계를 옮기는 것은 도움이 되지 않고 없애야 이득이 난다. 절반만 옮기면 다리는 그대로 두고 관리 대상만 늘어난다.
그래서 결론은 "언젠가 둘을 함께"다. 지금 우선순위는 낮다. 동작하고 있고, 얻는 것이 주로 정리 효과이며, 콘솔은 아직 자주 재배포되는 중이라 컨테이너화하면 빌드 사이클이 길어진다. 코드가 안정돼 재배포 빈도가 떨어지는 시점이 전환 신호가 될 것이다.
이틀 전 기록의 "Linux 특유의 함정" 여덟 개 중 셋이 정확히 이 경계에서 나왔다는 점도 덧붙여둔다. 개별 실수가 아니라 배치 선택의 필연적 결과였다.
경고문보다 파일
문서에 함정을 적어두는 것과 그 함정을 밟지 않는 것은 다른 일이었다.
이틀 전 기록에는 이 문제가 절 제목으로 박혀 있었고 "빠뜨리면 네트워크가 깨진다"는 문장까지 있었다. 그런데도 밟았다. 경고문은 사람이 그 순간 기억해야 작동하고, 사람은 잊는다.
그래서 이번 조치의 핵심은 문서를 더 강하게 쓰는 것이 아니라 .env 한 줄이었다. 값을 디렉터리에
묶으면 기억할 필요가 없어진다. 같은 원칙이 이 프로젝트의 소스 관리 규칙에도 이미 있었다. 환경마다
다른 것은 추적 파일이 아니라 .env와 override 파일로 뺀다는 것. 환경 차이는 셸이나 기억이
아니라 파일에 묶어야 한다는 원칙이 한 번 더 검증된 셈이다.
진단 쪽 교훈은 더 단순하다. 선언형 도구는 "현재 상태 → 목표 상태" 전이를 계산하는데, 이전 실행이 중간에 죽으면 현재 상태에 대한 가정이 틀어진다. 그래서 다음 명령이 엉뚱한 곳에서 죽는다. 이럴 때 가장 확실한 복구는 문제 리소스를 지우고 처음부터 만들게 하는 것이다.
관련 글
- 개발기에서 되던 것이 서버에서 안 되는 지점들 — Ubuntu 배포 — 이 세션이 읽고 있던 문서. 브리지 주소,
extra_hosts, 파일 세트 함정의 원래 맥락이 전부 여기 있다 - 채팅 DB에 pgvector를 얹다 — PostgreSQL 인스턴스 두 개를 하나로 — 5절의 이미지 빌드와 백업 세 종류, 복원 리허설 설계의 원본 기록
- 채널마다 다른 규정을 보게 하기 — 멀티 프로젝트 RAG 결선 — 같은 이미지로 인스턴스를 나눠 격리를 만드는 구조. 3절의 "컨테이너 이름으로 부른다"가 실제로 쓰이는 곳
- 주말에 봇이 조용히 죽었다 — 1시간짜리 토큰을 6시간마다 갱신하는 스케줄러 — 조치 도중에 다시 망가뜨린 또 다른 기록