[도커 30강] 24강. 네트워크 분리와 서비스 간 접근 제어

[도커 30강] 24강. 네트워크 분리와 서비스 간 접근 제어

도커를 쓰다 보면 처음에는 "컨테이너가 서로 통신되기만 하면 된다"는 관점으로 시작합니다. 하지만 서비스가 늘어나고 운영 환경으로 갈수록, 통신이 되는 것보다 어디까지 통신을 허용할지가 더 중요해집니다. 예를 들어 웹 서버, API 서버, DB, 캐시, 배치 워커가 한 호스트에 함께 올라갈 때 모든 컨테이너가 서로 직접 접근 가능한 상태라면, 한 서비스가 침해되었을 때 피해가 수평으로 빠르게 확산됩니다.

오늘 강의의 목표는 단순합니다. 네트워크를 역할별로 분리하고, 서비스 간 접근 경로를 의도적으로 설계하는 법을 익히는 것입니다. 도커의 bridge 네트워크, internal 네트워크, 서비스별 다중 네트워크 연결, publish 포트 최소화 원칙을 한 번에 정리해보겠습니다. 이 원칙은 이후 Kubernetes NetworkPolicy를 이해할 때도 그대로 이어집니다.


핵심 개념

  • **네트워크 분리(Network Segmentation)**는 서비스 간 통신을 최소 단위로 나누어 공격면을 줄이는 방법입니다. "필요한 통신만 허용"이 기본입니다.
  • 외부 노출(ingress)과 내부 통신(east-west)을 분리해야 합니다. 외부에서 들어오는 요청을 받는 서비스와 내부 데이터 계층(DB/Redis)은 같은 접근 정책이면 안 됩니다.
  • 도커에서는 컨테이너가 같은 사용자 정의 네트워크에 있을 때만 이름 기반 DNS 통신이 가능합니다. 이 특성을 이용해 의도한 서비스만 같은 네트워크에 넣을 수 있습니다.
  • ports는 호스트 외부 노출, expose는 문서적 의도 표현(같은 네트워크 내부 참고)이라는 점을 구분해야 합니다. 실무에서는 ports를 최소화하는 것이 핵심입니다.
  • internal: true 네트워크는 외부 인터넷으로의 직접 egress를 제한하는 데 유용합니다. DB처럼 외부 통신이 불필요한 계층에 적용하면 안전성이 올라갑니다.

기본 사용

예제 1) 단일 네트워크에서 시작해 구조 이해하기

mkdir -p ~/docker100/lesson24 && cd ~/docker100/lesson24

cat > compose.single.yaml <<'YAML'
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    depends_on:
      - api

  api:
    image: python:3.12-slim
    command: ["python", "-m", "http.server", "8000"]
    working_dir: /app
    volumes:
      - ./:/app
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
YAML

docker compose -f compose.single.yaml up -d
docker compose -f compose.single.yaml ps
docker network ls | grep lesson24 || true

설명:

  • 처음에는 모든 서비스가 같은 기본 네트워크에 올라갑니다.
  • 이 상태에서는 서비스 간 통신이 편하지만, 분리 관점에서는 과도하게 열려 있는 구조입니다.
  • 학습 포인트는 "동작은 쉽지만 통제는 약하다"는 출발점을 명확히 인식하는 것입니다.

예제 2) 프론트/백엔드 네트워크 분리 + 다중 네트워크 연결

cat > compose.segmented.yaml <<'YAML'
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend
    depends_on:
      - api

  api:
    image: hashicorp/http-echo:1.0
    command: ["-text=api ok"]
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
YAML

docker compose -f compose.segmented.yaml up -d

docker compose -f compose.segmented.yaml exec api sh -lc 'getent hosts db && getent hosts web || true'
docker compose -f compose.segmented.yaml exec web sh -lc 'getent hosts api && getent hosts db || true'

설명:

  • apifrontendbackend를 모두 연결해 게이트웨이 역할을 합니다.
  • webfrontend만 연결하므로 db를 직접 찾기 어렵습니다(설계 의도).
  • dbbackend에만 존재하므로 외부 진입점을 최소화할 수 있습니다.

예제 3) internal 네트워크로 데이터 계층 egress 제한하기

cat > compose.internal.yaml <<'YAML'
services:
  api:
    image: alpine:3.20
    command: ["sh", "-c", "sleep infinity"]
    networks:
      - app_net
      - data_net

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    networks:
      - data_net

networks:
  app_net:
    driver: bridge
  data_net:
    driver: bridge
    internal: true
