server

Docker 환경에서 Let's Encrypt SSL 인증서 자동 갱신 구현하기

179
24
공유

웹 서비스를 운영할 때 HTTPS는 선택이 아닌 필수입니다. Let's Encrypt를 사용하면 무료로 SSL 인증서를 발급받을 수 있지만, 90일마다 갱신해야 하는 번거로움이 있습니다. 이 글에서는 Docker 환경에서 Certbot을 활용해 인증서 자동 갱신을 구현하는 방법을 다룹니다. 테스트


목차

  1. 전체 아키텍처
  2. Nginx 설정
  3. Docker Compose 구성
  4. SSL 초기화 스크립트
  5. 자동 갱신 Cron 설정
  6. 문제 해결

전체 아키텍처

┌─────────────────────────────────────────────────────────────┐
│                        Client                               │
└─────────────────────────┬───────────────────────────────────┘
                          │ HTTPS (443)
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                     Nginx Container                         │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ SSL Termination                                       │  │
│  │ - /etc/nginx/ssl/fullchain.pem                        │  │
│  │ - /etc/nginx/ssl/privkey.pem                          │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ ACME Challenge                                        │  │
│  │ - /.well-known/acme-challenge/ → /var/www/certbot     │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                          │
        ┌─────────────────┼─────────────────┐
        ▼                 ▼                 ▼
   ┌─────────┐      ┌─────────┐      ┌─────────┐
   │Frontend │      │ Backend │      │  MinIO  │
   │ :3000   │      │  :8080  │      │  :9000  │
   └─────────┘      └─────────┘      └─────────┘

핵심 구성 요소

구성 요소 역할
Nginx SSL 종료(Termination), 리버스 프록시, ACME Challenge 서빙
Certbot Let's Encrypt 인증서 발급 및 갱신
Cron 주기적인 인증서 갱신 자동화

디렉토리 구조

~/project/
├── nginx/
│   ├── conf.d/
│   │   └── production.conf    # Nginx 설정
│   ├── ssl/                   # 인증서 저장 위치
│   │   ├── live/
│   │   │   └── example.com/
│   │   │       ├── fullchain.pem
│   │   │       └── privkey.pem
│   │   ├── fullchain.pem      # 심볼릭 링크
│   │   └── privkey.pem        # 심볼릭 링크
│   └── certbot/
│       └── www/               # ACME Challenge 파일 위치
├── docker-compose.yml
├── docker-compose.prod.yml
└── scripts/
    └── init-ssl.sh            # SSL 초기화 스크립트

Nginx 설정

HTTP → HTTPS 리다이렉트 및 ACME Challenge

# HTTP 서버 (포트 80)
server {
    listen 80;
    server_name example.com www.example.com;

    # Let's Encrypt 인증용 - ACME Challenge
    # Certbot이 도메인 소유권을 검증할 때 이 경로를 사용
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # 나머지 모든 요청은 HTTPS로 리다이렉트
    location / {
        return 301 https://$host$request_uri;
    }
}

ACME Challenge 동작 방식:

  1. Certbot이 /var/www/certbot/.well-known/acme-challenge/ 디렉토리에 랜덤 토큰 파일 생성
  2. Let's Encrypt 서버가 http://example.com/.well-known/acme-challenge/<token>으로 접근
  3. 파일 내용 검증 후 도메인 소유권 확인
  4. 인증서 발급

HTTPS 서버 설정

# HTTPS 서버 (포트 443)
server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    # SSL 인증서 경로
    ssl_certificate /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    # SSL 프로토콜 설정 (보안 강화)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;

    # SSL 세션 캐싱 (성능 최적화)
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:50m;
    ssl_session_tickets off;

    # OCSP Stapling (인증서 유효성 검증 속도 향상)
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # HSTS (HTTP Strict Transport Security)
    # 브라우저가 향후 2년간 HTTPS만 사용하도록 강제
    add_header Strict-Transport-Security "max-age=63072000" always;

    # 보안 헤더
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # www → non-www 리다이렉트
    if ($host = www.example.com) {
        return 301 https://example.com$request_uri;
    }

    # 프록시 설정...
}

SSL 설정 상세 설명

설정 설명
ssl_protocols TLSv1.2 TLSv1.3 취약한 TLS 1.0/1.1 비활성화
ssl_session_cache shared:SSL:50m SSL 세션을 50MB 공유 캐시에 저장하여 핸드셰이크 오버헤드 감소
ssl_stapling on OCSP 응답을 Nginx가 캐시하여 클라이언트에 전달
HSTS max-age=63072000 2년간 HTTPS 강제 (초 단위)

