EMQX로 EC2에 MQTT 브로커 셋업하기

EC2(Amazon Linux 2023)에 Docker로 EMQX를 올리고, Let's Encrypt TLS 인증서를 적용해 MQTTS(8883)·WSS(8084)를 서빙하고, 인증서 자동 갱신까지 구성하는 전 과정.

환경: EC2 / Amazon Linux 2023, Docker, EMQX 5.8, 도메인 mqtt-broker.vaunce-app.cloud, certbot standalone(80포트), cron 없음(systemd timer 사용)

0. 전체 그림

핵심 설계 포인트 3가지:

  1. 인증서는 파일 경로 참조로 운영한다 (Dashboard에 붙여넣지 않는다). 그래야 자동 갱신이 물린다.
  2. 인증서 경로는 docker-compose 환경변수로 못 박는다 (Dashboard 설정과 충돌 방지).
  3. 갱신되면 deploy hook으로 EMQX를 reload해 새 인증서를 즉시 적용한다.

1. 사전 준비

  • 도메인이 EC2 퍼블릭 IP로 A레코드 연결되어 있을 것
  • 보안그룹에서 1883/8083/8084/8883/18083, 그리고 인증서 발급용 80(TCP) 인바운드 허용
  • Docker / docker-compose 설치
  • certbot 설치 (sudo dnf install -y certbot)

2. Let's Encrypt 인증서 발급 (RSA)

certbot standalone 방식. 발급 순간 80포트가 비어 있어야 한다.

# 80포트 점유 확인 (비어 있어야 함)
sudo ss -tlnp | grep ':80 '

# RSA 키로 발급
sudo certbot certonly \
  --standalone \
  --key-type rsa \
  --cert-name mqtt-broker.vaunce-app.cloud \
  -d mqtt-broker.vaunce-app.cloud

발급 결과 확인:

# 키가 RSA인지 (rsaEncryption 이어야 함)
sudo openssl x509 -in /etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/cert.pem \
  -noout -text | grep "Public Key Algorithm"

sudo certbot certificates

발급되면 인증서는 아래 경로에 생성된다:

/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/fullchain.pem
/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/privkey.pem
참고: live/의 파일은 archive/로 향하는 심볼릭 링크다. 그래서 컨테이너에는 archive/...1.pem을 직접 마운트하면 안 되고(갱신 시 번호가 올라가 옛 파일에 고정됨), /etc/letsencrypt 전체를 마운트해 링크가 따라가게 해야 한다.

3. docker-compose.yml

인증서 경로를 환경변수로 지정하고, /etc/letsencrypt를 통째로 마운트한다.

services:
  emqx:
    image: emqx/emqx:5.8.0
    container_name: emqx
    user: root
    restart: unless-stopped
    ports:
      - "1883:1883"
      - "8083:8083"
      - "8084:8084"
      - "8883:8883"
      - "18083:18083"
    environment:
      EMQX_ALLOW_ANONYMOUS: "false"
      EMQX_DASHBOARD__LISTENERS__HTTP__BIND: "18083"

      # TCP (1883)
      EMQX_LISTENERS__TCP__DEFAULT__ENABLED: "true"
      EMQX_LISTENERS__TCP__DEFAULT__BIND: "0.0.0.0:1883"

      # WS (8083)
      EMQX_LISTENERS__WS__DEFAULT__ENABLED: "true"
      EMQX_LISTENERS__WS__DEFAULT__BIND: "0.0.0.0:8083"

      # SSL / MQTTS (8883)
      EMQX_LISTENERS__SSL__DEFAULT__ENABLED: "true"
      EMQX_LISTENERS__SSL__DEFAULT__BIND: "0.0.0.0:8883"
      EMQX_LISTENERS__SSL__DEFAULT__SSL_OPTIONS__CERTFILE: "/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/fullchain.pem"
      EMQX_LISTENERS__SSL__DEFAULT__SSL_OPTIONS__KEYFILE: "/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/privkey.pem"

      # WSS (8084)
      EMQX_LISTENERS__WSS__DEFAULT__ENABLED: "true"
      EMQX_LISTENERS__WSS__DEFAULT__BIND: "0.0.0.0:8084"
      EMQX_LISTENERS__WSS__DEFAULT__SSL_OPTIONS__CERTFILE: "/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/fullchain.pem"
      EMQX_LISTENERS__WSS__DEFAULT__SSL_OPTIONS__KEYFILE: "/etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/privkey.pem"
    volumes:
      - ./data:/opt/emqx/data
      - ./log:/opt/emqx/log
      - /etc/letsencrypt:/etc/letsencrypt:ro

기동:

cd ~/emqx
docker compose up -d
sleep 5
docker exec emqx emqx ctl listeners

ssl:default(8883), wss:default(8084)가 running: true면 정상.

주의: Dashboard(18083)에서 리스너 인증서를 "텍스트 붙여넣기"로 등록하면 EMQX가 data/certs/listeners/에 복사본을 저장하고, 이 설정이 data/configs/cluster.hocon에 저장되어 환경변수보다 우선 적용된다. 자동 갱신이 무의미해지므로, 붙여넣기 방식은 쓰지 말고 위처럼 환경변수 경로 지정으로 통일한다. 이미 붙여넣은 적이 있다면 cluster.hocon의 listeners 블록을 제거해야 환경변수가 먹는다.

4. 인증서/체인 검증

# 컨테이너 안에서 인증서 파일이 보이는지 (-L 로 심볼릭 링크 따라감)
docker exec emqx ls -lL /etc/letsencrypt/live/mqtt-broker.vaunce-app.cloud/

