nsupdate.info 0.13.0 Docker Compose 部署实战
基于官方源码的 nsupdate.info 0.13.0 Compose 四件套部署实录:Django/gunicorn+PostgreSQL+BIND+nginx,端到端 DDNS 更新与 dig 验证全通过,完整保留三次构建失败与两次 SERVFAIL 排障实录。
nsupdate.info 0.13.0 Docker Compose 部署实战
目标:本文以官方源码为底、Docker Compose 为编排手段,从零把 nsupdate.info 0.13.0 完整跑通——Django 应用(gunicorn)、PostgreSQL、BIND 权威 DNS、nginx 反代四件套,最终完成"建域名 → 建 host → API 上报 → DNS 记录变化"的端到端验证。文中所有命令与回显均来自真实部署过程,三次构建失败与两次 SERVFAIL 的排障实录完整保留。
一、环境信息
本次部署的宿主机与软件环境:
| 项目 | 值 |
|---|---|
| 宿主系统 | Ubuntu 22.04(x86_64,6 核 / 19GiB 内存) |
| Docker | 29.9.2(Docker Compose v5.6.1 插件) |
| nsupdate.info | 0.13.0(官方仓库 git clone --branch 0.13.0 --depth 1 快照) |
| 应用基础镜像 | python:3.12-slim(构建产物 nsupdate-0.13.0-app,631MB) |
| 数据库镜像 | postgres:16-alpine |
| 权威 DNS 镜像 | internetsystemsconsortium/bind9:9.18(ISC 官方) |
| 反代镜像 | nginx:alpine |
| 栈网络 | 自定义 bridge dns-net,显式子网 10.89.61.0/24 |
| 宿主端口 | 8138 → nginx 80(Web),8139 → BIND 53(DNS) |
| 实验域名 | nsupdate.test(Zone)+ home.nsupdate.test(动态主机) |
背景认知见 《nsupdate.info 介绍与选型指南》,参数逐项含义见 《nsupdate.info 0.13.0 配置参数详解》,命令级运维见 《nsupdate.info 0.13.0 常用命令与运维实战》。
💡 说明:nsupdate.info 官方未发布生产用容器镜像——仓库
scripts/docker/下的 Dockerfile 仅用于 CI 自测(内嵌 named -g + pytest)。因此 Compose 部署的应用容器需基于官方源码自行构建,这正是本文的展开方式。
二、前置条件
-
宿主机已安装 Docker 与 Compose 插件(
docker compose version可用); -
端口 8138/8139 未被占用(
ss -ltn确认),或改用你自己的空闲端口; -
取得 0.13.0 源码快照:
git clone --branch 0.13.0 --depth 1 https://github.com/nsupdate-info/nsupdate.info.git -
规划一个不与现有 Docker 网络冲突的子网(本环境先
docker network ls查已占 10.89.x.0/24 段,再显式声明空闲的 10.89.61.0/24)。
三、部署拓扑与文件清单
最终拓扑(数据流自上而下):
宿主机 8138 ──► nginx(web 容器) ──► gunicorn(app 容器:8000) ──► PostgreSQL(db 容器:5432)
│
宿主机 8139 ──────────────────────────► BIND(bind 容器:53) ◄── RFC 2136 TSIG 动态更新
│ (app 经 dnspython 发起)
zone 文件(/var/lib/bind)
Compose 工程目录(应用源码 app/src/ 为官方仓库快照,篇幅所限图中省略):
nsupdate-compose/
├── docker-compose.yml # 四服务编排
├── app/
│ ├── Dockerfile # 基于 python:3.12-slim 构建官方源码
│ └── local_settings.py # 本地配置(挂载进容器)
├── bind/
│ ├── named.conf # BIND 主配置(含 zone/update-policy)
│ └── zones/
│ └── nsupdate.test.zone # 初始 zone 文件
└── nginx/
└── default.conf # 反代 + 静态文件配置
四、部署文件详解
4.1 应用镜像 Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# python:3.12-slim 缺 glibc 头文件,psycopg2 源码编译需要(stdlib.h 报错)
RUN apt-get update \
&& apt-get install -y --no-install-recommends libc6-dev \
&& rm -rf /var/lib/apt/lists/*
# 官方仓库 0.13.0 tag 快照(git clone --branch 0.13.0 --depth 1 所得)
COPY src/ /app/
RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NSUPDATE=0.13.0 pip install --no-cache-dir -r requirements.d/prod.txt \
&& SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NSUPDATE=0.13.0 pip install --no-cache-dir -e .
要点(两处环境变量都来自实战踩坑,见第九章 Q1/Q2):
gcc + libpq-dev + libc6-dev:官方requirements.d/prod.txt中的 psycopg2 是源码编译安装,需要完整编译工具链;SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NSUPDATE=0.13.0:pip install -e .依赖 setuptools-scm 从 git 元数据推导版本,而镜像内无 git 命令,用该环境变量显式声明版本(setuptools-scm 官方文档的推荐逃生口);- 依赖清单原样使用官方
requirements.d/prod.txt(级联 all.txt:django 5.2/dnspython/netaddr 等,实测安装 psycopg2 2.9.13、gunicorn 26.2.0、Django 5.2.18)。
4.2 应用本地配置 local_settings.py
完整文件(生产各键含义见配置参数详解篇):
from nsupdate.settings.prod import *
DEBUG = False
BASEDOMAIN = 'nsupdate.test'
WWW_HOST = 'www.' + BASEDOMAIN
WWW_IPV4_HOST = 'ipv4.' + BASEDOMAIN
WWW_IPV6_HOST = 'ipv6.' + BASEDOMAIN
ALLOWED_HOSTS = ['localhost', '127.0.0.1', WWW_HOST,
'ns1.' + BASEDOMAIN, 'ipv4.' + BASEDOMAIN, 'ipv6.' + BASEDOMAIN]
WE_HAVE_TLS = False
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
TIME_ZONE = 'Asia/Shanghai'
SERVICE_CONTACT = 'admin@nsupdate.test'
DEFAULT_FROM_EMAIL = 'nsupdate@nsupdate.test'
NAMESERVERS = ['10.89.61.53']
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'nsupdate',
'USER': 'nsupdate',
'PASSWORD': 'q1w2e3r4',
'HOST': 'db',
'PORT': '5432',
}
}
STATIC_ROOT = '/app/htdocs/static'
⚠️ 注意:
NAMESERVERS指向 BIND 容器的固定 IP(10.89.61.53),DATABASES的 HOST 指向 db 服务名——Compose 网络内置 DNS 解析服务名,但 nsupdate.info 的NAMESERVERS是给 dnspython 用的字符串列表,不会走服务名解析,所以这里要写固定 IP(这正是 Compose 文件里给四个容器都声明ipv4_address的原因)。
4.3 BIND 配置 named.conf
options {
directory "/var/cache/bind";
recursion no;
listen-on { any; };
allow-query { any; };
dnssec-validation no;
};
// 密钥块:secret 必须与 nsupdate.info 数据库中 Domain 记录的
// nameserver_update_secret 一致(部署时替换 <NS_SECRET>)
key "nsupdate.test." {
algorithm hmac-sha512;
secret "<NS_SECRET>";
};
zone "nsupdate.test" {
type primary;
file "/var/lib/bind/nsupdate.test.zone";
update-policy {
deny nsupdate.test. name nsupdate.test;
deny nsupdate.test. name www.nsupdate.test;
deny nsupdate.test. name ipv4.nsupdate.test;
deny nsupdate.test. name ipv6.nsupdate.test;
grant nsupdate.test. name connectivity-test.nsupdate.test A;
grant nsupdate.test. subdomain nsupdate.test;
};
};
(模板语义逐行解读见配置参数详解篇 5.8 节;<NS_SECRET> 占位符在部署步骤 5.4 用真实密钥渲染。)
4.4 初始 zone 文件 nsupdate.test.zone
$TTL 60
@ IN SOA ns1.nsupdate.test. root.nsupdate.test. (
2026102701 ; serial
3600 ; refresh
600 ; retry
604800 ; expire
60 ; minimum
)
IN NS ns1.nsupdate.test.
ns1 IN A 10.89.61.53
www IN A 10.89.61.10
4.5 nginx 反代配置 default.conf
server {
listen 80;
server_name _;
client_max_body_size 10m;
# Django collectstatic 产物(与 app 共享卷)
location /static/ {
alias /srv/static/;
expires 7d;
access_log off;
}
# 其余请求转发 gunicorn(WSGI: nsupdate.wsgi)
location / {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
4.6 docker-compose.yml
name: nsupdate-0.13.0-stack
networks:
dns-net:
driver: bridge
ipam:
config:
- subnet: 10.89.61.0/24
services:
db:
image: postgres:16-alpine
container_name: nsupdate-0.13.0-db
environment:
POSTGRES_DB: nsupdate
POSTGRES_USER: nsupdate
POSTGRES_PASSWORD: q1w2e3r4
volumes:
- pgdata:/var/lib/postgresql/data
networks:
dns-net:
ipv4_address: 10.89.61.11
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nsupdate -d nsupdate"]
interval: 5s
timeout: 3s
retries: 12
bind:
image: internetsystemsconsortium/bind9:9.18
container_name: nsupdate-0.13.0-bind
volumes:
- ./bind/named.conf:/etc/bind/named.conf:ro
- ./bind/zones:/var/lib/bind
ports:
- "8139:53/tcp"
- "8139:53/udp"
networks:
dns-net:
ipv4_address: 10.89.61.53
restart: unless-stopped
app:
build:
context: ./app
image: nsupdate-0.13.0-app
container_name: nsupdate-0.13.0-app
environment:
DJANGO_SETTINGS_MODULE: local_settings
SECRET_KEY: "nsupdate-compose-0.13.0-demo-secret-key-change-me"
DJANGO_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_EMAIL: admin@nsupdate.test
DJANGO_SUPERUSER_PASSWORD: q1w2e3r4
volumes:
- ./app/local_settings.py:/app/local_settings.py:ro
- static:/app/htdocs/static
depends_on:
db:
condition: service_healthy
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
gunicorn nsupdate.wsgi:application --name nsupdate.info --workers 2 --bind 0.0.0.0:8000"
networks:
dns-net:
ipv4_address: 10.89.61.10
restart: unless-stopped
web:
image: nginx:alpine
container_name: nsupdate-0.13.0-web
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- static:/srv/static:ro
ports:
- "8138:80"
depends_on:
- app
networks:
dns-net:
ipv4_address: 10.89.61.80
restart: unless-stopped
volumes:
pgdata:
static:
编排要点:
db带pg_isready健康检查,app用depends_on.condition: service_healthy保证"数据库就绪才迁移";app启动命令串联 migrate → collectstatic → gunicorn 三步,容器即"迁移+运行"一体;static命名卷由 app 写入(collectstatic 产物)、web 只读挂载,实现静态文件分离;SECRET_KEY通过环境变量注入(base.py 优先读环境变量,见配置参数详解篇 5.1 节)。
五、部署步骤实测
5.1 构建镜像
docker compose build app
实测最终成功行:
#13 DONE 0.0s
Image nsupdate-0.13.0-app Built
构建产物确认:
$ docker images nsupdate-0.13.0-app
REPOSITORY TAG IMAGE ID CREATED SIZE
nsupdate-0.13.0-app latest ddf381568e55 7 minutes ago 631MB
(构建过程中两次失败的排障实录见第九章 Q1/Q2,建议先读再动手,可少走弯路。)
5.2 启动栈
docker compose up -d
实测回显(关键段):
Container nsupdate-0.13.0-db Started
Container nsupdate-0.13.0-bind Started
Container nsupdate-0.13.0-db Waiting
Container nsupdate-0.13.0-db Healthy
Container nsupdate-0.13.0-app Starting
Container nsupdate-0.13.0-app Started
Container nsupdate-0.13.0-web Started
四容器状态:
$ docker compose ps
NAME IMAGE STATUS
nsupdate-0.13.0-app nsupdate-0.13.0-app Up 3 minutes
nsupdate-0.13.0-bind internetsystemsconsortium/bind9:9.18 Up About a minute
nsupdate-0.13.0-db postgres:16-alpine Up 3 minutes (healthy)
nsupdate-0.13.0-web nginx:alpine Up 3 minutes
app 容器自动完成数据库迁移(日志节选):
Operations to perform:
Apply all migrations: accounts, admin, auth, contenttypes, main, registration, sessions, sites, social_django
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying accounts.0001_initial... OK
...
5.3 创建管理员
DJANGO_SUPERUSER_* 环境变量已在编排文件中注入,直接非交互创建:
docker exec nsupdate-0.13.0-app python manage.py createsuperuser --noinput
实测回显:
Superuser created successfully.
5.4 创建 Domain/Host 并渲染 BIND 密钥
在 app 容器内用 Django shell 建模(等价于 Web 界面操作,generate_ns_secret() 即 Web 界面"生成共享密钥"按钮的实现):
docker exec nsupdate-0.13.0-app bash -c "python -c \"
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'local_settings')
django.setup()
from nsupdate.main.models import Domain, Host, UPDATE_ALGORITHM_DEFAULT
from django.contrib.auth.models import User
admin = User.objects.get(username='admin')
d, created = Domain.objects.get_or_create(name='nsupdate.test', defaults=dict(
nameserver_ip='10.89.61.53',
nameserver_update_algorithm=UPDATE_ALGORITHM_DEFAULT,
created_by=admin,
comment='compose stack demo zone'))
if created:
print('zone secret:', d.generate_ns_secret()); d.save()
h, hc = Host.objects.get_or_create(name='home', domain=d, defaults=dict(
created_by=admin, comment='compose demo host'))
s = h.generate_secret('q1w2e3r4'); h.save()
print('host secret:', s)
\""
实测回显(本次运行的密钥):
zone secret: eUdQYkpHZ2FmNGNwUVc0Z2tYSnZ3c2hQcWd3U1ZBR1hKUDRWR0JzbnhNUWU3dTdlY2ZBVldoZGd6UEdxQ2Nibg==
host secret: q1w2e3r4
把 zone 密钥渲染进 BIND 配置(宿主侧 sed 替换 <NS_SECRET> 占位符):
sed -i "s|<NS_SECRET>|eUdQYkpHZ2FmNGNwUVc0Z2tYSnZ3c2hQcWd3U1ZBR1hKUDRWR0JzbnhNUWU3dTdlY2ZBVldoZGd6UEdxQ2Nibg==|" bind/named.conf
docker compose restart bind
⚠️ 注意:不要用
docker cp覆盖/etc/bind/named.conf——它是 bind-mount 进容器的文件,实测报Error response from daemon: RemoveAll named.conf: device or resource busy。正确姿势就是上面两步:改宿主文件 →docker compose restart bind(bind-mount 实时映射)。
5.5 修正 zone 目录属主(关键)
BIND 容器内 named 进程以 uid 53(bind 用户)运行,而宿主目录默认 root 属主——动态更新要写 journal(.jnl)文件,属主不对会直接 SERVFAIL(实录见第九章 Q4)。在宿主机上修正:
chown 53:53 bind/zones bind/zones/nsupdate.test.zone
5.6 服务巡检
应用侧域名巡检(对 Domain 记录发 SOA 查询):
docker exec nsupdate-0.13.0-app python manage.py domains --check
实测回显:
[2026-10-28 00:40:09,230] DEBUG query: nsupdate.test answer: ns1.nsupdate.test. root.nsupdate.test. 2026102704 3600 600 604800 60
Web 与静态文件可用性(宿主机侧):
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8138/
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8138/accounts/login/
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8138/static/admin/css/base.css
200
六、端到端功能验证
用 dyndns2 协议从宿主机走完整链路(curl → nginx → gunicorn → dnspython → BIND),凭据为 5.4 节的 home.nsupdate.test:q1w2e3r4:
首次上报新 IP:
$ curl -s -u "home.nsupdate.test:q1w2e3r4" "http://127.0.0.1:8138/nic/update?myip=203.0.113.7"
good 203.0.113.7
向权威服务器验证 A 记录(app 容器内用 dnspython 直查 BIND):
$ docker exec nsupdate-0.13.0-app python -c "
import dns.resolver
r = dns.resolver.Resolver(configure=False); r.nameservers=['10.89.61.53']
print('A record:', [a.address for a in r.resolve('home.nsupdate.test', 'A')])"
A record: ['203.0.113.7']
重复上报同一 IP(应答 nochg):
$ curl -s -u "home.nsupdate.test:q1w2e3r4" "http://127.0.0.1:8138/nic/update?myip=203.0.113.7"
nochg 203.0.113.7
删除记录:
$ curl -s -u "home.nsupdate.test:q1w2e3r4" "http://127.0.0.1:8138/nic/delete?myip=0.0.0.0"
deleted A
删除后重新上报(应答回到 good,记录恢复):
$ curl -s -u "home.nsupdate.test:q1w2e3r4" "http://127.0.0.1:8138/nic/update?myip=203.0.113.8"
good 203.0.113.8
A record: ['203.0.113.8']
错误凭据(应答 401):
$ curl -s -u "home.nsupdate.test:wrongpass" -o /dev/null -w 'badauth: %{http_code}\n' \
"http://127.0.0.1:8138/nic/update?myip=203.0.113.9"
badauth: 401
myip 端点:
$ curl -s http://127.0.0.1:8138/myip
10.89.61.80
💡 说明:本栈中
/myip返回的是 nginx 容器 IP(10.89.61.80)——Django 侧从反代连接的 REMOTE_ADDR 取值。生产形态应在 nginx 用location /myip { return 200 $remote_addr; }直接应答真实客户端 IP(官方 nginx 示例即此做法),或正确传递并解析X-Real-IP头。端到端验证的核心指标是good应答与 A 记录实际变化,以上均已满足。
七、生产化改造建议
本栈是功能完整的最小验证形态,正式投产建议按序补齐:
- TLS:
WE_HAVE_TLS=True+ Cookie Secure(配置参数详解篇 5.1 节),nginx 挂证书或前面加 Traefik/Caddy; - /myip 直答:nginx 加
location /myip直接return 200 $remote_addr,避免反代链路污染客户端 IP 判定; - ** SECRET_KEY 管理**:改用 Docker secrets 或外部密钥管理,不落 compose 文件明文;
- 数据库备份:pgdata 卷加定时
pg_dump; - gunicorn 参数:按 CPU 调整
--workers(官方文档建议 2×核数+1 起步评估); - zone 权限收口:本文用 chown 53:53 适配容器内 bind 用户,宿主形态应保持 zone 目录仅 named 用户可写;
- 升级流程:
git fetch --tags更新 src 后重建镜像,migrate由容器启动命令自动执行。
八、注意事项
- 官方不提供生产镜像,应用容器必须自行构建;
scripts/docker/只是 CI 自测载体,勿直接拿来上生产; NAMESERVERS与 Domain 的nameserver_ip都要写容器固定 IP,不能用 Compose 服务名(dnspython 不走 Docker 内置 DNS);- BIND 的密钥/zone/update-policy 三点必须与数据库 Domain 记录一致,改密钥先改 BIND 再重置 Domain(配置参数详解篇 5.8 节对齐表);
- Domain 一旦因更新失败被标记不可用,后续更新会被短路(第九章 Q5),排障时先看应用日志的 unavailability 关键字;
- 本地验证建议保留 compose 工程目录——zone 文件与 journal 都在宿主
bind/zones/,删栈不丢数据。
九、常见问题(排障实录)
Q1:构建时报 fatal error: stdlib.h: No such file or directory(psycopg2 编译失败)?
python:3.12-slim 不含 glibc 头文件,psycopg2 源码编译编译 Python 扩展时报错。实测报错关键行:
/usr/local/include/python3.12/Python.h:23:12: fatal error: stdlib.h: No such file or directory
修复:Dockerfile 追加 apt-get install -y --no-install-recommends libc6-dev 层(见 4.1 节)。
Q2:pip install -e . 报 setuptools-scm 无法确定版本?
镜像内无 git 命令,setuptools-scm 从 git 标签推导版本失败。实测报错关键行:
Alternatively, set the version in the environment with SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NSUPDATE
or VCS_VERSIONING_PRETEND_VERSION_FOR_NSUPDATE, as described in
https://setuptools-scm.readthedocs.io/en/latest/config/
修复:按报错提示在 pip 安装命令前加 SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NSUPDATE=0.13.0(与官方 tag 一致)。
Q3:/nic/update 返回 dnserr,应用日志 DNS error [SERVFAIL],但 SOA 查询正常?
两段式排障实录(本环境真实踩坑):
- 第一段(zone 文件缺失):最初把 zone 文件挂到 bind 容器的具名空卷,named 起来后无 zone 可加载——对
home.nsupdate.test的查询返回 NXDOMAIN/SERVFAIL。修复:zone 改为宿主目录挂载./bind/zones:/var/lib/bind; - 第二段(journal 无写权限):zone 加载后更新仍 SERVFAIL——named 以 uid 53 运行,zone 文件 root:644,写 journal(.jnl)被拒。修复:宿主机
chown 53:53 bind/zones/(见 5.5 节)。修复后立即good。
Q4:为什么 SOA 查询通、动态更新却 SERVFAIL? RFC 2136 动态更新需要 BIND 对 zone 文件目录有写权限(journal 机制),只读可用不代表可更新。这是容器挂载宿主 zone 目录时最常踩的权限坑。
Q5:修复后 /nic/update 仍报 dnserr,日志显示 was flagged unavailable?
应用把连续更新失败的名字服务器标记为不可用(Domain.available=False),之后一段时间内的更新请求直接短路,不再尝试连接。实测日志:
ERROR home.nsupdate.test - received update that resulted in a dns error
[nameserver for domain nsupdate.test at IP 10.89.61.53 was flagged unavailable], ...
修复:BIND 侧问题解决后重置标志并重试:
docker exec nsupdate-0.13.0-app bash -c "python -c \"
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'local_settings')
django.setup()
from nsupdate.main.models import Domain
d = Domain.objects.get(name='nsupdate.test'); d.available = True; d.save(); print('reset ok')
\""
实测回显与重试结果:
reset ok
$ curl -s -u "home.nsupdate.test:q1w2e3r4" "http://127.0.0.1:8138/nic/update?myip=203.0.113.7"
good 203.0.113.7
Q6:docker cp 覆盖 bind 容器内 named.conf 报 device or resource busy?
该路径是 bind-mount 的宿主文件,容器内不可替换。改宿主文件后 docker compose restart bind(见 5.4 节注意)。
十、参考链接
- 官方安装文档:
https://nsupdateinfo.readthedocs.io/en/stable/admin.html - 官方仓库(源码快照来源):
https://github.com/nsupdate-info/nsupdate.info - ISC BIND9 官方镜像:
https://hub.docker.com/r/internetsystemsconsortium/bind9 - PostgreSQL 官方镜像:
https://hub.docker.com/_/postgres - RFC 2136:
https://datatracker.ietf.org/doc/html/rfc2136 - 本专栏相关篇目:《nsupdate.info 介绍与选型指南》、《nsupdate.info 0.13.0 配置参数详解》、《nsupdate.info 0.13.0 常用命令与运维实战》