Docker Compose 구성

docker-compose.prod.yml

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"      # HTTP (ACME Challenge + 리다이렉트)
      - "443:443"    # HTTPS
    volumes:
      # Nginx 설정 파일
      - ./nginx/conf.d/production.conf:/etc/nginx/conf.d/default.conf:ro
      # SSL 인증서 (읽기 전용)
      - ./nginx/ssl:/etc/nginx/ssl:ro
      # ACME Challenge 디렉토리 (읽기 전용)
      - ./nginx/certbot/www:/var/www/certbot:ro
    depends_on:
      frontend:
        condition: service_started
      backend:
        condition: service_healthy
    restart: unless-stopped

볼륨 마운트 설명

호스트 경로 컨테이너 경로 용도
./nginx/ssl /etc/nginx/ssl Let's Encrypt 인증서
./nginx/certbot/www /var/www/certbot ACME Challenge 파일

:ro (read-only) 플래그: Nginx 컨테이너는 인증서를 읽기만 하고 수정하지 않으므로 보안을 위해 읽기 전용으로 마운트합니다.


SSL 초기화 스크립트

scripts/init-ssl.sh

#!/bin/bash
set -e

# ===========================================
# Let's Encrypt SSL 인증서 초기화 스크립트
# ===========================================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOMAIN="example.com"
EMAIL="admin@example.com"

# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

log_info() {
    echo -e "${GREEN}[INFO]${NC} $1"
}

log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $1"
}

log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# 자체 서명 인증서 생성 (초기 nginx 시작용)
create_dummy_cert() {
    log_info "임시 자체 서명 인증서 생성 중..."

    mkdir -p "$PROJECT_DIR/nginx/ssl"

    # OpenSSL로 임시 인증서 생성 (1일 유효)
    openssl req -x509 -nodes -newkey rsa:4096 -days 1 \
        -keyout "$PROJECT_DIR/nginx/ssl/privkey.pem" \
        -out "$PROJECT_DIR/nginx/ssl/fullchain.pem" \
        -subj "/CN=$DOMAIN"

    log_info "임시 인증서 생성 완료"
}

# Let's Encrypt 인증서 발급
issue_cert() {
    log_info "Let's Encrypt 인증서 발급 중..."

    # certbot Docker 컨테이너로 인증서 발급
    docker run --rm \
        -v "$PROJECT_DIR/nginx/ssl:/etc/letsencrypt/live/$DOMAIN" \
        -v "$PROJECT_DIR/nginx/certbot/www:/var/www/certbot" \
        certbot/certbot certonly \
        --webroot \
        --webroot-path=/var/www/certbot \
        --email "$EMAIL" \
        --agree-tos \
        --no-eff-email \
        -d "$DOMAIN" \
        -d "www.$DOMAIN"

    log_info "인증서 발급 완료"
}

# 인증서 갱신
renew_cert() {
    log_info "인증서 갱신 중..."

    docker run --rm \
        -v "$PROJECT_DIR/nginx/ssl:/etc/letsencrypt" \
        -v "$PROJECT_DIR/nginx/certbot/www:/var/www/certbot" \
        certbot/certbot renew

    # nginx 설정 리로드 (무중단)
    docker exec blog-nginx nginx -s reload

    log_info "인증서 갱신 완료"
}

# 사용법
usage() {
    echo "사용법: $0 {init|issue|renew}"
    echo ""
    echo "Commands:"
    echo "  init   - 초기 설정 (임시 인증서 생성)"
    echo "  issue  - Let's Encrypt 인증서 발급"
    echo "  renew  - 인증서 갱신"
}

# 메인
case "${1:-}" in
    init)
        create_dummy_cert
        ;;
    issue)
        issue_cert
        ;;
    renew)
        renew_cert
        ;;
    *)
        usage
        exit 1
        ;;
esac

스크립트 사용 방법

# 1. 실행 권한 부여
chmod +x scripts/init-ssl.sh

# 2. 임시 인증서 생성 (Nginx 최초 시작용)
./scripts/init-ssl.sh init

# 3. Let's Encrypt 인증서 발급
./scripts/init-ssl.sh issue

# 4. 인증서 수동 갱신
./scripts/init-ssl.sh renew

자동 갱신 Cron 설정

Cron Job 등록

# crontab 편집
crontab -e