YAML

docker compose -f compose.internal.yaml up -d

# 네트워크 상세 확인
for n in $(docker network ls --format '{{.Name}}' | grep 'lesson24'); do
  echo "=== $n ==="
  docker network inspect "$n" --format '{{.Name}} internal={{.Internal}} containers={{len .Containers}}'
done

설명:

  • internal: true는 해당 네트워크의 외부 라우팅을 제한해 데이터 계층을 더 고립시킵니다.
  • DB가 꼭 외부 인터넷과 통신할 이유가 없다면 internal 네트워크는 매우 유효한 기본값입니다.
  • 단, 백업 에이전트/모니터링 에이전트 등 필요한 통신 경로는 사전에 설계해야 운영 장애를 막을 수 있습니다.

예제 4) 불필요한 포트 공개 제거와 검증

# 현재 포트 공개 현황 확인

docker ps --format 'table {{.Names}}\t{{.Ports}}'

# compose 파일에서 db 서비스의 ports 항목이 없는지 확인
python3 - <<'PY'
import yaml
for fn in ['compose.segmented.yaml','compose.internal.yaml']:
    with open(fn) as f:
        doc=yaml.safe_load(f)
    db_ports=doc.get('services',{}).get('db',{}).get('ports')
    print(fn, 'db ports =', db_ports)
PY

설명:

  • 운영 실수의 절반은 "테스트 편의로 연 포트를 그대로 배포"하면서 시작됩니다.
  • DB, Redis, 내부 관리 포트는 기본적으로 ports를 열지 않는 습관이 중요합니다.
  • 연결이 필요하면 앱 계층을 통해 접근시키고, 직접 접근은 bastion/운영 VPN 등 통제된 경로로만 허용하세요.

자주 하는 실수

실수 1) 모든 서비스를 하나의 네트워크에 몰아넣기

  • 원인: 설정이 간단하고 초기에는 빨리 동작하기 때문입니다.
  • 문제: 한 컨테이너 침해 시 다른 내부 서비스 탐색/접근이 쉬워집니다.
  • 해결: 최소한 frontend/backend 두 계층으로 나누고, 다중 연결은 필요한 중간 서비스(API)만 허용합니다.

실수 2) 내부 서비스까지 습관적으로 ports 공개

  • 원인: 로컬 디버깅 습관이 운영 설정으로 유입됩니다.
  • 문제: DB/캐시가 호스트 외부에 노출되어 공격 표면이 급격히 커집니다.
  • 해결: 내부 서비스는 ports 제거를 기본 정책으로 두고, 필요시 임시 터널링/관리 네트워크로 접근합니다.

실수 3) 네트워크 분리만 하고 실제 통신 검증을 생략

  • 원인: Compose가 정상 기동되면 정책도 정상이라고 오해합니다.
  • 문제: 의도치 않은 우회 경로가 남아 있거나, 반대로 필요한 통신이 막혀 장애가 발생합니다.
  • 해결: 배포 전/후에 docker compose exec로 이름 해석, 포트 접근, 실패 케이스까지 검증하는 체크리스트를 운영합니다.

실수 4) internal 네트워크 도입 후 운영 작업 경로를 설계하지 않음

  • 원인: 보안 강화 자체에만 집중하고 백업/모니터링/마이그레이션 흐름을 놓칩니다.
  • 문제: 긴급 점검 시 접속 경로가 없어 대응 시간이 늘어납니다.
  • 해결: 통제된 관리 경로(예: 전용 관리 컨테이너, VPN, bastion)를 함께 설계하고 문서화합니다.

실무 패턴

실무에서 네트워크 분리를 잘 적용하는 팀은 공통적으로 "구조를 먼저 정의하고 서비스는 그 구조 위에 배치"합니다. 즉 애플리케이션을 만든 뒤 네트워크를 덧붙이는 게 아니라, 네트워크 경계를 먼저 그려두고 서비스별 역할을 맞춰 넣습니다.

첫 번째 패턴은 3계층 기본 모델입니다. edge(front)는 외부 트래픽을 받는 레이어(리버스 프록시/API Gateway), app은 비즈니스 로직, data는 DB/캐시/메시지 브로커 계층으로 분리합니다. edge↔app, app↔data만 허용하고 edge↔data 직접 접근은 원칙적으로 금지합니다. 도커 컴포즈에서는 이 규칙을 네트워크 연결 자체로 강제할 수 있습니다.

