ZBLOG

nginx监听了443和8001端口,另一个进程由nginx的lua执行shell脚本运行,有一个奇特的现象,nginx退出后,由nginx启动的进程监听了nginx的端口!!!这是为什么,程序本身没有监听443和8001的代码

这是一个非常经典且值得深入探讨的问题。你遇到的现象并非程序本身的bug,而是Linux/Unix系统进程管理和网络栈机制的核心特性之一。

简单直接的回答是:因为子进程继承了父进程(nginx)已经打开的文件描述符(File Descriptors),其中就包括监听中的网络套接字(Sockets)。当父进程退出时,如果子进程没有主动关闭这些描述符,它们会继续保持打开状态,因此端口依然处于被监听状态。

下面我们从原理到排查一步步详细解释:


1. 核心原理:文件描述符的继承

在Unix-like系统中,当一个进程(父进程)通过 fork() + exec() 的方式创建另一个进程(子进程)时,子进程默认会继承父进程所有打开的文件描述符(除非显式设置了 FD_CLOEXEC标志)。

  • Nginx:主进程以root身份启动,绑定 privileged ports (如443),然后通过 fork() 出worker进程来处理连接。这些worker都继承了监听套接字。
  • 你的Lua脚本:当Nginx的Lua模块(如 lua-nginx-module)通过 os.executeio.popen 等函数执行shell脚本时,这个shell脚本进程就是由Nginx的worker进程 fork() + exec() 出来的。
  • 继承的发生:如果你的Shell脚本或其最终执行的程序(我们称之为Program X)是从Nginx worker进程继承而来,那么它就有可能继承了Nginx已经打开的、用于监听443和8001端口的套接字描述符。

2. 为什么父进程退出后端口仍被监听?

这涉及到另一个核心概念:文件描述符是系统级的资源,其生命周期独立于打开它的进程

  • 一个网络套接字(socket)在被打开并绑定到某个端口后,只要还有任何一个进程持有指向它的文件描述符,这个套接字就会一直存在并处于监听状态。
  • 当Nginx主进程退出时,它会关闭所有文件描述符。但是,如果在其退出之前,已经有子进程(你的Program X)继承了这些描述符,那么对于内核来说:
    1. Nginx主进程关闭了它的描述符副本 -> 引用计数减1。
    2. 由于Program X仍然持有该描述符的副本 -> 引用计数大于0。
    3. 因此内核不会释放该套接字资源,端口也就继续处于监听状态。

netstatss 命令的视角来看,它只关心是哪个PID在持有这个套接字。既然Nginx退出了,那么现在持有它的PID就变成了你的Program X的PID。


3. 如何验证和排查?

你可以通过以下命令清晰地看到整个过程:

  1. 找到监听端口的原始进程

    # 在Nginx运行时,查看443端口由谁监听
    sudo ss -tlpn | grep ':443\b'
    # 输出会显示 LISTEN, PID=nginx_pid, PROCESS_NAME=nginx
    
  2. 找到可疑的子进程

    # 使用pstree查看nginx的进程树,寻找它启动的子shell或程序
    pstree -p <nginx_main_pid>
    # 或者广泛地查找谁在调用shell
    ps auxf | grep -A5 -B5 'nginx'
    
  3. 关键步骤:查看具体进程打开的文件描述符

    # 首先找到疑似继承了端口的那个Program X的PID
    suspect_pid=<PROGRAM_X_PID>
    
    # 查看这个进程中所有打开的文件描述符列表
    sudo ls -la /proc/$suspect_pid/fd
    
    # 你会看到像这样的输出:
    # lrwx------ ... 0 -> /dev/pts/0
    # lrwx------ ... 1 -> /dev/pts/0
    # lrwx------ ... 2 -> /dev/pts/0
    # lrwx------ ... 3 -> socket:[<SOCKET_INODE_NUMBER>]
    # lrwx------ ... 4 -> socket:[<SOCKET_INODE_NUMBER>]
    
    # socket:[XXXXX] 就是网络套接字描述符。
    

    为了确认这些套接字就是443和8001端口,可以对比它们的inode号。

  4. 对比Socket Inode号

    # 查看当前系统中所有监听状态的套接字及其inode号
    sudo ss -tlpne | grep -E '(LISTEN|inode)'
    
    # 找到443和8001端口对应的inode号(例如12345)
    # 然后再回到 /proc/$suspect_pid/fd/ 目录下,
    #	你会发现某个文件描述符指向 `socket:[12345]`。
    #	这就铁证如山了。
    

.NET/.NET Core中的情况

虽然你提到程序本身没有监听代码,但需要注意:.NET Core运行时或某些库可能会重用已继承的socket。例如:

  • Kestrel:ASP.NET Core的默认服务器。在某些配置或旧版本中,如果它发现已经有现成的、符合要求的监听套接字存在(比如从父进程继承来的),它可能会直接使用它而不是抛出“地址已使用”的异常。
  • HttpListener等类:也可能有类似的行为。

