도커 nginx를 걷어내고 호스트 nginx로 갈아탄 이야기

왜 바꿨나

여러 개의 개인 프로젝트를 오라클 클라우드 인스턴스 하나에 몰아넣고 운영하다 보니, 리버스 프록시 역할을 하던 nginx가 도커 컨테이너로 떠 있었습니다. 처음엔 편했는데, 서비스가 하나둘 늘어나면서 슬슬 답답한 지점들이 보이기 시작했어요.

  • 설정 하나 바꾸려면 매번 컨테이너를 다시 만져야 함
  • 로그 확인이나 디버깅이 컨테이너 밖에서 한 단계 더 거쳐야 해서 번거로움
  • 시스템 레벨 도구(certbot, systemd 서비스 관리 등)와 자연스럽게 연동이 안 됨

그래서 nginx를 도커에서 빼서 호스트에 직접 설치하는 쪽으로 정리하기로 했습니다. 이 글은 그 전환 과정을 정리한 기록입니다.

핵심 아이디어: “이름 기반”에서 “포트 기반”으로

도커 컨테이너로 nginx를 돌릴 때는 같은 도커 네트워크에 묶인 다른 컨테이너들을 컨테이너 이름으로 바로 찾아갈 수 있습니다. 예를 들면 이런 식이었죠.

nginx

location / {
    proxy_pass http://my-app:80;
}

my-app이라는 이름은 도커의 내부 DNS가 알아서 실제 컨테이너 IP로 바꿔줍니다.

그런데 nginx를 호스트로 옮기면 이 내부 DNS를 쓸 수 없습니다. 호스트 입장에서 my-app이라는 이름은 아무 의미가 없거든요. 그래서 전환의 본질은 “컨테이너 이름 기반 프록시”를 “포트 기반 프록시”로 바꾸는 작업이었습니다.

방법은 간단합니다. 각 백엔드 컨테이너의 포트를 호스트에 노출시키면 됩니다.

yaml

services:
  my-app:
    ports:
      - "127.0.0.1:18010:80"

여기서 포인트는 127.0.0.1:을 꼭 붙이는 것입니다. 이걸 빼고 그냥 18010:80이라고만 쓰면 기본값이 0.0.0.0이 되어서, 이 포트가 전 세계 인터넷에 그대로 노출돼 버립니다. 백엔드 컨테이너는 nginx를 거쳐서만 접근해야 하는 내부용이니, 로컬호스트에서만 접근 가능하게 막아주는 게 안전합니다.

그다음 nginx 설정에서는 컨테이너 이름 대신 이 로컬 포트를 바라보게 바꿔주면 끝입니다.

nginx

location / {
    proxy_pass http://127.0.0.1:18010;
}

전환 순서

  1. 호스트에 nginx 설치

bash

   sudo apt install nginx -y
   sudo rm -f /etc/nginx/sites-enabled/default   # 기본 웰컴 페이지 비활성화
  1. 백엔드 컨테이너마다 포트 매핑 추가하고 재기동 서비스 개수만큼 이 작업을 반복합니다. 각각 재기동 후 curl 127.0.0.1:포트로 응답 오는지 확인.
  2. conf 파일을 컨테이너 이름 → 로컬 포트로 전부 수정해서 /etc/nginx/conf.d/에 배치

bash

   sudo nginx -t   # 문법 검증 필수
  1. 기존 도커 proxy 컨테이너 정지

bash

   docker compose down

80/443 같은 포트를 비워야 호스트 nginx가 그 포트를 잡을 수 있습니다.

  1. 호스트 nginx 시작

bash

   sudo systemctl start nginx
   sudo systemctl enable nginx   # 재부팅 시에도 자동 시작

이 스위칭 구간에서만 몇 초 정도 다운타임이 생깁니다. 완전 무중단으로 하려면 로드밸런서를 앞단에 하나 더 둬야 하는데, 개인 프로젝트 규모에서는 굳이 그렇게까지 할 필요는 없다고 판단했습니다.

예상 못 했던 함정 세 가지

1. 인증서는 그대로 써도 되지만, 자동 갱신 경로는 손봐야 한다

Let’s Encrypt 인증서 파일 자체(/etc/letsencrypt/live/...)는 호스트의 실제 경로였기 때문에 별도 이전 작업이 필요 없었습니다. 문제는 자동 갱신 시 도메인 소유권을 증명하는 방식이었어요.