두 번째 패턴은 포트 공개 최소화 규칙의 코드화입니다. 팀 규약으로 "외부 공개 가능한 서비스 목록"을 정하고, 그 외 서비스에서 ports가 등장하면 PR에서 차단합니다. 사람이 매번 기억하기 어렵기 때문에 lint 스크립트나 CI 정책으로 자동화하는 것이 안전합니다.

세 번째 패턴은 운영 디버깅 경로 분리입니다. 내부 서비스 문제를 디버깅하려고 평소에 DB 포트를 열어두는 것은 나쁜 트레이드오프입니다. 대신 관리 작업은 일시적 점프 컨테이너나 사설 네트워크 기반 도구를 통해 수행하고, 작업 종료 후 즉시 종료하는 절차를 표준화합니다.

네 번째 패턴은 **명시적 통신 계약(Service Contract)**입니다. 어떤 서비스가 어떤 포트로 누구와 통신하는지 문서화하고, 실제 Compose/Kubernetes 설정과 맞는지 주기적으로 비교합니다. 문서와 실제가 어긋나기 시작하면 보안 구멍이나 장애 원인이 누적됩니다.

다섯 번째 패턴은 관측 가능한 거부(deny) 전략입니다. 접근을 막는 것은 시작일 뿐이며, 막힌 이벤트를 로그/메트릭으로 확인할 수 있어야 운영 품질이 올라갑니다. 예를 들어 API가 DB 외 다른 내부 대상에 연결 시도하는 패턴이 보인다면 설계 위반이나 침해 신호일 수 있습니다.

결론적으로 네트워크 분리의 목적은 복잡함을 늘리는 것이 아니라, 사고가 나도 "어디까지 번질 수 있는지"를 제한하는 데 있습니다. 초기에 조금 더 설계하면, 나중에 훨씬 큰 장애와 보안 비용을 줄일 수 있습니다.

오늘의 결론

한 줄 요약: 도커 네트워크는 연결을 쉽게 만드는 도구이면서 동시에 경계를 강제하는 보안 장치이므로, 서비스 역할 기준으로 분리하고 공개 포트를 최소화해야 한다.

연습문제

  1. 현재 사용하는 Compose 프로젝트를 기준으로 frontend, backend 네트워크를 분리하고, 어떤 서비스가 두 네트워크를 동시에 가져야 하는지 표로 정리해보세요.
  2. 내부 서비스(DB/Redis 등)에서 ports를 제거한 뒤 애플리케이션이 정상 동작하는지 확인하고, 운영 디버깅 대체 경로를 설계해보세요.
  3. internal: true 네트워크를 데이터 계층에 적용한 후, 필요한 백업/모니터링 통신이 유지되는지 검증 체크리스트를 만들어보세요.

이전 강의 정답

23강 연습문제 해설:

  • non-root 전환의 핵심은 단순히 USER 한 줄을 추가하는 것이 아니라, 애플리케이션이 실제로 쓰는 경로 권한까지 함께 정리하는 것입니다. Permission denied가 난다고 root로 되돌리기보다, 필요한 디렉터리 소유권/권한을 정확히 맞추는 방식이 정답입니다.
  • --cap-drop ALLno-new-privileges는 "당장 문제 없으면 생략"이 아니라 기본값으로 두고, 필요한 capability만 근거를 남겨 제한적으로 추가해야 합니다.
  • read-only root filesystem은 보안과 안정성에 모두 도움이 됩니다. 다만 임시 파일 경로(/tmp)나 앱 캐시 경로를 명시적으로 허용해야 하므로, 사전 점검과 관측(로그)이 함께 가야 운영에서 유지됩니다.

실습 환경/재현 정보

  • OS: macOS 15+ 또는 Ubuntu 22.04+
  • Docker 버전: Docker Engine/Desktop 25.x 이상
  • 사용 도구: Docker Compose v2
  • 실행 순서:
    1. 단일 네트워크 Compose로 기본 동작 확인
    2. frontend/backend 네트워크 분리 및 서비스 재배치
    3. internal 네트워크 적용 후 네트워크 속성 검증
    4. 포트 공개 현황 점검 및 내부 서비스 ports 제거
    5. 서비스 간 이름 해석/접근 성공·실패 시나리오 테스트
  • 재현 체크:
    • web이 db에 직접 접근하지 못하는가?
    • api는 web·db 양쪽과 필요한 통신만 가능한가?
    • data 계층 네트워크가 internal로 설정되었는가?
    • 내부 서비스의 불필요한 외부 공개 포트가 제거되었는가?
    • 운영 디버깅 대체 경로가 문서화되어 있는가?