# 다음 라인 추가 (매일 새벽 3시 실행)
0 3 * * * docker run --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  -v ~/project/nginx/certbot/www:/var/www/certbot \
  certbot/certbot renew --quiet && \
  docker exec blog-nginx nginx -s reload

Cron 표현식 설명

0 3 * * *
│ │ │ │ │
│ │ │ │ └── 요일 (0-7, 0과 7은 일요일)
│ │ │ └──── 월 (1-12)
│ │ └────── 일 (1-31)
│ └──────── 시 (0-23)
└────────── 분 (0-59)

= 매일 새벽 3시 0분에 실행

갱신 프로세스 상세

┌─────────────────────────────────────────────────────────────┐
│                    Cron Job (매일 03:00)                    │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│              certbot/certbot renew --quiet                  │
│                                                             │
│  1. 인증서 만료일 확인                                       │
│  2. 만료 30일 이내인 경우에만 갱신 시도                       │
│  3. ACME Challenge 수행                                     │
│     - /.well-known/acme-challenge/에 토큰 파일 생성         │
│     - Let's Encrypt 서버가 해당 파일 접근하여 검증           │
│  4. 새 인증서 발급 및 저장                                   │
└─────────────────────────┬───────────────────────────────────┘
                          │ 성공 시
                          ▼
┌─────────────────────────────────────────────────────────────┐
│            docker exec blog-nginx nginx -s reload           │
│                                                             │
│  - Nginx 프로세스 재시작 없이 설정 리로드                    │
│  - 새 인증서 즉시 적용                                       │
│  - 기존 연결 유지 (무중단)                                   │
└─────────────────────────────────────────────────────────────┘

왜 매일 실행하나요?

Let's Encrypt 인증서는 90일간 유효하며, Certbot은 만료 30일 이내인 경우에만 갱신을 시도합니다.

  • 매일 실행해도 불필요한 갱신은 발생하지 않음
  • 갱신 실패 시 재시도 기회 확보 (30일간 매일 시도 가능)
  • 서버 다운타임이나 네트워크 문제 대비

nginx -s reload vs restart

명령어 동작 다운타임
nginx -s reload 설정 리로드, 기존 연결 유지 없음
nginx restart 프로세스 재시작 수 초 발생

-s reload를 사용하면 무중단으로 새 인증서를 적용할 수 있습니다.


초기 설정 가이드

1단계: 디렉토리 생성

mkdir -p ~/project/nginx/ssl
mkdir -p ~/project/nginx/certbot/www

2단계: 임시 인증서로 Nginx 시작

Let's Encrypt 인증서 발급을 위해서는 먼저 Nginx가 실행되어 ACME Challenge를 서빙해야 합니다. 닭과 달걀 문제를 해결하기 위해 임시 자체 서명 인증서를 먼저 생성합니다.

# 임시 nginx 컨테이너로 certbot 챌린지 서빙
docker run -d --name temp-nginx \
  -p 80:80 \
  -v ~/project/nginx/certbot/www:/var/www/certbot:ro \
  nginx:alpine

3단계: Let's Encrypt 인증서 발급

docker run -it --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  -v ~/project/nginx/certbot/www:/var/www/certbot \
  certbot/certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  -d example.com \
  -d www.example.com \
  --email your-email@example.com \
  --agree-tos \
  --no-eff-email

옵션 설명:

  • --webroot: 웹 서버의 특정 디렉토리를 사용하여 도메인 검증
  • -d: 인증서에 포함할 도메인 (여러 개 지정 가능)
  • --agree-tos: Let's Encrypt 서비스 약관 동의
  • --no-eff-email: EFF 뉴스레터 구독 거부

4단계: 심볼릭 링크 생성

# 임시 nginx 제거
docker stop temp-nginx && docker rm temp-nginx

# 인증서 심볼릭 링크 생성
cd ~/project/nginx/ssl
ln -sf live/example.com/fullchain.pem fullchain.pem
ln -sf live/example.com/privkey.pem privkey.pem

왜 심볼릭 링크를 사용하나요?

Certbot은 인증서를 live/<domain>/ 디렉토리에 저장하고, 갱신 시 새 인증서를 archive/ 디렉토리에 저장한 후 live/의 심볼릭 링크를 업데이트합니다. Nginx 설정에서 심볼릭 링크를 참조하면 갱신 후에도 설정 변경 없이 새 인증서를 사용할 수 있습니다.

5단계: 프로덕션 서비스 시작

cd ~/project
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

6단계: Cron 등록

