DuckDNS 와일드카드 인증서 발급 + Docker Postgres 권한 문제 해결기

Proxmox VM(Ubuntu 24)에서 개인 서버를 운영하면서 겪은 세 가지 트러블슈팅을 기록해둔다.

1. DuckDNS 와일드카드 인증서 발급

배경

기존에 쓰던 apex 도메인(example.duckdns.org)은 이미 여러 nginx 설정에서 광범위하게 사용 중이었다. 여기에 와일드카드 인증서를 추가로 발급하려다 보니, DuckDNS의 근본적인 제약을 다시 마주쳤다.

DuckDNS는 서브도메인당 TXT 레코드를 1개만 저장할 수 있다.

apex와 와일드카드를 같은 인증서에 넣으면 둘 다 _acme-challenge.example.duckdns.org라는 같은 이름에 TXT 값을 요구하는데, 값이 충돌해서 발급이 실패한다. 갱신(certbot renew) 시점에도 동일한 문제가 재발한다.

해결책은 명확하다: 처음부터 apex와 와일드카드를 완전히 별도의 인증서로 발급한다. 이번엔 아예 새로운 서브도메인(newname.duckdns.org)을 하나 더 만들어서 거기에 와일드카드 인증서를 단독으로 발급하는 방식을 택했다.

작업 순서

certbot이 DuckDNS의 DNS-01 challenge를 처리할 수 있도록 전용 플러그인을 설치한다.

certbot plugins   # dns-duckdns 존재 여부 확인

Ubuntu 24는 pip 시스템 설치를 기본적으로 막아놓기 때문에(PEP 668, externally-managed-environment), 옵션을 하나 추가해야 한다.

sudo pip3 install certbot-dns-duckdns --break-system-packages

와일드카드 인증서를 apex와 완전히 분리해서 단독 발급한다.

sudo certbot certonly \
  --non-interactive --agree-tos \
  --email [email protected] \
  --preferred-challenges dns \
  --authenticator dns-duckdns \
  --dns-duckdns-token <DUCKDNS_TOKEN> \
  --dns-duckdns-propagation-seconds 30 \
  -d "*.newname.duckdns.org"

발급 후 반드시 갱신 시뮬레이션까지 확인한다. apex와 와일드카드가 서로 다른 인증서로 완전히 분리되어 있으면 아래 명령이 문제 없이 통과해야 한다.

sudo certbot renew --dry-run

실제로 두 인증서 모두 에러 없이 통과하는 것을 확인했고, certbot.timer가 12시간 주기로 자동 갱신을 처리하고 있다는 것도 확인했다.

핵심 교훈

DuckDNS + certbot 조합으로 와일드카드 인증서를 쓸 계획이라면, apex 도메인과는 처음부터 아예 다른 서브도메인으로 분리하는 게 가장 깔끔하다. 나중에 통합하려다 renew 단계에서 다시 충돌하는 것보다 훨씬 낫다.

2. DuckDNS IP 갱신 스크립트에 도메인 추가

기존에 크론으로 10분마다 도메인 IP를 갱신하던 스크립트가 있었는데, 새로 추가한 서브도메인도 같이 갱신하도록 확장했다. 계정이 다르면 토큰도 다르므로, 도메인별로 배열과 토큰을 분리해서 관리했다.

#!/bin/bash

DOMAINS_A=("domain-a")
TOKEN_A="<token-a>"

DOMAINS_B=("domain-b")
TOKEN_B="<token-b>"

for DOMAIN in "${DOMAINS_A[@]}"
do
    curl -s "https://www.duckdns.org/update?domains=${DOMAIN}&token=${TOKEN_A}&ip="
done

for DOMAIN in "${DOMAINS_B[@]}"
do
    curl -s "https://www.duckdns.org/update?domains=${DOMAIN}&token=${TOKEN_B}&ip="
done

수동 실행 시 응답이 OK가 붙어서(OKOK) 나오면 두 도메인 모두 정상 갱신된 것이다. 참고로 이 스크립트가 root의 crontab에 등록되어 있었는데, 이런 개인용 스크립트는 가급적 전용 계정의 crontab으로 옮기는 게 권한 관리 측면에서 더 안전하다.

3. Docker Postgres 컨테이너 permission denied

증상

Postgres에 의존하는 애플리케이션 컨테이너가 계속 재시작을 반복했다. 로그를 보니:

서버 시작 실패: error: could not open file "global/pg_filenode.map": Permission denied

pg_filenode.map은 PostgreSQL이 내부적으로 테이블/인덱스 매핑에 쓰는 핵심 카탈로그 파일이다. 애플리케이션 코드 문제가 아니라 Postgres 자신이 자기 데이터 파일을 못 읽는 상황이었다.

원인

Postgres 데이터 디렉토리는 호스트에 바인드 마운트되어 있었다.

docker inspect postgres-db | grep -A 5 Mounts
"Source": "/path/to/pgdata",
"Destination": "/var/lib/postgresql/data",

그런데 이 디렉토리의 실제 소유자를 확인해보니 호스트의 일반 사용자 계정이었고, 컨테이너 안 Postgres 프로세스가 실제로 사용하는 UID와 일치하지 않았다.

docker exec postgres-db id postgres
# uid=70(postgres) gid=70(postgres)

postgres:16-alpine 이미지의 postgres 계정은 UID 70을 쓴다. (debian 기반 이미지는 보통 999를 쓴다.) 호스트 디렉토리 소유자가 이와 다르면, 권한이 아무리 열려 있어도 컨테이너 안 프로세스 입장에서는 접근 불가능한 파일이 되어버린다. Postgres 데이터 디렉토리는 보안상 700 권한을 강제하기 때문에, 소유자가 다르면 그냥 접근 자체가 막힌다.

해결

docker stop postgres-db
sudo chown -R 70:70 /path/to/pgdata
docker start postgres-db
docker logs postgres-db --tail 30

database system is ready to accept connections 로그가 뜨면 정상화된 것이다.

여기서 한 번 더 걸림돌

작업 도중 홈 디렉토리 상위 경로에 재귀적으로 chown -R $USER:$USER를 돌렸는데, 이게 하위에 있던 pgdata 디렉토리 소유권까지 전부 일반 사용자로 다시 덮어써버려서 같은 문제가 재발했다. 상위 디렉토리에 재귀 chown을 걸 때는 컨테이너 내부 UID로 맞춰놓은 하위 디렉토리를 반드시 제외해야 한다는 걸 다시 한번 확인했다.

핵심 교훈

Docker 볼륨을 바인드 마운트로 쓸 때는, 호스트 디렉토리 소유권을 컨테이너 안 프로세스가 실제로 쓰는 UID에 맞춰야 한다. 이미지마다 UID가 다르므로(id <user> 등으로 확인) 무작정 현재 로그인 계정으로 맞추면 안 된다.

마무리

세 가지 문제 모두 “컨테이너/서비스가 기대하는 소유권·권한 구조”와 “호스트에서 실제로 설정된 구조”가 어긋나서 생긴 문제였다. 특히 Docker 환경에서는 컨테이너 내부 UID/GID를 확인하는 습관이 트러블슈팅 시간을 크게 줄여준다.