webroot 방식으로 인증서를 갱신하고 있었는데, 이 방식은 http://도메인/.well-known/acme-challenge/... 경로로 요청이 들어오면 특정 폴더의 파일을 nginx가 대신 응답해주는 식으로 동작합니다. 그런데 이 “특정 폴더” 경로가 /etc/letsencrypt/renewal/도메인.conf 파일 안에 예전 도커 볼륨 경로로 박혀 있더라고요.

webroot_path = /예전/도커/볼륨/경로,

이걸 실제 호스트 nginx가 참조하는 표준 경로로 바꿔줘야 했습니다.

bash

sudo sed -i 's#예전경로#/var/www/certbot#' /etc/letsencrypt/renewal/도메인.conf

바꾼 뒤에는 실제로 인증서가 갱신되는지 dry-run으로 검증했습니다.

bash

sudo certbot renew --dry-run --cert-name 도메인

참고로 도메인마다 인증 방식이 다를 수 있다는 것도 이번에 알았습니다. DNS 인증(Cloudflare API 등)으로 발급받은 도메인은 nginx나 webroot 경로와 완전히 무관하게 동작하기 때문에 이런 문제 자체가 없었어요.

2. Docker는 방화벽 규칙을 우회한다

이게 제일 뒤통수를 맞은 부분입니다. 도커로 컨테이너 포트를 열어둘 때는 iptables 규칙을 도커가 직접 조작합니다. 그래서 ufw(우분투 방화벽) 설정에 아무 규칙이 없어도 컨테이너가 열어둔 포트는 외부에서 잘 접근이 됐어요.

그런데 nginx가 호스트 프로세스로 바뀌는 순간, 이 우회 경로가 사라지고 ufw 규칙이 그대로 적용되기 시작합니다. 저는 도커 시절엔 방화벽 신경 안 써도 됐으니 필요한 포트들이 ufw에 아예 등록조차 안 되어 있었고, 그 결과 nginx는 정상 기동됐는데 외부에서는 전부 접속이 안 되는 상황을 겪었습니다.

bash

sudo ufw status

로 확인해보니 웹 서비스에 쓰는 포트들이 목록에 하나도 없더군요. 필요한 포트를 다 열어주고 나서야 정상화됐습니다.

bash

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

로컬(127.0.0.1)에서는 잘 되는데 외부에서만 안 될 때는, nginx나 애플리케이션 설정보다 방화벽/보안그룹부터 의심하는 게 맞다는 걸 다시 배웠습니다.

3. 컨테이너 내부 포트를 다시 확인하게 됨

포트 매핑을 급하게 하다 보니, 한 서비스에서 컨테이너가 실제로 서비스하는 포트(예: https용 포트)와 다른 포트를 매핑해버린 적이 있습니다. 그 결과 nginx는 정상 작동하는데 백엔드로 연결 자체가 안 되는 502 Bad Gateway가 발생했어요.

원인은 단순했습니다. 도커 시절 conf에 proxy_pass https://컨테이너이름:포트로 명시돼 있던 그 포트 번호를 그대로 옮겨야 하는데, 습관적으로 다른 흔한 포트 번호로 매핑해버린 거였습니다. 예전 설정 파일에 있던 포트 번호를 한 글자도 놓치지 않고 그대로 옮기는 게 생각보다 중요하더라고요.

정리

전체적으로 보면 전환 자체의 개념은 단순합니다. **”컨테이너 이름으로 통신하던 걸 로컬호스트 포트로 바꾼다”**는 한 문장으로 요약되니까요. 하지만 실제로는 서비스 개수만큼 반복 작업이 필요했고, 인증서 갱신 경로나 방화벽처럼 평소엔 신경 쓸 일 없던 주변 설정들이 도커가 대신 처리해주던 것들이었다는 사실을 이번에 제대로 체감했습니다.

정리하면 체크리스트는 이렇습니다.

  • 호스트 nginx 설치, 자동 시작 등록
  • 모든 백엔드 컨테이너에 127.0.0.1:포트:내부포트 매핑 추가
  • conf 파일에서 컨테이너 이름 → 로컬 포트로 전부 치환
  • 기존 도커 프록시 컨테이너 정지 후 호스트 nginx 기동
  • 인증서 자동 갱신 경로 재확인 (webroot 방식이라면 특히)
  • 갱신 후 nginx reload되는 훅 등록
  • 방화벽(ufw)에 필요한 포트 전부 개방 여부 확인
  • 각 백엔드 컨테이너의 실제 내부 포트 번호 재확인

같은 구조로 여러 서비스를 하나의 서버에 몰아 운영하고 계신 분들이라면 참고가 되셨으면 합니다.