.NET 6 在 Linux Docker 中连接 SQL Server 2005/2008 R2:PreLogin 排障

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
.NETSQL ServerDockerLinuxMicrosoft.Data.SqlClient

我把一个运行多年的 ASP.NET Core 项目从 Windows 迁进 Linux Docker。应用启动了,静态文件也能访问,最后却死在数据库连接上。

数据库并不新:主库是 SQL Server 2008 R2,另外几个业务库还是 SQL Server 2005。升级数据库当然是长期答案,但它不是当天可以执行的一条命令。实例上有存储过程、旧客户端和多年没有动过的业务,升级要评估兼容性、停机窗口和回滚,不可能为了发布一个容器顺手完成。

更让我困惑的是,同一台 Linux 主机上的 Node.js 数据同步程序一直连接正常。它使用 mssqltedious,配置了 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 11tedious 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.cnf

unsupported 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=True

TrustServerCertificate=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.3TdsParser 源码后,关键点终于清楚了。

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;
}

OFFNOT_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");
}
#endif

LEGACY_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 降级不是最终修复

降低容器的 MinProtocolSECLEVEL 后,最初的 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中。