Skip to content

인증서. 명령어 한방으로 관리하자 - Docker, Let's Encrypt

이 글의 목적

해당 글은 docker와 certbot를 활용하여 SSL인증서를 간편하게 발급하고 갱신하는 방법에 대해 다룹니다.

실습을 진행하기 위해선 최소 공인 ip가 할당되어 있는 서버가 필요합니다.

도메인 주소는 선택사항입니다.

자동화 소스코드는 github 레포지토리에 공개되어 있습니다.

글 요약

SSL 인증서 발급 간편하게 발급 받는 방법

  1. docker 설치

  2. 발급 자동화 스크립트 를 clone한다.

  3. nginx-for-cert 디렉터리에 있는 run.sh도메인명 이메일 주소를 넣어 실행한다.

    ./run.sh [domain.com] youremail@mail.com
    
    1. 사전 작업 : 구입한 도메인 공인 IP와 매핑되어 있어야 함

    2. 구입한 도메인이 없다면 nip.io 를 이용해서 발급하는 방법도 있음

      실행 예시: ./run.sh 1.2.3.4.nip.io example@email.com

  4. certifications에서 발급한 인증서 확인

인증서 관리의 불편함 인식

현재 운영하고 있는 '총대마켓' 프로젝트의 인프라는 아래와 같이 설계되어 있습니다. HTTPS적용을 각 서버 용도별로 각각 설정해주면 관리가 불편하여 현재는 개발, 운영서버 모두 리버스 프록시 서버에서 SSL을 통합해서 관리하고 있습니다.

Image title

이 구조의 장점은 클라이언트는 https 요청을 그대로 사용할 수 있으며 WAS도 별도의 SSL 설정 없이 붙일 수 있어 개발자는 https 환경을 별도로 고려하지 않아도 되는 장점이 있었습니다. 그러나 리버스 프록시 서버는 이중화되어 있지 않기 때문에 단일 실패 지점이 될 수 있는 위험이 존재하게 됩니다. 현재는 프로젝트가 소규모이기 때문에 해당 구조를 유지하고 있습니다.

프로젝트가 끝나고 Let's Encrypt에서 발급 받은 무료 SSL 인증서가 만료가 도래되었다는 메일을 받고나서 재발급을 해야 하는데, 이전에 AWS로 운영하던 프로젝트에서 가져온 인증서로 마이그레이션을 했기 때문에 자동화 설정이 적용되지 않아 수동으로 발급하는 상황이 왔습니다.

자동화를 하기 위해선 80 포트를 오픈해야 했었는데 이는 SSL Stripping1공격 위협이 있기 때문에 적용하지 않았습니다.

하지만 공격하는 방식이 까다롭기 때문에 무시하고 80 포트를 열수도 있었지만 하인리히 법칙처럼 작은 문제점들이 나중에 이어져 큰 사고가 날 수 있기 때문에 보수적으로 생각했습니다.

자동화의 필요성을 느끼게 된건 온프레미스 환경에서 하나의 공인 IP로 여러 서버를 운영해야 했기 때문입니다. 따라서 인증서도 그만큼 많이 필요하게 되었습니다.

또한 이미지 서버 접근 시 SSL을 적용해야 했고, 개발(애플리케이션 + 이미지) 및 운영(애플리케이션 + 이미지) 환경을 포함해 총 4개의 인증서를 수동으로 관리해야 했기 때문에 상당한 불편함이 있었습니다.

그리고 얼마 후 팀 프로젝트가 마무리 된 시점에서 위와 같은 단점과 위협의 영향도를 최소화 하는 방법을 찾게 되어 이를 공유하고자 합니다.

Let's Encrypt 인증서

Let's Encrypt는 SSL 인증서를 무료로 발급해줄 수 있는 기관입니다. 그러나 인증서의 유효기간이 짧아(90일) 계속 재갱신 해야하는 불편함이 있었습니다.

Let's Encrypt로 발급하지 않고 인증서를 구매하여 관리하는 방법들이 있습니다. yesnic 가비아 심지어 AWS의 ACM 을 활용하여 발급과 갱신까지 관리할 수 있지만 이는 소규모프로젝트를 하기엔 적지않은 비용이 들어갔습니다.

