nsupdate.info 0.13.0 Docker Compose 部署实战

基于官方源码的 nsupdate.info 0.13.0 Compose 四件套部署实录:Django/gunicorn+PostgreSQL+BIND+nginx,端到端 DDNS 更新与 dig 验证全通过,完整保留三次构建失败与两次 SERVFAIL 排障实录。

nsupdate.infoDNSDockerPython2026/10/28

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 内存)
Docker29.9.2(Docker Compose v5.6.1 插件)
nsupdate.info0.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 部署的应用容器需基于官方源码自行构建,这正是本文的展开方式。

二、前置条件

  1. 宿主机已安装 Docker 与 Compose 插件(docker compose version 可用);

  2. 端口 8138/8139 未被占用(ss -ltn 确认),或改用你自己的空闲端口;

  3. 取得 0.13.0 源码快照:

    git clone --branch 0.13.0 --depth 1 https://github.com/nsupdate-info/nsupdate.info.git
    
  4. 规划一个不与现有 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 记录实际变化,以上均已满足。

七、生产化改造建议

本栈是功能完整的最小验证形态,正式投产建议按序补齐:

  1. TLS:WE_HAVE_TLS=True + Cookie Secure(配置参数详解篇 5.1 节),nginx 挂证书或前面加 Traefik/Caddy;
  2. /myip 直答:nginx 加 location /myip 直接 return 200 $remote_addr,避免反代链路污染客户端 IP 判定;
  3. ** SECRET_KEY 管理**:改用 Docker secrets 或外部密钥管理,不落 compose 文件明文;
  4. 数据库备份:pgdata 卷加定时 pg_dump;
  5. gunicorn 参数:按 CPU 调整 --workers(官方文档建议 2×核数+1 起步评估);
  6. zone 权限收口:本文用 chown 53:53 适配容器内 bind 用户,宿主形态应保持 zone 目录仅 named 用户可写;
  7. 升级流程:git fetch --tags 更新 src 后重建镜像,migrate 由容器启动命令自动执行。

八、注意事项

  1. 官方不提供生产镜像,应用容器必须自行构建;scripts/docker/ 只是 CI 自测载体,勿直接拿来上生产;
  2. NAMESERVERS 与 Domain 的 nameserver_ip 都要写容器固定 IP,不能用 Compose 服务名(dnspython 不走 Docker 内置 DNS);
  3. BIND 的密钥/zone/update-policy 三点必须与数据库 Domain 记录一致,改密钥先改 BIND 再重置 Domain(配置参数详解篇 5.8 节对齐表);
  4. Domain 一旦因更新失败被标记不可用,后续更新会被短路(第九章 Q5),排障时先看应用日志的 unavailability 关键字;
  5. 本地验证建议保留 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 节注意)。

十、参考链接