crontab -e
# 추가:
# 0 3 * * * docker run --rm -v ~/project/nginx/ssl:/etc/letsencrypt -v ~/project/nginx/certbot/www:/var/www/certbot certbot/certbot renew --quiet && docker exec blog-nginx nginx -s reload

문제 해결

인증서 상태 확인

# 인증서 만료일 및 상태 확인
docker run --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  certbot/certbot certificates

출력 예시:

Certificate Name: example.com
  Domains: example.com www.example.com
  Expiry Date: 2024-03-15 10:30:00+00:00 (VALID: 89 days)
  Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem
  Private Key Path: /etc/letsencrypt/live/example.com/privkey.pem

OpenSSL로 인증서 정보 확인

# 인증서 유효 기간 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -text -noout | grep -A2 "Validity"

# 인증서 발급자 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -issuer -noout

# 인증서에 포함된 도메인 확인
openssl x509 -in ~/project/nginx/ssl/fullchain.pem -text -noout | grep -A1 "Subject Alternative Name"

갱신 테스트 (Dry Run)

실제 갱신 없이 테스트만 수행:

docker run --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  -v ~/project/nginx/certbot/www:/var/www/certbot \
  certbot/certbot renew --dry-run

성공 시 출력:

Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/example.com/fullchain.pem (success)

일반적인 문제

문제 원인 해결 방법
Challenge 실패 포트 80 차단 방화벽에서 80 포트 허용
인증서 찾을 수 없음 심볼릭 링크 오류 ln -sf 명령으로 재생성
Nginx 리로드 실패 컨테이너 이름 불일치 docker ps로 정확한 이름 확인
Rate Limit 초과 너무 많은 발급 요청 1주일 대기 또는 스테이징 환경 사용
DNS 검증 실패 DNS 전파 지연 잠시 대기 후 재시도

Rate Limit 정보

Let's Encrypt Rate Limit:

  • 인증서 발급: 동일 도메인 조합에 대해 주당 5회
  • 갱신: 갱신은 Rate Limit에 포함되지 않음
  • 실패한 검증: 시간당 5회

Rate Limit 우회 (테스트용)

개발/테스트 시에는 스테이징 환경을 사용하세요:

docker run -it --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  -v ~/project/nginx/certbot/www:/var/www/certbot \
  certbot/certbot certonly \
  --webroot \
  --webroot-path=/var/www/certbot \
  -d example.com \
  --staging  # 스테이징 환경 (Rate Limit 완화, 신뢰되지 않는 인증서)

Cron 로그 확인

# Ubuntu/Debian
grep CRON /var/log/syslog

# CentOS/RHEL
grep CRON /var/log/cron

# systemd 기반 시스템
journalctl -u cron

수동으로 갱신 테스트

# 실제 갱신 실행 (verbose 모드)
docker run --rm \
  -v ~/project/nginx/ssl:/etc/letsencrypt \
  -v ~/project/nginx/certbot/www:/var/www/certbot \
  certbot/certbot renew -v

보안 고려사항

1. 인증서 파일 권한

# 인증서 디렉토리 권한 확인
ls -la ~/project/nginx/ssl/

# 권장 권한 설정
chmod 755 ~/project/nginx/ssl/
chmod 644 ~/project/nginx/ssl/fullchain.pem
chmod 600 ~/project/nginx/ssl/privkey.pem  # 개인키는 제한적 권한

2. HSTS Preload

HSTS를 더 강화하려면 preload 리스트에 등록할 수 있습니다:

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

hstspreload.org에서 등록할 수 있습니다.

3. SSL Labs 테스트

설정 후 SSL Labs에서 A+ 등급을 목표로 테스트하세요.


마무리

Docker 환경에서 Let's Encrypt SSL 인증서 자동 갱신은 다음 세 가지 요소로 구성됩니다:

  1. Nginx 설정: ACME Challenge 경로 노출 및 SSL 설정
  2. Certbot: Docker 컨테이너로 인증서 발급/갱신
  3. Cron: 주기적인 갱신 자동화

한 번 설정해두면 인증서 만료 걱정 없이 안정적으로 HTTPS 서비스를 운영할 수 있습니다.

체크리스트

  • Nginx에서 /.well-known/acme-challenge/ 경로 설정
  • SSL 인증서 경로 볼륨 마운트
  • 초기 인증서 발급 완료
  • 심볼릭 링크 생성
  • Cron job 등록
  • Dry-run 테스트 통과
  • SSL Labs에서 A+ 등급 확인

참고 자료

댓글