DHCPv4 的动态分配把地址借给客户端一段租期。续租可以延长期限,到期则允许地址再次分配。DHCP 也支持其他分配策略,所以本文讨论的是有限期的动态租约,不把它说成协议唯一能够提供的配置。租期是使用地址的约定,不是设备对地址的永久所有权,也不是证明设备身份的凭据。
借用而非拥有地址
新初始化的客户端发现服务器、选择一份报价,再请求配置。确认后的租约记录允许使用地址的时间。租约数据库可以追踪分配,但不能保证所有机器都遵守协议:未受管理的静态地址、重叠地址池、服务器状态丢失或非法服务器,仍可能制造冲突。不能因为有集中租约表,就假定服务器永远知道整张网络里所有真实占用情况。
续约让租约保持存活
有限租期内,客户端通常在 T1 向原服务器请求续租;若到 T2 还没收到确认,则广播重绑请求,让其他合适的服务器也有机会回答。默认值分别是租期的一半与八分之七,服务器可以提供其他合法定时值。RFC 2131 建议在固定值附近加入随机变化,以减少客户端同步请求。因此默认比例不是必须原样保留的性能调优命令。
确认后的到期时刻按续租请求发送时间加上获准的新租期计算;后续 T1 和 T2 也应按更新后的租约计算,而不是继续沿最初入网时刻推进。续租通常保留地址,却不能保证设备在重启、换网或策略改变后永远得到同一地址。短租期让离场地址较快回收,也增加续租流量;长租期给客户端更多时间应对服务中断,却让已经离场的设备占用地址池更久。固定身份需求应明确配置分配策略。
过期即可回收
客户端可以主动释放地址,但正确运行不能依赖每台离场设备都发送 RELEASE。租约过期后,客户端必须停止使用地址并重新获取配置;服务器也可以重新分配这个地址。设备回来时可能拿到原地址,也可能获得不同地址,不能把换号写成必然结果。管理端删除绑定不会撤回客户端已经获得的租期,因此不能用盲目清空租约来代替冲突调查,否则可能主动制造重复分配。
坏掉的样子
地址池耗尽时,新发现请求可能收不到报价。NAK 则有特定含义,例如拒绝不适合当前网络的请求地址,不能把它统一解释成“地址池已满”。部分客户端在 DHCP 失败后配置 IPv4 链路本地地址,但这是另一种行为,也不能单凭它推断失败原因。先检查池容量、转发路径与服务器日志,再决定是否扩容或调整重试,避免把网络丢包当成地址不足。
租期过短会增加续租负载,并缩短客户端能够容忍服务器中断的时间。集中开机可能让流量聚集,却不意味着整个网络必然同步续租。应核对服务器实际下发的定时值和客户端重试方式,而不是只看名义租期。给新租约调长时间,也不会追溯性地延长之前已经确认的旧租约,观察窗口需要覆盖这部分过渡。
非法服务器可以给出错误网关或解析器。选择第一个报价只是客户端的一种可能策略,并非协议强制规定。服务器标识选项没有提供密码学认证,攻击者可以伪造;单独记住一个标识,并不能建立信任。交换机 DHCP snooping 等网络控制需要正确配置受信任端口和转发路径,才能限制回答来自哪里,不能用调短或调长定时器代替这个边界。
睡眠和计时处理可能暴露旧配置。设备醒来时若租约已经过期,必须先重新获取配置,不能继续占用地址,也不能假定自己只是进入 T2 重绑阶段。排查要结合租约已经经过的时间和实现日志;墙上时钟同步本身不能证明定时逻辑正确。地址冲突检测和 DECLINE 处理是另外的保障,不能因为租约表看起来正常就省略它们。
快速验证
把代码保存为 dhcp_lease_sim.py,运行 python3 dhcp_lease_sim.py。它只用标准库,通过虚拟时钟和简化的内存服务器检查租期算术与地址归属。单播和广播只是场景标签,没有发送网络报文,也没有实现完整客户端状态机。这里假定请求与确认瞬间完成,T2 取整秒;实际系统应读取服务器给出的值,不能把示例常量当作所有网络的固定设置。
"""Simulate a DHCP lease lifecycle on a virtual clock (stdlib only)."""
from __future__ import annotations
LEASE_TIME = 100
T1 = LEASE_TIME // 2 # 50: unicast renewal (RFC 2131 default)
T2 = int(LEASE_TIME * 0.875) # 87: broadcast rebinding (RFC 2131 default)
class DHCPServer:
def __init__(self, pool):
self.pool = list(pool)
self.leases: dict[str, dict] = {} # ip -> {"client": id, "expiry": int}
self.now = 0
def _free_ip(self):
for ip in self.pool:
lease = self.leases.get(ip)
if lease is None or lease["expiry"] <= self.now:
return ip
return None
def discover_offer(self, client_id):
"""DISCOVER -> OFFER (no state change)."""
ip = self._free_ip()
assert ip is not None, f"t={self.now}: pool exhausted, no OFFER for {client_id}"
return ip
def request_ack(self, client_id, ip):
"""REQUEST -> ACK and bind the lease."""
assert ip in self.pool, "requested address outside pool"
lease = self.leases.get(ip)
assert lease is None or lease["expiry"] <= self.now or lease["client"] == client_id, \
f"t={self.now}: {ip} held by another client"
self.leases[ip] = {"client": client_id, "expiry": self.now + LEASE_TIME}
return {"ip": ip, "lease_time": LEASE_TIME, "t1": T1, "t2": T2,
"expiry": self.now + LEASE_TIME}
def renew(self, client_id, ip):
"""Extend a current lease; transport and client states are not modeled."""
lease = self.leases.get(ip)
if lease is None or lease["client"] != client_id or lease["expiry"] <= self.now:
return False # expired: server already reclaimed
lease["expiry"] = self.now + LEASE_TIME
return True
def tick(self, dt):
self.now += dt
# Reclaim expired leases so a new client can reuse them.
for ip, lease in list(self.leases.items()):
if lease["expiry"] <= self.now:
del self.leases[ip]
def check(cond, msg):
assert cond, msg
def main():
server = DHCPServer(["192.168.1.10", "192.168.1.11"])
# 1. DORA: client c1 joins.
offered = server.discover_offer("c1")
check(offered == "192.168.1.10", f"expected first free IP, got {offered}")
ack = server.request_ack("c1", offered)
check((ack["lease_time"], ack["t1"], ack["t2"]) == (100, 50, 87), f"bad timers: {ack}")
check(ack["expiry"] == 100, f"expiry should be 0+100, got {ack['expiry']}")
print(f"t={server.now}: c1 ACK {ack['ip']} lease=100 T1=50 T2=87 expiry=100")
# 2. T1 unicast renewal at half lease.
server.tick(T1) # now=50
check(server.renew("c1", offered), "T1 renewal should be ACKed")
check(server.leases[offered]["expiry"] == 150, "T1 must extend expiry to 50+100")
print(f"t={server.now}: c1 T1 unicast renew OK expiry=150")
# 3. Miss T1, rebind at T2 via broadcast.
server.tick(T1) # now=100: renewed T1; pretend this attempt got no ACK
server.tick(T2 - T1) # now=137: renewed T2, before expiry 150
check(server.now == 50 + T2, "rebind must use renewed lease origin")
check(server.renew("c1", offered), "T2 rebinding should be ACKed")
check(server.leases[offered]["expiry"] == 237, "T2 must extend expiry to 137+100")
print(f"t={server.now}: c1 T2 broadcast rebind OK expiry=237")
# 4. Leave without renewing: expiry reclaims the address.
server.tick(200) # now=337 > expiry 237
check(offered not in server.leases, "expired lease must be reclaimed")
offered2 = server.discover_offer("c2")
check(offered2 == offered, f"reclaimed {offered} should be reused, got {offered2}")
ack2 = server.request_ack("c2", offered2)
check(ack2["expiry"] == server.now + LEASE_TIME, "new lease expiry must restart")
# Stale renewal from the departed client must fail.
check(not server.renew("c1", offered), "stale renewal after expiry must not be ACKed")
print(f"t={server.now}: c1 expired, c2 ACK {ack2['ip']} expiry={ack2['expiry']}")
print("PASS: discover/offer/request/ack, T1 renew, T2 rebind, expiry reclaim")
if __name__ == "__main__":
main()
程序断言:服务器给出第一个空闲地址,ACK 携带 100 秒租期且 T1 为 50、T2 为 87,每次续约都从续约时刻重新计算完整租期,离场地址被回收并转租给第二个客户端,而旧客户端的过期续约会被拒绝。运行成功会看到四行带时间戳的日志,外加一行说明四个阶段都通过的 PASS。
旧版曾记录 macOS、Python 3.12 断言通过,但把时刻 107 的请求误标为 T2,此时续租后的 T2 应为 137。修正后在 Python 3.12.14 运行通过:日志时刻为 0、50、137、337,重绑后的到期时刻为 237。这验证算术修正,不替代真实服务器的报文、地址池和客户端到期行为检查。
延伸阅读
来源
- IETF:RFC 2131 DHCP,租约生命周期的权威定义:DISCOVER、OFFER、REQUEST、ACK 与 T1、T2 续约定时器,已核验 2027-08-15。
- ISC:ISC DHCP,地址池管理、租约数据库与服务器运行说明,已核验 2027-08-15。
- 维基百科:动态主机配置协议,租约生命周期的通俗总览,已核验 2027-08-15。