Optuna在机器学习圈子里受欢迎的原因很简单:定义一个objective函数就能跑超参数搜索,API足够简洁。但很多人跑完demo之后会遇到同一个问题——实验跑在本机的一个SQLite文件里,训练脚本一旦中断,历史记录就丢了;团队里其他人想看调参结果,还得把数据库文件拷来拷去。把Optuna装进Docker容器,配上一个持久化的存储后端和常驻的Dashboard服务,这些问题都能得到解决。这篇文章从存储后端选型讲起,一步步给出完整的容器化方案。

为什么Optuna需要容器化以及存储后端选型
Optuna的架构里有一个关键角色叫Storage,也就是存储后端。默认情况下create_study()不传参数,Optuna会自动创建一个内存中的存储,进程退出数据就没了。单机快速实验这样没问题,但只要满足下面任意一个条件,就应该换成外部存储:
- 训练时间很长,进程可能中断,需要断点续跑;
- 多个worker并行执行trial,需要共享同一个study;
- 团队需要通过Dashboard查看历史实验和参数重要性分析。
Optuna官方支持SQLite、MySQL和PostgreSQL。SQLite胜在零配置,但它在并发写入时容易锁表,Docker容器里如果多个worker同时写同一个SQLite文件,database is locked的报错几乎是必然出现的。所以在容器化场景下,推荐直接上MySQL或者PostgreSQL。本文以MySQL为例,因为它的连接串写法在Optuna文档里最典型,踩坑资料也最多。
还有一个容易被忽视的点:Optuna的Dashboard是独立于训练进程的Web服务,如果不做容器化,每次想看结果都要手动敲命令启动,体验很差。把它打包成一个常驻容器,配合反向代理就能随时访问。
编写Dockerfile与docker-compose编排文件
先准备一个最小化的项目结构:optuna-app/目录下放Dockerfile、docker-compose.yml和train.py。训练脚本里通过环境变量读取数据库连接信息,这样同一份代码在本地和容器里都能跑。
训练脚本的核心部分如下,注意storage参数传的是MySQL连接串,格式为mysql+pymysql://用户名:密码@主机:端口/数据库名:
import os
import optuna
# 从环境变量读取数据库连接信息,避免硬编码
DB_USER = os.environ.get("DB_USER", "optuna")
DB_PASS = os.environ.get("DB_PASS", "optuna123")
DB_HOST = os.environ.get("DB_HOST", "mysql")
DB_PORT = os.environ.get("DB_PORT", "3306")
DB_NAME = os.environ.get("DB_NAME", "optuna")
STORAGE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
def objective(trial):
x = trial.suggest_float("x", -10, 10)
y = trial.suggest_float("y", -10, 10)
return x ** 2 + y ** 2
if __name__ == "__main__":
study = optuna.create_study(
study_name="demo-study",
storage=STORAGE_URL,
direction="minimize",
load_if_exists=True, # 容器重启后继续写入同一个study
)
study.optimize(objective, n_trials=50)
print("best value:", study.best_value)
这里有两个细节值得强调。第一,load_if_exists=True是断点续跑的关键,容器被杀掉再重启后会尝试加载同名study而不是报错。第二,主机名写的是服务名mysql而不是127.0.0.1,因为在docker-compose网络里,容器之间通过服务名互相访问,写localhost只会指向容器自己。
Dockerfile本身很简单,安装Optuna时记得带上MySQL驱动和Dashboard:
FROM python:3.11-slim WORKDIR /app # 安装Optuna核心库、MySQL驱动和可视化面板 RUN pip install --no-cache-dir optuna optuna-dashboard pymysql COPY train.py . # 默认启动Dashboard,训练任务通过docker compose run单独触发 CMD ["optuna-dashboard", "mysql+pymysql://optuna:optuna123@mysql:3306/optuna", "--host", "0.0.0.0", "--port", "8080"]
接着是编排文件,把MySQL、Dashboard和训练worker三个角色都定义出来:
version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: optuna
MYSQL_USER: optuna
MYSQL_PASSWORD: optuna123
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
retries: 10
dashboard:
build: .
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
worker:
build: .
command: python train.py
environment:
DB_HOST: mysql
N_TRIALS: "100"
depends_on:
mysql:
condition: service_healthy
deploy:
replicas: 2 # 两个worker共享同一个study,实现并行搜索
volumes:
mysql-data:
启动整个环境只需要两条命令:docker compose up -d mysql dashboard先把基础设施跑起来,然后用docker compose up worker触发训练。浏览器打开http://服务器IP:8080就能看到Dashboard,里面有每个trial的参数、目标值曲线以及参数重要性分析。
分布式worker接入与生产环境的注意事项
上面编排文件里replicas: 2已经演示了多worker并行的用法。Optuna的分布式实现非常轻量,多个worker只要指向同一个storage和同一个study名称,就会自动通过数据库协调任务的分配,不需要额外的消息队列。如果是在多台物理机上扩展,把MySQL暴露出来,其他机器的worker改一下DB_HOST即可,代码完全不用动。
生产环境有几个坑需要提前规避。首先是MySQL的max_connections,每个worker都会持有数据库连接,worker数量多时要适当调大这个参数,否则会出现连接被拒的报错。其次是Dashboard的访问安全,它默认没有任何鉴权,直接暴露在公网等于把实验数据裸奔,建议前面挡一层Nginx做Basic Auth,或者只绑定内网IP。最后是资源隔离,训练类worker往往吃CPU和内存很凶,给worker容器加上mem_limit和cpus限制,避免把宿主机打挂。
日志方面建议给每个worker容器配置日志轮转,Docker默认的json-file驱动不限制大小的话,长时间跑下来日志文件会非常可观。在编排文件里加上这样的配置即可:
worker:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
总结一下,Optuna容器化的核心不是写Dockerfile本身,而是理清存储后端、Dashboard和训练worker三者之间的关系。把MySQL作为唯一的数据源持久化下来,Dashboard和worker都只是无状态的消费者,这套结构天然支持横向扩展,无论是单机多容器还是多机分布式,都只是改几个环境变量的事。