Docker 运行 Sql Server 2017:数据库还原与 SQL 脚本批量执行实战
1. 为什么要在 Docker 里跑 Sql Server 2017
把 Sql Server 2017 塞进 Docker 容器里跑,这件事在几年前还挺新鲜,现在已经是很多开发团队的常规操作了。我最早接触这个组合,是因为手头一个老项目需要做数据迁移验证,本地机器上不想装一整套 Sql Server 实例——安装包大、注册表写一堆、卸载还卸不干净,折腾一次少说半小时。后来改用容器,拉镜像、起容器、连上去,前后不到五分钟,用完直接删容器,机器干干净净。这个体验上的差距,是我后来一直坚持用容器跑数据库的根本原因。
具体来说,Docker 运行 Sql Server 2017 解决的核心问题是环境隔离与快速复现。你可以在同一台开发机上同时跑 Sql Server 2017、2019、2022 三个版本,各自监听不同端口,互不干扰;也可以把一套带数据的容器配置写成脚本,交给同事一键复现,不用再对着文档一步步装环境。对于需要频繁还原数据库、执行 SQL 脚本做验证的场景,这种能力尤其值钱。
这篇文章适合谁看?如果你是需要做数据库迁移验证的后端开发、要搭建测试环境的质量同学、或者单纯想学容器化数据库的运维新手,都能从下面找到可以直接抄作业的步骤。我会从镜像选型讲到容器启动、从数据库还原讲到 SQL 文件批量执行,中间穿插我自己踩过的坑和参数选择的理由。需要说明的是,Sql Server 2017 官方镜像已经比较老了,部分细节和更新版本有差异,我会在涉及的地方明确标注。
先给一个整体认知:整个流程分四步——拉镜像起容器、准备数据文件、还原数据库、执行 SQL 脚本。每一步都有几个容易翻车的点,尤其是文件权限和路径映射,后面会重点展开。
2. 环境准备与镜像选型的关键考量
2.1 镜像版本怎么选才不踩坑
Sql Server 2017 的官方镜像在镜像仓库里有多个标签,常见的有2017-latest、2017-CU31-ubuntu、2017-GDR等。我一般推荐用mcr.microsoft.com/mssql/server:2017-latest,理由很简单:latest标签指向该大版本下最新的累积更新,安全补丁和 bug 修复都包含在内,省得你自己去追 CU 编号。如果你对版本有严格锁定需求(比如要和线上环境完全一致),那就查清楚线上跑的是哪个 CU,用对应的精确标签,比如2017-CU31-ubuntu。
这里有个容易忽略的点:Sql Server 2017 的镜像基于 Ubuntu 16.04,这个基础镜像本身已经停止维护了。这意味着你拉下来的镜像里,系统层面的包是比较旧的。对于本地开发和测试验证,这通常不是问题;但如果要放到对安全基线有要求的环境里,就得评估一下。我的做法是本地验证用 2017,正式环境该升级就升级,别在一个停止维护的版本上死磕。
另外提醒一句,Sql Server 镜像对内存有硬性要求,官方文档写的是至少 2GB,实际跑起来加上还原数据库、执行脚本,我建议给到 4GB 以上。内存不够的时候容器会直接退出,日志里只留一句含糊的提示,排查起来很费劲。下面是我常用的启动参数表,先有个印象:
| 参数 | 推荐值 | 作用说明 |
|---|---|---|
-e ACCEPT_EULA | Y | 必须接受许可协议,否则容器起不来 |
-e MSSQL_SA_PASSWORD | 强密码 | SA 账户密码,需满足复杂度要求 |
-e MSSQL_PID | Developer | 版本类型,开发测试用 Developer 免费 |
-p | 1433:1433 | 端口映射,注意宿主机端口冲突 |
-v | 自定义路径 | 数据持久化与文件交换的关键 |
--memory | 4g | 限制内存,防止吃满宿主机 |
2.2 密码策略与许可协议这两个必填项
ACCEPT_EULA=Y和MSSQL_SA_PASSWORD是两个没有商量余地的环境变量。前者不接受容器直接退出,后者不设置或者设置得太简单,容器同样起不来。密码复杂度要求是:至少 8 位,且包含大写字母、小写字母、数字、符号中的至少三类。我见过有人设12345678然后对着日志找半天原因,其实日志第一行就写了密码不符合策略。
密码里如果包含特殊字符,在命令行里要注意转义。比如密码里有$,在 shell 里会被当变量展开,得用单引号包起来或者加反斜杠。我一般干脆避开$、!、反引号这类在 shell 里有特殊含义的字符,用@、#、_组合,省心。
2.3 数据持久化的目录规划思路
容器删了数据就没了,这是容器的基本特性。所以只要你的数据有价值,就必须做卷映射。我习惯映射两个目录:一个放数据库文件(mdf/ldf),一个放备份文件和待执行的 SQL 脚本。这样还原数据库时,容器内能直接读到宿主机上的 bak 文件,执行脚本时也能直接读到 sql 文件,不用反复docker cp。
目录规划上,我建议在宿主机建一个清晰的工作目录,比如:
mkdir -p /data/mssql/data mkdir -p /data/mssql/backup mkdir -p /data/mssql/scripts对应容器内映射到/var/opt/mssql/data、/var/opt/mssql/backup、/var/opt/mssql/scripts。注意容器内/var/opt/mssql是 Sql Server 的工作目录,权限归mssql用户所有。宿主机目录的权限问题后面单独讲,这是还原数据库时最常见的坑之一。
3. 启动容器与连接验证的完整实操
3.1 一条命令起容器及其参数逐项拆解
把前面的准备串起来,启动命令大概长这样:
docker run -d \ --name mssql2017 \ -e ACCEPT_EULA=Y \ -e MSSQL_SA_PASSWORD='YourStrong@Passw0rd' \ -e MSSQL_PID=Developer \ -p 1433:1433 \ --memory 4g \ -v /data/mssql/data:/var/opt/mssql/data \ -v /data/mssql/backup:/var/opt/mssql/backup \ -v /data/mssql/scripts:/var/opt/mssql/scripts \ mcr.microsoft.com/mssql/server:2017-latest逐项说一下为什么这么写。-d后台运行不用解释;--name给容器起个固定名字,后面docker exec进容器、看日志都靠它,比记容器 ID 方便太多;-p 1433:1433把容器端口映射到宿主机,如果你宿主机上已经有一个 Sql Server 在跑 1433,就改成-p 14330:1433之类,避免冲突;--memory 4g是软限制,防止数据库把宿主机内存吃光导致整机卡死,这个参数在开发机上尤其重要,我吃过一次没限制内存、容器把 16G 内存吃到只剩几百兆的亏。
MSSQL_PID=Developer这个参数值得单独说。Sql Server 有多个版本类型,Developer 版功能完整、免费,仅限开发和测试使用,正好匹配我们的场景。如果不设这个参数,默认可能是 Evaluation 版,有使用期限,到期后数据库会变成只读,到时候还原数据会莫名其妙失败,排查半天才发现是版本过期。这个坑我踩过一次,之后每次启动都显式指定。
3.2 容器启动后的健康检查
容器起来不代表数据库就绪。Sql Server 启动要初始化系统库,通常需要十几秒到半分钟。我一般用两种方式确认就绪:看日志和实际连接。
看日志:
docker logs -f mssql2017当日志里出现SQL Server is now ready for client connections这行,基本就绪了。如果看到This program can not be run as root或者密码相关的报错,说明启动参数有问题,容器会反复重启,这时候docker ps -a能看到状态是Restarting。
实际连接验证,如果你宿主机装了 sqlcmd 客户端,直接:
sqlcmd -S localhost,1433 -U sa -P 'YourStrong@Passw0rd' -Q "SELECT @@VERSION"没装客户端也没关系,进容器里用自带的 sqlcmd:
docker exec -it mssql2017 /opt/mssql-tools/bin/sqlcmd \ -S localhost -U sa -P 'YourStrong@Passw0rd' \ -Q "SELECT @@VERSION"注意 Sql Server 2017 镜像里 sqlcmd 的路径是/opt/mssql-tools/bin/,更新的镜像里改成了/opt/mssql-tools18/bin/,路径不对会报找不到命令。这个细节在混用不同版本镜像时特别容易搞混。
提示:如果连接时报
Login failed for user 'sa',先确认密码有没有被 shell 转义搞乱,再确认容器是不是还在初始化中。密码错误和未就绪的报错有时候长得像,多等十几秒再试一次往往就好了。
3.3 宿主机目录权限这个隐形炸弹
这是我要重点强调的一节,因为还原数据库失败十次里有八次是权限问题。容器内的 Sql Server 以mssql用户运行,这个用户在容器内的 UID 通常是 10001。当你把宿主机目录映射进容器,容器内进程对这个目录的读写权限,取决于宿主机目录的属主和权限位。
如果你在宿主机用 root 建的目录,默认权限 755,属主 root,那么容器内的 mssql 用户(UID 10001)对这个目录只有读和执行权限,没有写权限。表现出来就是:还原数据库时提示无法创建文件、或者备份文件读不到。解决办法是给目录放权:
chown -R 10001:10001 /data/mssql/data chown -R 10001:10001 /data/mssql/backup chmod -R 750 /data/mssql/data把属主改成 10001,和容器内 mssql 用户的 UID 对齐。备份目录和脚本目录如果只需要读,权限可以松一点,但数据目录必须可写。我一般三个目录统一处理,省得后面还原时又出幺蛾子。
还有一种情况:宿主机开了 SELinux 或者用了某些强制访问控制机制,即使权限位对了,容器还是访问不了。这时候可以在卷映射后面加:z或:Z标签,让系统自动处理上下文。不过这个和具体发行版有关,遇到访问被拒且权限位确认无误时,可以往这个方向查。
4. 数据库还原的两种路径与细节把控
4.1 从 bak 备份文件还原的标准流程
还原数据库是这套流程的核心诉求之一。假设你有一个demo.bak放在宿主机的/data/mssql/backup目录,容器内对应/var/opt/mssql/backup/demo.bak。还原前先做一件事:查备份文件里包含哪些逻辑文件名和物理路径。这一步很多人跳过,直接RESTORE DATABASE,然后报错说找不到文件或者路径不对。
查文件列表:
RESTORE FILELISTONLY FROM DISK = '/var/opt/mssql/backup/demo.bak';这条语句会返回结果集,重点看两列:LogicalName和PhysicalName。LogicalName是还原时MOVE子句要用的名字,PhysicalName是备份时原始数据库文件所在的路径,这个路径在容器里通常不存在,所以必须用MOVE重定向到容器内的有效路径。
完整的还原语句:
RESTORE DATABASE DemoDB FROM DISK = '/var/opt/mssql/backup/demo.bak' WITH MOVE 'DemoDB' TO '/var/opt/mssql/data/DemoDB.mdf', MOVE 'DemoDB_log' TO '/var/opt/mssql/data/DemoDB_log.ldf', REPLACE, STATS = 10;MOVE的两个逻辑名要和FILELISTONLY查出来的一致,物理路径指向映射的数据目录。REPLACE表示如果同名数据库已存在就覆盖,做反复验证时很有用。STATS = 10让还原过程每完成 10% 输出一次进度,长还原时心里有底。
如果备份文件很大,还原时间较长,建议在 sqlcmd 里执行时把超时设长一点,或者干脆用-t 0表示不超时。默认超时在某些客户端配置下可能只有几十秒,大库还原到一半连接断了,白等。
4.2 没有 bak 文件时用脚本建库的替代方案
有时候你手上没有备份文件,只有建表脚本和初始化数据脚本。这种情况就不需要还原,直接建一个空库,然后执行脚本。建库语句很简单:
CREATE DATABASE DemoDB;但这里有个细节:建库时如果不指定文件路径,数据库文件会落到默认位置,通常是/var/opt/mssql/data,正好是我们映射的目录,所以一般不用额外指定。如果你想让文件落到特定子目录,可以在CREATE DATABASE时用ON PRIMARY指定文件路径,不过对大多数验证场景没必要。
建完库之后就是执行 SQL 脚本,这部分下一节展开。这里先提一句:建库和执行脚本可以合并到一个 sqlcmd 调用里,用-i指定脚本文件,脚本开头写USE DemoDB;切换上下文,一气呵成。
4.3 还原过程中的常见报错与应对
还原环节的报错集中在几类,我整理成速查表:
| 报错信息关键词 | 大概率原因 | 处理方向 |
|---|---|---|
Cannot open backup device | 路径不对或权限不足 | 确认容器内路径、检查目录属主 |
The file ... cannot be overwritten | 目标文件已存在且未加 REPLACE | 加REPLACE或先删旧库 |
Logical file ... is not part of database | MOVE 的逻辑名写错 | 重新跑 FILELISTONLY 核对 |
Operating system error 5 | 权限被拒 | 检查 UID 10001 的目录权限 |
Exclusive access could not be obtained | 数据库正被连接占用 | 先ALTER DATABASE ... SET SINGLE_USER |
Exclusive access这个报错在反复还原时特别常见。你上一次还原后连上去看过数据,连接没断干净,再还原就报独占访问失败。解决办法是先强制单用户模式踢掉其他连接:
ALTER DATABASE DemoDB SET SINGLE_USER WITH ROLLBACK IMMEDIATE;然后再执行还原,还原完数据库会自动回到多用户模式。这个技巧在自动化脚本里很实用,我一般把它放在还原语句前面,作为固定套路。
5. 批量执行 SQL 文件的实操与技巧
5.1 用 sqlcmd 执行单个与多个脚本
执行 SQL 文件最直接的方式是 sqlcmd 的-i参数。单个文件:
docker exec -it mssql2017 /opt/mssql-tools/bin/sqlcmd \ -S localhost -U sa -P 'YourStrong@Passw0rd' \ -d DemoDB -i /var/opt/mssql/scripts/init.sql-d DemoDB指定默认数据库,脚本里就不用反复写USE。如果脚本里有中文,注意文件编码,sqlcmd 默认按当前代码页解析,UTF-8 带 BOM 的文件有时会出乱码或者语法错误。我的经验是把 SQL 文件存成 UTF-8 无 BOM,或者在 sqlcmd 加-f 65001指定编码。
多个文件批量执行,有两种思路。第一种是写一个总的入口脚本,里面用:r指令引用其他脚本:
:r /var/opt/mssql/scripts/01_schema.sql :r /var/opt/mssql/scripts/02_data.sql :r /var/opt/mssql/scripts/03_index.sql:r是 sqlcmd 的包含指令,按顺序把文件内容读进来执行,适合有明确依赖顺序的场景。第二种是在宿主机用 shell 循环:
for f in /data/mssql/scripts/*.sql; do docker exec -i mssql2017 /opt/mssql-tools/bin/sqlcmd \ -S localhost -U sa -P 'YourStrong@Passw0rd' \ -d DemoDB -i "/var/opt/mssql/scripts/$(basename $f)" done循环方式的好处是每个文件独立执行,某个文件失败不影响后续(取决于你要不要中断),日志也按文件分开,排查方便。缺点是每次docker exec有启动开销,文件多的时候慢一些。文件数量在几十个以内,两种方式差别不大,我一般用:r入口脚本,结构清晰。
5.2 脚本执行顺序与事务控制的取舍
批量执行脚本时,顺序很重要。建表要在插数据之前,建索引可以在插数据之后(数据量大时这样更快),外键约束的创建顺序也有讲究。我习惯按编号命名脚本文件,01_、02_前缀强制排序,配合:r入口脚本,顺序一目了然。
事务控制是个需要权衡的点。把整个脚本包在一个大事务里,出错可以整体回滚,听起来很美,但实际执行时如果脚本很长、数据量很大,事务日志会膨胀得厉害,甚至把日志文件撑爆。我的做法是:结构变更脚本(建表、改表)可以包事务,数据脚本按批次提交,比如每 1000 行COMMIT一次。这样出错时最多回滚一个批次,重跑成本可控。
注意:sqlcmd 默认遇到错误会继续执行后续语句,不会中断。如果你希望出错即停,加
-b参数,让 sqlcmd 在遇到错误时返回非零退出码并停止。自动化脚本里这个参数几乎是必加的,否则脚本中间报错了你还以为执行成功了。
5.3 执行结果的验证与日志留存
脚本执行完,别急着说搞定,做几个验证。查表数量、查关键表的行数、查有没有编译错误。我常用的验证查询:
SELECT COUNT(*) FROM sys.tables; SELECT COUNT(*) FROM sys.objects WHERE type = 'P';把预期值和实际值对一下,心里有数。另外,执行脚本时把输出重定向到日志文件,方便事后追溯:
docker exec -i mssql2017 /opt/mssql-tools/bin/sqlcmd \ -S localhost -U sa -P 'YourStrong@Passw0rd' \ -d DemoDB -i /var/opt/mssql/scripts/init.sql -b \ > /data/mssql/scripts/exec_$(date +%Y%m%d_%H%M%S).log 2>&1日志文件名带时间戳,多次执行不会互相覆盖。出问题时翻日志,比凭记忆靠谱得多。
6. 实操心得与高频问题排查
6.1 我踩过的几个典型坑
第一个坑是容器重启后数据丢失。早期我没做卷映射,容器一删数据全没,还原的库白做了。后来养成习惯,任何带数据的容器,启动命令里必须有-v,而且映射的是宿主机上明确的目录,不是匿名卷。匿名卷虽然也能持久化,但位置不好找,管理起来麻烦。
第二个坑是密码里的特殊字符。有一次密码设了带!的,在交互式 shell 里!触发历史展开,实际传给容器的密码和我想的不一样,连接一直失败。后来统一用单引号包密码,并且避开 shell 元字符,再没出过问题。
第三个坑是还原大库时超时。一个几 GB 的备份,还原到一半 sqlcmd 连接超时断开,但数据库还原其实还在后台跑。这时候如果急着重试,会撞上独占访问的报错。正确做法是等还原真正结束(看日志或查sys.dm_exec_requests),或者一开始就用-t 0关掉超时。
第四个坑是脚本编码。带中文注释的 SQL 文件,在某些编辑器里存成 GBK,sqlcmd 按 UTF-8 解析就乱码,甚至把注释后面的语句吞掉。统一 UTF-8 无 BOM 之后问题消失。这个坑不常遇到,但遇到一次能查半天。
6.2 高频问题速查表
| 现象 | 排查第一步 | 常见根因 |
|---|---|---|
| 容器起不来,状态 Restarting | docker logs看首行报错 | 密码不合规、未接受 EULA、内存不足 |
| 连不上 1433 端口 | docker port确认映射 | 端口冲突、容器未就绪 |
| 还原报无法打开备份设备 | 进容器ls备份目录 | 路径写错、目录权限不足 |
| 脚本执行报语法错误 | 检查文件编码和 BOM | 编码不匹配、特殊字符转义 |
| 中文数据变问号 | 检查排序规则和编码 | 库排序规则非中文、文件编码错 |
| 执行成功但数据不对 | 查@@ERROR和日志 | 脚本中途报错但未中断 |
中文乱码这个问题值得多说一句。Sql Server 的排序规则(Collation)决定了字符比较和存储行为,如果建库时用了默认的拉丁排序规则,存中文可能出问题。建库时显式指定中文排序规则更稳妥:
CREATE DATABASE DemoDB COLLATE Chinese_PRC_CI_AS;CI表示不区分大小写,AS表示区分重音,这是中文场景下常用的组合。还原的库则沿用备份里的排序规则,不用额外指定。
6.3 把整套流程脚本化的建议
手动敲命令验证一两次可以,反复做就得脚本化。我一般写两个脚本:一个start.sh负责起容器(带好所有参数和卷映射),一个restore_and_run.sh负责还原加执行脚本。脚本里把密码、路径这些做成变量放顶部,换环境只改变量。这样同事拿到脚本,改两行就能跑,比口头交接靠谱。
脚本化时注意幂等性:容器已存在就先删再起,数据库已存在就REPLACE还原,脚本重复执行不报错。幂等做得好,出问题重跑一遍就行,不用手工清理残留状态。这一点在 CI 环境里尤其重要,每次构建都是干净环境,脚本必须能从头跑通。
最后分享一个小技巧:把常用的 sqlcmd 调用封装成 shell 函数或者 alias,比如mssql()自动带上服务器、用户、密码参数,日常敲命令能省不少事。密码别硬编码在 alias 里,从环境变量读,安全一些。这套东西用熟之后,从零到能查数据,真的就是几分钟的事。