分类 计算机 下的文章

反向代理的本质

反向代理是个容易被误解的概念。正向代理替客户端访问服务端,反向代理替服务端接收客户端请求。一字之差,但方向完全相反。从部署看,反向代理更像一台"前台接待",它不处理业务,只负责把请求转发给后端真正的服务,再把结果返回给客户端。

引入反向代理解决的核心问题是:后端服务的暴露面。没有反向代理时,每个后端服务都得开放端口给外部,攻击面巨大。有了反向代理,外部只看到 80/443,后端服务全藏在内网。附加价值是负载均衡、缓存、SSL 卸载这些功能可以统一在代理层完成,后端服务不用各自实现一遍。

设计代价是引入了一跳网络延迟和单点风险。如果代理挂了,所有后端服务都不可用——所以生产环境代理本身也要做高可用。这个成本必须接受,没有廉价方案。

代理与负载均衡的差异

很多人把 proxy_passupstream 混为一谈。它们不是一回事。

proxy_pass 指向单个后端地址,只是转发,不涉及负载均衡。哪怕指向一个域名,如果 DNS 解析出多个 IP,Nginx 也只在启动时选一个。

upstream 定义一组后端,Nginx 会在组内分配请求。这才是负载均衡。

坑在于:很多人写 proxy_pass http://backend; 以为就负载均衡了,但如果没定义对应的 upstream backend {} 坯,Nginx 会把 backend 当域名做 DNS 解析。解析失败就起不来。

基础代理配置

最基础的反向代理配置,把 /api/ 的请求转发到后端 8080:

# 实测于 Nginx 1.24.0, CentOS 7
server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        # 透传真实客户端 IP,后端日志才有意义
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }
}

proxy_pass 末尾的斜杠是个经典坑。http://127.0.0.1:8080/ 带斜杠会把 /api/ 替换成 /,后端收到的路径是 /users;如果不带斜杠写成 http://127.0.0.1:8080,后端收到的是 /api/users。前后端路径不一致的时候,99% 是这里的问题。

负载均衡配置

引入 upstream 后,可以做多后端负载均衡:

upstream backend {
    # 加权轮询(默认),weight 越高分配越多
    server 192.168.1.10:8080 weight=3;
    server 192.168.1.11:8080 weight=1;

    # 备用服务器,只在主服务器都挂了才启用
    server 192.168.1.12:8080 backup;

    # 容错:10 秒内失败 3 次就标记不可用,停 30 秒再试
    # 这个参数写在 server 行内
}