这意味着即使你的C#代码里没有写 Listen(443),运行时环境也可能“意外地”激活了那个继承来的 socket。

.NET/.NET Core解决方案

对于.NET程序来说,最佳实践是在程序启动时明确关闭不需要的文件描述符。

  1. 设置句柄不继承

.NET Core / .NET >=5.0提供了更精细的控制。你可以在创建HttpListenerTcpListenersocket时显式设置socketOptionName.ReuseAddress等选项,但更根本的是防止继承。

   // .NET Core >=3.0 SDK环境下,新创建的ProcessStartInfo可以设置标准句柄不继承.
   var processStartInfo = new ProcessStartInfo 
   {
        FileName = "your-program",
        UseShellExecute = false,
        RedirectStandardInput = true,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        // .NET Core >=3.0: 
        // Setting this to true prevents the child process from inheriting any handles 
        // from the parent that are not explicitly intended for communication (std in/out/err).
        // This is CRITICAL.
        CreateNoWindow = true // Also helps on Windows, no effect on inheritance on Unix.
   };
   
   // Unfortunately, for the current process itself (your C# app started by nginx), 
   // you need a different approach.
   
  1. (推荐)启动时清理所有无关的开放句柄 (Unix/Linux):

    最彻底的方式是在程序入口处(Main函数)立即关闭所有从父进程中继承而来、且非必要的文件描述符(通常是除了stdin/stdout/stderr之外的所有fd)。

     using System;
     using System.IO;
     using System.Runtime.InteropServices;
    
    
     public class Program
     {
         [DllImport("libc", SetLastError = true)]
         private static extern int close(int fd);
    
    
         public static void Main(string[] args)
         {
             // Close all inherited file descriptors except stdin(0), stdout(1), stderr(2).
             // This is a common practice for daemons and services on Unix.
    
    
             // Get the highest possible file descriptor number we might need to check.
             // One way is to check the /proc/self/fd directory.
             string fdDirPath = "/proc/self/fd";
             if (Directory.Exists(fdDirPath))
             {
                 try
                 {
                     foreach (string fdLink in Directory.EnumerateFiles(fdDirPath))
                     {
                         if (int.TryParse(Path.GetFileName(fdLink), out int fdNum))
                         {
                             if (fdNum > STDERR_FILENO) // Close everything beyond stderr (2)
                             {
                                 close(fdNum); // Ignore errors - might fail for valid reasons (e.g., no permission)
                             }
                         }
                     }
                 }
                 catch(Exception ex)
                 {
                     // Log warning, but proceed. Might not have permissions or not on Linux.
                     Console.Error.WriteLine($"Warning: Failed to clean up inherited file descriptors: {ex.Message}");
                 }
             }
             else
             {
                 // Fallback: If /proc doesn't exist, try closing a range of FDs (less elegant)
                 for (int fd = STDERR_FILENO + 1; fd < MAX_SAFE_FD; ++fd) 
                 { 
                      close(fd); 
                 } 
             }
    
    
             // ... Your actual application code starts here ...
    		 Console.WriteLine("Started after cleaning inherited FDs.");
         }
    
    
         private const int STDERR_FILENO = 2;
         private const int MAX_SAFE_FD = 1024; // Classic Unix limit, adjust if needed
     }
    

    注意:上述代码需要运行在Linux环境下,并且需要适当的权限(通常需要与父进��相同的用户或root权限才能关闭某些fd)。在生产环境中使用时需增加更多错误处理和日志。

Nginx Lua侧的解决方案

除了修改你的程序,更合理的做法是从源头(Nginx Lua脚本)避免这个问题:

    
-- os.execute会继承所有fd,这是危险的!
-- os.execute("your_script.sh &")

-- Instead, use a method that allows controlling file descriptors.

-- Using 'sudo' or 'setsid' with redirection can help create separation,
-- but the most robust way is if your shell script itself closes FDs.

-- Example: invoke shell with input/output redirected and close other FDs in the shell itself.
local command = "your_script.sh >/dev/null </dev/null "

-- The below is complex and error-prone. Better to fix the called program itself.
os.execute(command)

2.最佳实践:强烈建议修改被Nginx Lua调用的那个shell脚本或其最终执行的程序(Program X) ,使其在启动初期就主动关闭所有非必需的文件描述符(如上文C#示例所示)。这才是最根本、最可靠的解决方法。

总结一下,你遇到的并不是灵异事件,而是Unix系统一个基础且强大的特性。理解了文件描述符的继承与生命周期,就能很好地解释和解决这个问题。


内容由零声教学AI助手提供,问题来源于学员提问

本站部分文章来源于网络,版权归原作者所有,如有侵权请联系站长删除。
转载请注明出处:https://golang.0voice.com/?id=24251

分享:
扫描分享到社交APP
上一篇
下一篇
发表列表
游客 游客
此处应有掌声~
评论列表

还没有评论,快来说点什么吧~

联系我们

在线咨询: 点击这里给我发消息

微信号:3007537140

上班时间: 10:30-22:30

关注我们
x

注册

已经有帐号?