따라서 소규모 프로젝트에 적합하고 HTTPS를 문제없이 적용할 수 있는 Let's Encrypt에서 발급하여 관리했습니다.

Let's Encrypt 클라이언트(certbot) 설치

Let's Encrypt 인증서를 발급하기 위해선 클라이언트 프로그램인 certbot을 설치해야 합니다. 설치하는 방법은 이 글의 주제가 아니니 생략합니다.

Let's Encrypt 발급 방식

1. dns 방식

dns 방식은 도메인(DNS) 서버를 이용해 발급하는 방법입니다. 아래의 그림과 같이 let's encrypt 측에서 랜덤으로 발급한 TXT 레코드를 설정한 후 요청해서 해당 도메인 주소를 가지고 있음을 검증하고 발급하는 방식입니다.

Image title

이 방법은 DNS 서버만 접근 가능하면 공인 IP가 있는 서버 없이 개발자 노트북으로 인증서를 쉽게 발급할 수 있는 장점이 있습니다.

하지만 dns방식으로 생성한 인증서는 Route53이나 CloudFlare를 활용하지 않은 이상 재갱신을 할 수 없었습니다.

프로젝트를 한창 진행하고 있을 때는 최소한의 인프라 관리로 해당 방식을 사용했습니다.

발급 기간도 프로젝트 기간동안 재갱신이 필요 없을 것으로 판단했습니다. 또한 AWS환경을 사용하고 있어 개발 서버와 운영 서버의 IP 발급이 자유로웠습니다.

인증서는 운영 서버(chongdae.site)만 발급하면 되기 때문에 webroot로 할 필요성은 없었습니다.

2. webroot 방식

webroot방식은 도메인을 설정한 서버가 존재하는지 검증하여 인증서를 발급하는 방법입니다.

도메인의 소유주가 공인 IP를 설정할 수 있기 때문에 Let's Encrypt는 해당 서버에 발급 대상 도메인에 직접 가능한지 확인하면 인증서를 생성합니다.

그러나 가짜 도메인을 생성하고 SSL을 발급하는 경우를 방지하기 위해 http 서버의 특정 디렉터리에 예측 불가능한 랜덤 값이 적힌 파일을 생성하여 이 값을 검증합니다.

랜덤 값 기입은 웹서버에 설치되어 있는 certbot을 활용하여 웹 서버의 특정 디렉터리에 파일을 생성합니다.

Image title

이 방식의 장점은 자동 재갱신하기 쉽습니다. 인증서를 한 번 발급하면 certbot renew명령어를 입력하여 알아서 발급해주기 때문입니다.

그러나 해당 방식을 사용하면 기존에 사용하고 있는 80포트 프로세스를 중지해야 하고 방화벽도 예외 처리해야 하는 단점이 존재합니다.

공격 실현 가능성은 낮지만 http통신을 하기 때문에 앞서 설명드린 SSL Stripping 공격에 노출 될 수 있습니다.

AWS 그렇다면 webroot방식을 활용하면서 웹 서버를 안전하게 관리하려면 어떻게 해야할까요?

인프라 구조 고민

1. 기존 리버스 프록시에 certbot 설정 추가

아래의 그림처럼 리버스 프록시 서버에 80포트를 할당하여 webroot를 사용하기 위한 설정을 추가할 수 있습니다.

Image title

이렇게 하면 설정파일 하나만 관리할 수 있고 단순한 구조를 유지할 수 있습니다.

그러나 인증서 재발급만을 위해 80포트를 점유하며 어플리케이션에서 처리하는 패킷이 해당 서버에 지나가기 때문에 책임이 합처져 버려 결합도가 증가하게 됩니다.

따라서 해당 방법은 사용하지 않았습니다.

2. 인증서 갱신용 컨테이너를 새로 띄움(채택)

다음은 이전의 1단계와 비슷해 보이지만 nginx 컨테이너를 2개를 운영하는 방식으로 변경된 버전입니다.

Image title

