.NET 6 在 Linux Docker 中连接 SQL Server 2005/2008 R2:PreLogin 排障
我把一个运行多年的 ASP.NET Core 项目从 Windows 迁进 Linux Docker。应用启动了,静态文件也能访问,最后却死在数据库连接上。
数据库并不新:主库是 SQL Server 2008 R2,另外几个业务库还是 SQL Server 2005。升级数据库当然是长期答案,但它不是当天可以执行的一条命令。实例上有存储过程、旧客户端和多年没有动过的业务,升级要评估兼容性、停机窗口和回滚,不可能为了发布一个容器顺手完成。
更让我困惑的是,同一台 Linux 主机上的 Node.js 数据同步程序一直连接正常。它使用 mssql 和 tedious,配置了 encrypt: false。网络、账号和数据库服务显然都没有整体失效,只有 .NET 容器起不来。
这次故障前后出现了三种不同错误。它们看起来分别属于网络、TLS 和超时,实际都发生在 SQL Server 登录协议附近。
环境
- 应用:ASP.NET Core 6;
- 容器基础镜像:
mcr.microsoft.com/dotnet/aspnet:6.0; - 宿主机:Ubuntu 22.04;
- 驱动:最终统一到
Microsoft.Data.SqlClient 5.2.3; - 数据库:SQL Server 2008 R2 和 SQL Server 2005;
- 对照程序:Node.js、
mssql 11、tedious 19,显式关闭连接加密。
文中的地址、账号和密码都使用占位符。
第一个错误:OpenSSL 不接受旧协议
最早的异常发生在 Pre-Login:
A connection was successfully established with the server,
but then an error occurred during the pre-login handshake.
SSL Handshake failed with OpenSSL error - SSL_ERROR_SSL.
ssl_choose_client_version:unsupported protocol这段日志已经说明 TCP 连接成功。防火墙、端口和路由不是首要嫌疑,失败点是客户端与服务端准备登录时进行的 TLS 握手。
当前 Linux 发行版的 OpenSSL 默认不再接受 TLS 1.0 和旧密码套件。微软的 SQL Server TLS 1.2 支持说明列出了各版本所需补丁:SQL Server 2005 不支持 TLS 1.2;SQL Server 2008/2008 R2 也只有安装到特定补丁版本后才支持 TLS 1.2。老实例没有这些能力时,双方找不到共同协议。
我先在专用容器里降低了 OpenSSL 的协议和安全等级:
FROM mcr.microsoft.com/dotnet/aspnet:6.0
RUN sed -i \
-e 's/MinProtocol = TLSv1.2/MinProtocol = TLSv1/g' \
-e 's/CipherString = DEFAULT@SECLEVEL=2/CipherString = DEFAULT@SECLEVEL=1/g' \
/etc/ssl/openssl.cnfunsupported protocol 消失了,但应用仍然没有启动成功。
第二个错误:变成了 Post-Login 超时
修改 OpenSSL 后,异常变成:
Connection Timeout Expired.
The timeout period elapsed during the post-login phase.
[Pre-Login] initialization=54; handshake=208;
[Login] initialization=1; authentication=9;
[Post-Login] complete=14011;这个错误很容易把人带到连接池、数据库负载或超时时间上。日志里有一个接近 14 秒的等待,把默认 15 秒改成 30 秒似乎也说得通。
我试过,没用。它只会让失败来得更晚。
网络已经连接,Pre-Login 也只用了两百多毫秒。真正异常的是登录状态没有正常收尾。这里继续调整 Connection Timeout 没有诊断价值。
TrustServerCertificate 解决不了协议不兼容
Microsoft.Data.SqlClient 的驱动支持周期与兼容表体现了它面向较新 SQL Server 版本的支持边界。升级后,现代版本默认更倾向于加密连接,并验证服务端证书。连接使用自签名证书或没有可信证书链时,需要显式配置:
Encrypt=True;TrustServerCertificate=True对于确定不能进行现代 TLS 的旧实例,则是:
Encrypt=False;TrustServerCertificate=TrueTrustServerCertificate=True 的作用是跳过证书链验证。它不会给 SQL Server 2005 增加 TLS 1.2,也不会改变 TDS PreLogin 数据包向服务端声明的加密能力。
因此,证书错误可以靠它消除,unsupported protocol 和随后出现的登录超时却不能靠它解决。这几个问题不能混在一起。
先统一驱动,再继续定位
项目里同时存在两个 SQL Server 驱动:
System.Data.SqlClient
Microsoft.Data.SqlClient这是 .NET 生态迁移留下的真实状态。微软当年为了让新驱动能和 .NET Framework 内置的旧驱动并存,创建了新的命名空间。结果是一个项目可以在 EF Core、Dapper 和工具代码中悄悄使用不同实现。
如果不先统一,连接字符串改了也无法确定最终由哪个驱动解释。我把 Dapper、EF Core 和工具项目统一到了:
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.3" />微软介绍 Microsoft.Data.SqlClient 新命名空间的文章说明了新包与旧命名空间并存的背景。所有 SqlConnection 引用也改成 Microsoft.Data.SqlClient.SqlConnection。这样做没有直接修好连接,但消除了第二套网络栈和第二套默认值。
真正的问题在 Managed SNI 的 PreLogin 声明
Microsoft.Data.SqlClient 的包说明指出,Unix 默认使用 Managed SNI,Windows 则通常使用 Native SNI。两边不是同一套底层实现。一个项目在 Windows 上能连接,并不能证明 Linux 上会走过完全相同的登录过程。
查看 Microsoft.Data.SqlClient 5.2.3 的 TdsParser 源码后,关键点终于清楚了。
TdsParser 初始化时从 SNI 工厂读取支持的加密选项:
private static EncryptionOptions s_sniSupportedEncryptionOption =
TdsParserStateObjectFactory.Singleton.EncryptionOptions;
private EncryptionOptions _encryptionOption =
s_sniSupportedEncryptionOption;构造 PreLogin 数据包时,ENCRYPT 选项有一条不常见的分支:
if (_encryptionOption == EncryptionOptions.NOT_SUP)
{
// 客户端声明不支持加密
payload[payloadLength] = (byte)EncryptionOptions.NOT_SUP;
}
else if (encrypt == SqlConnectionEncryptOption.Mandatory)
{
payload[payloadLength] = (byte)EncryptionOptions.ON;
}
else
{
payload[payloadLength] = (byte)EncryptionOptions.OFF;
}OFF 和 NOT_SUP 不是一个意思。前者是“不要求整条连接加密”,后者是“客户端不支持加密”。微软的 TLS 说明还特别提到,即使没有启用完整加密通道,SQL Server 在身份验证阶段仍会处理登录信息的加密,因此 Encrypt=False 不能简单理解成“登录阶段绝不会碰 TLS”。
在这批 SQL Server 2005/2008 R2 实例、Linux Managed SNI 和当前系统 OpenSSL 的组合下,只有让客户端在 PreLogin 明确发送 NOT_SUP,登录才能完成。
没有公共开关,只能做版本绑定的兼容处理
s_sniSupportedEncryptionOption 是私有静态字段。Microsoft.Data.SqlClient 5.2.3 没有提供等价的公共连接字符串选项或 AppContext 开关。
最终处理是在任何 SqlConnection 创建之前,通过反射把该字段设置为 NOT_SUP:
#if LEGACY_SQL_SERVER
if (OperatingSystem.IsLinux())
{
// Linux 使用 Managed SNI。旧版 SQL Server 会卡在登录阶段的临时 TLS 通道。
// 必须在创建任何 SqlConnection 前修改 PreLogin 加密能力声明。
var sqlClientAssembly = typeof(Microsoft.Data.SqlClient.SqlConnection).Assembly;
var tdsParserType = sqlClientAssembly.GetType(
"Microsoft.Data.SqlClient.TdsParser",
throwOnError: true)!;
RuntimeHelpers.RunClassConstructor(tdsParserType.TypeHandle);
var encryptionOptionField = tdsParserType.GetField(
"s_sniSupportedEncryptionOption",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
"当前 Microsoft.Data.SqlClient 已不包含旧版 SQL Server 兼容字段。请重新验证兼容实现。");
encryptionOptionField.SetValue(
null,
Enum.Parse(encryptionOptionField.FieldType, "NOT_SUP"));
Log.Warning(
"已启用 Linux 旧版 SQL Server 登录兼容模式:SqlClient PreLogin 使用 ENCRYPT_NOT_SUP");
}
#endifLEGACY_SQL_SERVER 是文章里的示例编译符号,不是 .NET 内置符号。只有明确需要连接旧实例的发布目标才定义它。这样,其他宿主即使引用同一个项目,也不会把这段进程级兼容代码编译进去。
编译条件还不够,所以内部继续判断 OperatingSystem.IsLinux()。Windows 通常使用 Native SNI,这次问题发生在 Linux Managed SNI,不能把补丁扩大到没有出现问题的平台。
反射字段找不到时直接终止启动也是有意为之。它表示当前 SqlClient 版本已经改变了内部实现,需要重新检查源码和连接行为。此时静默跳过补丁,只会让应用重新陷入难以解释的登录超时。
部署后,日志先打印兼容模式提示,随后 EF Core、Dapper、后台任务和 Web Host 均完成初始化。登录、验证码和管理端的实际业务请求也正常通过。到这里,故障才算解决。
为什么 Node.js 程序一直能连
作为对照的 Node.js 程序使用 node-mssql 及其默认的 tedious 驱动,连接参数明确设置了:
const config = {
server: process.env.SQL_SERVER_HOST,
database: process.env.SQL_SERVER_DATABASE,
user: process.env.SQL_SERVER_USER,
password: process.env.SQL_SERVER_PASSWORD,
options: {
encrypt: false,
trustServerCertificate: true,
},
};这不能证明 Node.js 在所有旧 SQL Server 环境下都更兼容。tedious 的 issue 中同样能找到 Node 与 OpenSSL 版本变化后连接 SQL Server 2008 R2 失败的记录。
但对这个具体系统来说,事实很直接:Node 程序在同一台 Linux 主机、同一批数据库上持续工作,.NET 程序失败。这个对照帮助我及时停止检查防火墙和账号,转向 SqlClient 的登录实现。
使用这段兼容代码前要确认什么
这段反射修改的不是某一条连接字符串,而是 Microsoft.Data.SqlClient 在当前进程中的静态字段。设置完成后,这个进程创建的所有 Microsoft.Data.SqlClient.SqlConnection 都会受到影响。
这也是为什么代码外面需要条件编译:它不能作为普通的 Linux 初始化代码,默认进入所有发布目标。
同一个进程不能混用两套要求
如果当前进程只连接 SQL Server 2005、未修补的 SQL Server 2008 R2,并且这些连接都需要 ENCRYPT_NOT_SUP,可以把它作为临时兼容措施。
如果同一个进程还要连接要求 TLS 的现代 SQL Server,就不能这样全局修改。旧实例和现代实例应拆到不同进程,或者先找到连接级别的实现。否则,为旧实例关闭 PreLogin 加密能力的同时,也会影响本来可以安全连接的新实例。
升级 SqlClient 时必须重新验证
文章中的字段名来自 Microsoft.Data.SqlClient 5.2.3。它是私有实现,不属于兼容性承诺。驱动升级后,字段可能改名、改变类型或被删除。
因此代码在字段不存在时直接报错。升级驱动的验收也不能只有编译通过,必须从 Linux 环境实际连接旧实例。只有真实登录成功,才能确认兼容处理仍然有效。
OpenSSL 降级不是最终修复
降低容器的 MinProtocol 和 SECLEVEL 后,最初的 unsupported protocol 消失了,但应用只是从 PreLogin 握手失败变成 Post-Login 超时。这一步帮助我确认了旧协议问题,没有解决整个登录过程。
真正让连接完成的是 PreLogin 的 ENCRYPT_NOT_SUP。启用反射兼容后,应重新制作一份不降低 OpenSSL 安全等级的镜像进行验证。如果旧实例仍能全部连接,就删除 OpenSSL 修改。保留它会降低整个容器中所有 TLS 客户端的安全下限,而不只影响 SqlClient。
适用条件
只有同时满足下面这些条件,我才会暂时使用这段代码:
- 应用运行在 Linux,确认使用的是 Managed SNI;
- 目标是暂时无法升级的 SQL Server 2005 或旧 SQL Server 2008/2008 R2;
- 进程内没有要求现代 TLS 的其他 SQL Server 连接;
- 数据库位于受控网络,并且已经接受暂时不使用现代 TLS 的风险。
数据库直接暴露在公网、同一进程混合连接新旧实例,或者团队无法固定并验证 SqlClient 版本时,都不应该使用这个方案。
什么时候删除
SQL Server 2005 没有 TLS 1.2 支持。SQL Server 2008/2008 R2 则可以通过微软列出的补丁版本获得 TLS 1.2。实例完成升级或补丁更新,并确认能够正常使用现代 TLS 后,依次删除反射兼容代码和 OpenSSL 降级,再重新验证所有连接。
数据库升级仍然需要做,只是不该被描述成一次客户端发布的简单前置步骤。升级驱动可能只要十分钟,升级一个承载旧业务的数据库实例却可能需要数周。两者不是同一个工作量。
最后
错误先后指向网络、证书和数据库响应时间,但实际卡点一直在登录协议附近。Linux 使用 Managed SNI,OpenSSL 拒绝旧协议,而 Encrypt=False 没有让登录阶段的加密协商消失。把超时从 15 秒改成 30 秒只会延后失败。
这段反射代码只适合已经接受风险、能够固定 SqlClient 版本的遗留系统。它让我重新评估了跨平台迁移中的诊断成本,相关取舍写在我为什么越来越少在遗留业务中使用 .NET中。