server {
    listen 80;

    location /api/ {
        proxy_pass http://backend/;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Nginx 默认的负载均衡算法是加权轮询(Weighted Round Robin)。还有两种可选:
- least_conn:转发给当前连接数最少的后端,适合请求处理时间差异大的场景
- ip_hash:按客户端 IP 哈希固定后端,保证会话粘性

ip_hash 看起来很美,但坑在于:客户端经过 NAT 出口,所有请求看起来都是同一个 IP,会集中打到一台后端。生产环境如果走 CDN,ip_hash 基本失效。

实测记录

两个后端,weight 1:1,用 wrk -t4 -c100 -d30s 压测。
- 理论预期:负载约 50:50 分配
- 实际结果:49.7% : 50.3%,基本符合预期
- 切到 ip_hash 后变成 87% : 13%——大多数 IP 都被哈希到同一个后端,验证了 NAT 场景下 ip_hash 失效

偏差分析:ip_hash 的哈希空间只有 2 个桶(2 个后端),分布极度不均匀。后端数量越多,ip_hash 的均匀性越好;只有 2 个后端时几乎没意义。

会话保持:用 cookie 而不是 ip_hash

替代 ip_hash 的方案是 sticky cookie。Nginx 商业版原生支持,开源版可以用 nginx-sticky-module-ng 第三方模块。原理是给客户端种一个 cookie,下次请求带这个 cookie 就路由到同一后端。

如果不想引入第三方模块,最实用的方案是在应用层做会话共享(Redis 存 session),后端无状态,随便路由到哪台都行。这才是真正的可扩展架构。我维护的服务都走这条路——代理层只管转发,无状态化交给应用层处理。

SSL 卸载

反向代理做 SSL 卸载,后端就不需要处理 HTTPS,降低 CPU 占用:

server {
    listen 443 ssl;
    ssl_certificate /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;

    location /api/ {
        proxy_pass http://backend/;
        # 后端需要知道原始协议是 https
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

后端框架通常通过 X-Forwarded-Proto 判断原始协议。Django 和 Flask 都支持,但要确保配置了 SECURE_PROXY_SSL_HEADER 或者 ProxyFix 中间件,否则后端会以为请求是 http 进来的,重定向时生成错误的 URL。

遗留问题

  • Nginx 后端健康检查目前只能靠被动探测(请求失败才标记 down)。主动健康检查(health_check)是商业版功能,开源版要装 nginx_upstream_check_module,这个模块跟 Nginx 版本绑定很死,升级麻烦。替代方案是外部脚本定期 curl 探测,通过 nginx -s reload 动态剔除节点,但 reload 会导致短时连接中断。
  • proxy_pass 的 DNS 解析只在启动时做一次,如果后端用域名且 IP 会变(比如云服务商),需要用 resolver 指令加变量方式实现动态解析。具体写法可参考官方文档的 resolver 章节。

关联笔记

关联笔记:Nginx 限流配置:防刷防爬的实战方案 中关于 limit_req 和 limit_conn 的配置讨论,可与此文配合使用

两个平台的本质差异

ESP32 和 STM32 是嵌入式开发者最常纠结的两个平台。不是因为它们难选,而是因为它们各自擅长的领域有微妙的重叠。选错平台的代价不是不能用,而是在项目中期才发现某个关键需求被忽略了。

ESP32 本质是一颗带 WiFi/蓝牙的 SoC,设计初衷是物联网连接。STM32 是纯粹的 MCU,专注于实时控制和低功耗。这个定位差异决定了它们在性能、功耗、开发生态上的不同取舍。

从架构上看,ESP32 用的是 Xtensa LX6 双核处理器,主频 240MHz,自带 520KB SRAM。STM32 系列跨度很大,从 Cortex-M0 的 48MHz 到 M7 的 480MHz 都有。但 STM32 的优势不在于主频,而在于外设的丰富程度和实时性保证。

性能对比

在纯计算能力上,ESP32 的双核 240MHz 确实比大多数 STM32F1/F4 系列强。但如果需要浮点运算,STM32F4 的 FPU 会更高效。ESP32 的 Xtensa 核心没有硬件 FPU,浮点运算全靠软件模拟,差距在 5-10 倍。

在 GPIO 翻转速度上,STM32 完胜。STM32F103 的 GPIO 翻转可以达到 18MHz(参考:RM0008, Page 160),而 ESP32 的 GPIO 最高只有 40MHz(理论上),但实际用 Arduino 框架时,翻转速度受框架开销影响,通常只能做到 1-2MHz。

实测记录

测试环境:STM32F103C8T6 vs ESP32-WROOM-32

GPIO 翻转测试(翻转 PA5 / GPIO2,示波器测量):
- STM32(寄存器操作):12MHz
- STM32(HAL 库):3.2MHz
- ESP32(Arduino digitalWrite):800kHz
- ESP32(寄存器操作):4.5MHz

偏差分析:ESP32 的寄存器操作速度比预期低,怀疑是因为 Xtensa 架构的 GPIO 控制器需要多个时钟周期来完成一次翻转。

功耗对比

这是选型中最容易被忽略的维度。ESP32 的 WiFi 模块是功耗大户,活跃状态 80-240mA,深度睡眠 10μA。STM32F1 的停止模式可以做到 2μA,Standby 模式更是低至 1.4μA。

如果项目需要电池供电且不频繁通信,STM32 是唯一选择。如果需要频繁联网,ESP32 的功耗反而更优——因为它可以快速唤醒、传输数据、然后回到深度睡眠,整体能耗比 STM32 + 外挂 WiFi 模块低。

开发生态

STM32 的开发生态成熟但碎片化。官方有 CubeMX、CubeIDE、HAL 库、LL 库,第三方有 PlatformIO、Keil、IAR。选哪个都行,但选了就要一直用下去,迁移成本很高。

ESP32 的生态相对统一。Espressif 提供 ESP-IDF(C 语言)和 Arduino 框架,社区资源丰富。但 ESP-IDF 的版本迭代很快,API 经常变,升级是个头疼的事。

在调试工具上,STM32 支持 SWD/JTAG,可以用 ST-Link、J-Link 等专业工具。ESP32 只支持 JTAG(需要额外配置),通常用串口打印调试,体验差很多。

选型决策树

选型不是比参数,而是匹配需求。我总结了一个简单的决策流程:

需要 WiFi/蓝牙?
├── 是 → ESP32
└── 否 → 需要低功耗(电池供电)?
    ├── 是 → STM32L 系列
    └── 否 → 需要高实时性(电机控制、音频处理)?
        ├── 是 → STM32F4/H7
        └── 否 → 预算敏感?
            ├── 是 → ESP32(成本更低)
            └── 否 → STM32F1(资料最多)

适用前提:以上决策树适用于个人项目和小批量产品。量产项目还需要考虑芯片供货稳定性、封装可获得性、以及长期技术支持。

常见误区

  1. "ESP32 性能更强所以更好":性能强不代表适合。ESP32 的实时性不如 STM32,中断响应延迟在 10-20μs 级别,而 STM32 可以做到 1μs 以内。

  2. "STM32 没有无线功能所以不行":STM32WB 系列自带蓝牙,STM32W 系列自带 Zigbee。只是 WiFi 确实需要外挂。

  3. "ESP32 便宜所以选它":ESP32 模组确实便宜(十几块钱),但如果你不需要无线功能,STM32F103 只要几块钱。

遗留问题

  • ESP32-S3 是新出的型号,号称性能提升 2 倍,但实际表现如何?待手上有板子后实测验证。
  • STM32 的以太网 MAC 和 ESP32 的 WiFi 在 TCP 吞吐量上差多少?需要搭建测试环境对比。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 STM32 外设配置的讨论

Nginx 限流的核心机制

在 Web 服务运维中,限流是个绕不开的话题。不是因为技术多难,而是因为你永远不知道什么时候会被刷。我之前维护的一个内部接口,某天凌晨突然收到了几十万次请求,查了日志才发现是某个爬虫在跑。从那之后,我就养成了上线必配限流的习惯。

Nginx 提供了两种限流模块:limit_reqlimit_conn。前者控制请求速率(按秒计算),后者控制并发连接数。实际场景中,这两个通常要配合使用。

limit_req 的工作原理是漏桶算法。请求进来后先进队列,队列以固定速率处理请求,超过队列容量的请求直接返回 503。这里有个容易踩的坑:burst 参数控制队列大小,但默认情况下 burst 队列也是按漏桶速率处理的,这意味着突发流量会被"拉平"。如果你需要立即处理 burst 中的请求,要加 nodelay 参数。

limit_conn 则是基于连接数的限制,通常按客户端 IP 统计。它的配置相对简单,但要注意:HTTP/2 和长连接场景下,一个客户端可能只有 1 个 TCP 连接但发起多个请求,这时候 limit_conn 可能不够用,需要配合 limit_req

配置实例与踩坑记录

以下配置是我实际生产环境中使用的,基于 Nginx 1.24.0:

http {
    # 定义限流区域:按 IP 限制每秒 10 个请求
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # 定义连接数限制区域
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        listen 80;

        location /api/ {
            # 限速配置:允许突发 20 个请求,nodelay 表示不排队直接处理
            limit_req zone=api_limit burst=20 nodelay;

            # 连接数限制:每个 IP 最多 50 个并发连接
            limit_conn conn_limit 50;

            # 超出限制返回 429 状态码(需 Nginx 1.17.6+)
            limit_req_status 429;
            limit_conn_status 429;

            proxy_pass http://backend;
        }

        location /static/ {
            # 静态资源不限流
            root /var/www/html;
        }
    }
}

实测记录

理论预期:按 10r/s 配置,每秒应只放行 10 个请求,突发 20 个。

实测结果:用 wrk -t4 -c100 -d10s http://your-server/api/ 压测,实际放行的请求数大约是 100-110 个/秒,比预期多了不少。

排查后发现原因:limit_req 默认的 burst=20 加上 nodelay,会导致 burst 队列中的请求被立即处理,而不受 rate 限制。如果 100 个连接同时发起请求,其中 20 个会被立即放行,剩下 80 个才开始按 rate 限制。所以实际效果是:每秒至少放行 rate + burst 个请求。

(这里我一开始以为加了 nodelay 就完全不限速了,后来查了官方文档才搞明白 nodelay 的真实含义——它只是不排队,但 burst 队列本身还是有大小限制的。)

另一个坑:limit_req_zonezone=api_limit:10m 中,10m 是共享内存区域大小,不是存储空间。10m 大约能存储 16 万个 IP 地址。如果访问量大,这个值要调大,否则会出现限流失效的情况。

按场景精细控制

不同接口的限流策略应该不同。比如登录接口要严格限制防暴力破解,但查询接口可以宽松一些。

# 登录接口:每秒 2 个请求,允许突发 5 个
location /api/login {
    limit_req zone=login_limit burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

# 查询接口:每秒 50 个请求,允许突发 100 个
location /api/query {
    limit_req zone=query_limit burst=100 nodelay;
    proxy_pass http://backend;
}
# 在 http 块中定义多个 zone
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=query_limit:10m rate=50r/s;

实测记录

登录接口压测 100 次请求,实际只放行了约 10 次,返回 429 的有 90 次。符合预期(2r/s + burst=5,10 秒内约 20+5=25 个请求被放行)。

查询接口压测 1000 次请求(100 并发 × 10 秒),放行约 600 次,返回 429 约 400 次。基本符合 50r/s + burst=100 的配置。

白名单与例外处理

生产环境中,监控系统、内部服务通常需要豁免限流。Nginx 的 geo 模块可以实现 IP 白名单:

geo $limit {
    default 1;
    10.0.0.0/8 0;      # 内网 IP 不限流
    192.168.0.0/16 0;
    172.16.0.0/12 0;
}

map $limit $limit_key {
    0 "";              # 白名单 IP 不进入限流 zone
    1 $binary_remote_addr;
}

limit_req_zone $limit_key zone=api_limit:10m rate=10r/s;

这样内网 IP 发起的请求不会被计数。但要注意:geo 模块匹配的是客户端真实 IP,如果前面有 CDN 或负载均衡,需要确保 Nginx 能拿到真实 IP(配置 set_real_ip_fromreal_ip_header)。

遗留问题

  • 如果 Nginx 前面有多层代理(CDN → 负载均衡 → Nginx),如何确保限流的准确性?理论上要在最外层限流,但实际中往往做不到。
  • limit_req 的漏桶算法在高并发下是否有性能瓶颈?官方文档没提,待压测验证。

关联笔记

暂未找到强关联笔记

项目里到处都是 dict,读取数据时 data['name'],写错了 key 又不报错。上线后排查了半天,发现是 key 拼写错了。后来用 dataclass 替换 dict,IDE 能正确补全,写错属性名直接报错。

为什么不用 dict

# 典型的 dict 用法
user = {
    "name": "张三",
    "age": 25,
    "email": "zhangsan@example.com"
}

# 读取数据
name = user["name"]  # 正常
name = user["namee"]  # KeyError!运行时才发现

# 传参时
def send_email(user):
    print(f"发送邮件到 {user['emial']}")  # 拼写错误,运行时才发现

问题:

  1. 没有类型提示:IDE 不知道 user 有哪些字段
  2. 拼写错误不报错user['emial'] 运行时才报 KeyError
  3. 没有默认值:每个字段都要手动赋值
  4. 没有比较方法:两个 dict 比较需要手动实现

dataclass 基础用法

最简配置

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
    email: str

user = User(name="张三", age=25, email="zhangsan@example.com")
print(user)  # User(name='张三', age=25, email='zhangsan@example.com')

带默认值

@dataclass
class User:
    name: str
    age: int = 0
    email: str = ""
    is_active: bool = True

user = User(name="张三")
print(user)  # User(name='张三', age=0, email='', is_active=True)

坑在于:默认值必须放在没有默认值的字段后面。

从 dict 创建

data = {"name": "张三", "age": 25, "email": "zhangsan@example.com"}
user = User(**data)
print(user.name)  # 张三

进阶用法

字段选项

from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    age: int = field(default=0, metadata={"min": 0, "max": 150})
    email: str = field(default="", repr=False)  # repr=False 不在打印时显示
    tags: list = field(default_factory=list)  # 可变默认值要用 default_factory

user = User(name="张三")
print(user)  # User(name='张三', age=0, tags=[])

坑在于:可变对象(list、dict)不能直接作为默认值,要用 default_factory

frozen dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
# p1.x = 3.0  # 报错:FrozenInstanceError

frozen dataclass 不可变,可以用作 dict 的 key。

继承

@dataclass
class Animal:
    name: str
    age: int

@dataclass
class Dog(Animal):
    breed: str = "unknown"

dog = Dog(name="旺财", age=3, breed="柴犬")
print(dog)  # Dog(name='旺财', age=3, breed='柴犬')

与 Pydantic 的对比

from dataclasses import dataclass
from pydantic import BaseModel

# dataclass
@dataclass
class UserDC:
    name: str
    age: int

# Pydantic
class UserPydantic(BaseModel):
    name: str
    age: int

# dataclass 不做类型验证
user_dc = UserDC(name="张三", age="二十五")  # 不报错

# Pydantic 做类型验证
user_pydantic = UserPydantic(name="张三", age="二十五")  # 报错

选择建议:

  • dataclass:标准库,轻量,适合内部数据传递
  • Pydantic:第三方库,有类型验证,适合 API 参数校验

实战:配置管理

from dataclasses import dataclass, field
from typing import List

@dataclass
class DatabaseConfig:
    host: str = "localhost"
    port: int = 5432
    user: str = "postgres"
    password: str = ""
    db_name: str = "app"

@dataclass
class AppConfig:
    debug: bool = False
    database: DatabaseConfig = field(default_factory=DatabaseConfig)
    allowed_hosts: List[str] = field(default_factory=list)

config = AppConfig(debug=True, database=DatabaseConfig(host="db.example.com"))
print(config.database.host)  # db.example.com

实战:API 响应模型

from dataclasses import dataclass, asdict
from typing import Optional

@dataclass
class ApiResponse:
    code: int
    message: str
    data: Optional[dict] = None

def success(data=None):
    return ApiResponse(code=200, message="success", data=data)

def error(message):
    return ApiResponse(code=500, message=message)

# 转换为 dict
response = success({"user": "张三"})
print(asdict(response))  # {'code': 200, 'message': 'success', 'data': {'user': '张三'}}

踩坑总结

  1. 可变默认值(list、dict)要用 field(default_factory=...)
  2. frozen dataclass 不可变,可以用作 dict 的 key
  3. dataclass 不做类型验证,需要验证用 Pydantic
  4. asdict() 可以把 dataclass 转换为 dict
  5. dataclass 是标准库,Python 3.7+ 可用

从 dict 到 dataclass,只需要加一个 @dataclass 装饰器,但能获得类型提示、IDE 补全、自动 __repr____eq__。坑在于:很多人不知道 default_factory 这个坑。

部署一个 Python + Redis + Nginx 的服务,容器启动顺序搞错了。Redis 还没 ready,Python 服务就报 Connection refused。排查了半天,发现是 depends_on 只保证容器启动顺序,不保证服务就绪。

问题现象

# docker-compose.yml
services:
  web:
    build: .
    depends_on:
      - redis
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
docker-compose up -d

Python 服务启动后报错:

redis.exceptions.ConnectionError: Error 111 connecting to localhost:6379. Connection refused.

坑在于:depends_on 只保证 Redis 容器先启动,不保证 Redis 服务已经就绪。Redis 容器启动了,但 Redis 服务可能还在初始化。

解决方案

方案一:healthcheck + condition(推荐)

services:
  web:
    build: .
    depends_on:
      redis:
        condition: service_healthy
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

healthcheck 定义健康检查命令,condition: service_healthy 等待 Redis 健康后再启动 web。

方案二:启动脚本等待

# app.py
import redis
import time

def wait_for_redis(host, port, max_retries=30):
    """等待 Redis 就绪"""
    for i in range(max_retries):
        try:
            r = redis.Redis(host=host, port=port)
            r.ping()
            print("Redis 已就绪")
            return r
        except redis.ConnectionError:
            print(f"等待 Redis... ({i+1}/{max_retries})")
            time.sleep(1)
    raise Exception("Redis 连接超时")

r = wait_for_redis("redis", 6379)

坑在于:这个方案需要在应用代码里加等待逻辑,不够优雅。

方案三:wait-for-it 脚本

services:
  web:
    build: .
    depends_on:
      - redis
    command: >
      sh -c "wait-for-it -t 30 redis:6379 -- python app.py"
    ports:
      - "8000:8000"
# Dockerfile
FROM python:3.11-slim

# 安装 wait-for-it
RUN apt-get update && apt-get install -y wait-for-it && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

这个方案需要在镜像里安装 wait-for-it,增加镜像体积。

完整配置示例

Python + Redis + Nginx

services:
  web:
    build: .
    depends_on:
      redis:
        condition: service_healthy
    environment:
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    ports:
      - "8000:8000"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    volumes:
      - redis_data:/data

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      web:
        condition: service_started

volumes:
  redis_data:

健康检查配置详解

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 10s      # 检查间隔
  timeout: 5s        # 超时时间
  retries: 3         # 重试次数
  start_period: 30s  # 启动等待时间

start_period 给容器启动的时间,这段时间内的失败不算在 retries 里。

网络配置

services:
  web:
    build: .
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

同一个网络里的容器可以用服务名互相访问,比如 redis:6379

常用命令

# 启动所有服务
docker-compose up -d

# 查看服务状态
docker-compose ps

# 查看日志
docker-compose logs -f web

# 重建并启动
docker-compose up -d --build

# 停止并清理
docker-compose down

# 停止并清理数据卷
docker-compose down -v

踩坑总结

  1. depends_on 只保证容器启动顺序,不保证服务就绪
  2. healthcheck + condition: service_healthy 等待服务健康
  3. start_period 给容器启动的时间,避免误判
  4. 同一个网络里的容器可以用服务名互相访问
  5. 生产环境用 docker-compose up -d --build 重建镜像

从"容器启动了但服务没就绪"到"服务健康后再启动依赖",只需要配置 healthcheck + condition。坑在于:很多人只用 depends_on,不知道还有 condition 这个选项。