이 구조는 웹 서비스용 nginx와 인증서 갱신용 nginx 설정을 별도로 관리할 수 있기 때문에 확장이 가능합니다.

또한 컨테이너가 분리되어 있기 때문에 서로의 환경이 각각 격리되어 있음을 보장하고 인증서가 필요할때만 80포트를 열 수 있습니다.

그러나 앞서 말한 80포트를 점유하는 문제와 웹 서비스에 부하가 발생하면 인증서 갱신 서버도 같이 영향 받는 단점이 있습니다.

반대로 인증서 갱신 서버에 부하가 발생하면 웹 서비스에도 영향을 미칠 수 있습니다.

3. 인증서 갱신용 서버를 별도 생성

마지막으로 생각했던 방법은 인증서 서버를 새로 띄어 앞단의 방화벽의 포트포워딩을 이용하여 서버 수준으로 격리하는 방법을 생각했습니다.

Image title

이 구조를 채택하면 리버스 프록시 서버의 부하와 상관없이 인증서 서버를 통해 인증서를 갱신할 수 있습니다. 하지만 소규모 프로젝트로 쓰기에는 과한 분리라고 생각했습니다.

개발 서버가 사설 네트워크를 통해서만 접속할 수 있는 복잡한 환경도 아니었고, NFS를 통해 인증서를 공유해야 하는 관리 포인트가 하나 더 생기게 됩니다.

따라서 해당 방법은 떠올랐지만 채택하지 않았습니다.

docker를 활용한 certbot 자동화

인증서 발급

docker를 활용해 인증서를 자동 발급해주는 스크립트를 작성하였습니다. 사용방법은 아래와 같습니다.

chmod u+x run.sh
./run.sh [도메인 ] [이메일주소]

이 스크립트를 작성하기 위해 몇가지 고려사항이 있었습니다. 이미 리버스 프록시용 컨테이너를 docker compose로 관리하고 있기 때문에 인증서 컨테이너를 종료하기 위해 docker compose stop명령어를 실행하게 되면 리버스 프록시가 종료되는 문제가 있었습니다.

docker compose를 프로젝트 단위로 실행하도록 설정을 추가하면 certbot과 관련된 컨테이너만 종료시킬 수 있습니다. run.sh안에는 project명을 명령 실행 일자와 추가해서 관리하고 있습니다.

run.sh
#!/bin/bash

set -e

if [ -z "$1" ] || [ -z "$2" ]; then
  echo "Usage: ./run.sh [yourdomain.com] [youremail@example.com]"
  exit 1
fi

DOMAIN="$1"
EMAIL="$2"
CURRENT_DATE=$(date +"%y%m%d")
PROJECT_NAME="certbot_$CURRENT_DATE"

sed "s/DOMAIN_HERE/${DOMAIN}/g" ./nginx/conf.d/default.conf.template > ./nginx/conf.d/default.conf

docker compose -p "$PROJECT_NAME" up -d nginx_for_cert

docker compose -p "$PROJECT_NAME" \
    run --rm certbot certonly --webroot -w /var/www/html -d "$DOMAIN" --agree-tos --email "$EMAIL" --non-interactive

docker compose -p "$PROJECT_NAME" down
rm -rf ./nginx/conf.d/default.conf

docker-compose.yml은 certbot과 nginx를 연결하기 위해 certbot-webroot를 마운트 합니다. 발급이 성공하면 certifications에 인증서가 저장되는 구조입니다.

docker-compose.yml
version: '3.8'

services:
  nginx_for_cert:
    image: nginx
    container_name: nginx_for_cert
    ports:
      - "80:80"
    volumes:
      - ./certbot-webroot:/var/www/html
      - ./nginx/conf.d:/etc/nginx/conf.d

  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot-webroot:/var/www/html
      - ../certifications:/etc/letsencrypt

인증서 적용

이제 리버스 프록시에 쓸 인증서 폴더와 nginx를 띄울 docker-compose.yml 파일을 작성합니다. 인증서는 내용이 컨테이너에서 훼손됨을 방지하기 위해 ro 옵션을 추가로 주었습니다.