# 8883이 내려주는 인증서 (키 알고리즘 + 만료일)
echo | openssl s_client -connect mqtt-broker.vaunce-app.cloud:8883 2>/dev/null \
  | openssl x509 -noout -text | grep "Public Key Algorithm"

echo | openssl s_client -connect mqtt-broker.vaunce-app.cloud:8883 2>/dev/null \
  | openssl x509 -noout -dates -issuer

Public Key Algorithm: rsaEncryption이면 RSA 인증서 서빙 중.

TIP: 임베디드/구형 TLS 스택(일부 게이트웨이)은 ECDSA 인증서나 신규 루트를 신뢰하지 못할 수 있다. 클라이언트가 핸드셰이크에 실패하면 RSA 키로 발급했는지, 클라이언트가 신뢰하는 루트 체인인지 확인할 것. 대규모 전환 전 1대로 핸드셰이크+CONNACK를 먼저 테스트(카나리)하는 것을 권장.

5. 자동 갱신 — deploy hook

갱신이 성공하면 EMQX가 새 인증서를 다시 읽도록 reload해야 한다. renewal-hooks/deploy/의 스크립트는 실제 갱신 성공 시에만 실행된다.

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-emqx.sh > /dev/null <<'EOF'
#!/bin/sh
# 갱신 성공 시 ssl/wss 리스너 재시작 → 새 인증서 로드
docker exec emqx emqx ctl listeners restart ssl:default
docker exec emqx emqx ctl listeners restart wss:default
echo "[deploy-hook] EMQX ssl/wss restarted at $(date)" >> /var/log/emqx-cert-renew.log
EOF

sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-emqx.sh
주의: emqx ctl listeners restart는 리스너 식별자(ssl:default 등)를 반드시 줘야 한다. 인자 없이 listeners restart만 쓰면 명령이 실행되지 않고 도움말만 출력된다. 식별자 방식이 환경에서 동작하지 않으면 docker restart emqx로 대체 가능.

6. 자동 갱신 — systemd 타이머

이 서버에는 cron이 없으므로 systemd timer를 사용한다. (certbot 패키지가 이미 certbot-renew.timer를 깔았다면 그걸 활성화만 하면 된다.)

기존 타이머 확인:

systemctl list-timers --all | grep -i certbot

없으면 직접 생성 (ExecStart 경로는 which certbot 결과로 맞출 것):

sudo tee /etc/systemd/system/certbot-renew.service > /dev/null <<'EOF'
[Unit]
Description=Certbot Renewal
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
EOF

sudo tee /etc/systemd/system/certbot-renew.timer > /dev/null <<'EOF'
[Unit]
Description=Run certbot twice daily

[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now certbot-renew.timer

# 다음 실행 예정 확인
systemctl list-timers | grep certbot

7. 갱신 검증

# 1) 실제 갱신 없이 흐름만 시뮬레이션
sudo certbot renew --dry-run

# 2) (선택, 발급 한도 소모 — 1회만) 강제 갱신으로 hook까지 실증
sudo certbot renew --force-renewal
sudo cat /var/log/emqx-cert-renew.log
echo | openssl s_client -connect mqtt-broker.vaunce-app.cloud:8883 2>/dev/null \
  | openssl x509 -noout -dates

dry-run이 Congratulations, all simulated renewals succeeded면 갱신 경로 정상. 강제 갱신 후 notBefore가 방금 시각으로 바뀌면 EMQX가 새 인증서를 적용한 것.

--force-renewal은 Let's Encrypt 발급 한도(도메인별 주간 제한)를 소모하므로 테스트로 남발하지 말 것.

8. 클라이언트 연결 정보

JavaScript (WSS):

import mqtt from 'mqtt';
const client = mqtt.connect('wss://mqtt-broker.vaunce-app.cloud:8084/mqtt', {
  username: '...', password: '...',
  protocolVersion: 5, clean: true, keepalive: 60,
});

CLI 테스트 (MQTTS):

mosquitto_sub -h mqtt-broker.vaunce-app.cloud -p 8883 \
  -u USER -P PASS -t test/loadtest --tls-use-os-certs -d

동작 흐름 (완료 후)

  1. systemd timer가 하루 2회 certbot renew 실행
  2. 만료 30일 이내면 실제 갱신, 그 외엔 즉시 skip
  3. 갱신 성공 → deploy hook이 EMQX ssl/wss 리스너 재시작
  4. EMQX가 새 인증서 로드 → 클라이언트는 동일 체인이라 끊김 없이 유지

트러블슈팅

  • 갱신은 됐는데 EMQX가 옛 인증서 → deploy hook 미실행 또는 식별자 누락. hook의 listeners restart 뒤에 ssl:default·wss:default가 있는지 확인, 임시로 docker restart emqx.
  • dry-run "address already in use" → 80포트 점유. standalone은 갱신 순간 80을 쓰므로, 80 상시 사용 서비스가 있으면 pre/post hook으로 잠깐 정지 처리.
  • 클라이언트 핸드셰이크 실패 → 인증서 키 종류(RSA/ECDSA)·루트 체인을 클라이언트가 신뢰하는지 확인. 1대 카나리 테스트로 먼저 검증.
  • "too many certificates already issued" → force-renewal 남발. 약 1주 대기.
  • Dashboard 붙여넣기 인증서가 환경변수를 덮어씀 → data/configs/cluster.hocon의 listeners 블록 제거 후 재시작.

체크리스트