docker-compose.yml
services:
  nginx_ssl:
    image: nginx:latest
    container_name: nginx_ssl
    ports:
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ../certifications/live:/etc/letsencrypt/live:ro
      - ../certifications/archive:/etc/letsencrypt/archive:ro

인증서 갱신

인증서 갱신은 간단합니다. certbot 도커이미지와 이전에 발급 받은 인증서 폴더와 연결하면 바로 재갱신이 가능합니다. renew 명령어만 있어도 되지만 확실히 갱신하기 위해 --force-renewal을 추가로 적용했습니다.

docker-compose.yml
1
2
3
4
5
6
services:
  certbot:
    image: certbot/certbot
    volumes:
      - ../certifications:/etc/letsencrypt
    command: ["renew", "--force-renewal"]

재갱신 뒤에는 자동으로 nginx에 적용하기 위해 예약 작업을 설정하기 위한 스크립트를 작성합니다. 인증서 디렉터리는 각 서버마다 다르기 때문에 상대경로로 설정해주었습니다. crontab으로 스크립트를 실행하게 되면 작업 디렉터리가 어디인지 불분명하기 때문에 스크립트 위치 기준으로 작업 디렉터리를 이동하는 코드를 추가했습니다.

run.sh
#!/bin/bash

ORIGINAL_DIR="$(pwd)"

ABSOLUTE_SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

cd "${ABSOLUTE_SCRIPT_DIR}" || exit 1

set -e

if [ -z "$1" ]; then
  echo "Usage: ./run.sh [applied_ssl_nginx_container_name]"
  exit 1
fi

CONTAINER_NAME="$1"
CERTIFICATIONS_PATH="../certifications"

echo "[+] Renew certificate"
docker compose run certbot

echo "[+] apply new certificate"
docker exec $CONTAINER_NAME nginx -s reload

echo "change all certification permission"
find "$CERTIFICATIONS_PATH" -type f -exec chmod 400 {} \;

cd "${ORIGINAL_DIR}"

이제 아래와 같이 기입하면 아래와 같이 예약 작업을 등록할 수 있습니다.

crontab -e
0 3 * * * /재갱신/스크립트/절대경로/run.sh 

결론

어떻게 수동으로 작업한 인증서 발급과 갱신을 자동화 할 수 있을지, 재활용 하기 위해 스크립트를 어떻게 설계할지, 만약 80포트를 한 서버에서 점유하고 있으면 어떤 인프라 구조를 채택해야하는지 등 고민해야하는 포인트가 많았습니다.

이 글에는 없지만 정말로 SSL Stripping 공격이 가능한 취약점이 있음을 가정할 때 웹 서버엔 어떻게 조치해야할지 고민도 해봤습니다.

리버스 프록시 서버에 HSTS(HTTP Strict Transport Security)2 를 적용하면 해결가능하나, 현재 SSL 도메인을 받기 위한 webroot방식이 HSTS 헤더가 적용되어 80포트로 통신하지 못하면 어떻게 해야하지? 고민이 추가로 나오게 되는 문제였습니다.

위의 우려가 실제 생기는지 직접 테스트해가면서 작업을 진행하니 예상보다 작업속도가 늦어졌습니다.(webroot방식은 다행히 HSTS 설정과 무관하게 동작했습니다.) 그러나 이런 테스트를 반복적으로 하면서 자신감은 생기는 것 같습니다.



  1. 공격자가 같은 도메인을 대상으로 http로 통신하도록 링크를 숨겨서 암호화되지 않은 통신으로 변경한 후 패킷을 감청하는 공격. 공격자는 공격대상의 패킷을 감청할 수 있는 네트워크 위치에 있어야 함 

  2. HTTP 헤더 속성 값 Strict-Transport-Security에 기입된 max-age 기간동안 80포트로 요청하는 HTTP를 내부에서 443 포트로 통신하도록 전달하는 속성입니다. 이를 설정하면 클라이언트(브라우저)가 443으로 리디렉션하는 것이 아닌 처음부터 HTTPS로 요청할 수 있도록 요청값을 보냅니다. 

Comments