Ubuntu系统中,Nginx默认运行在AppArmor的严格沙箱里,这本身是好事。但问题出在当你使用fastcgi_pass或proxy_pass对接后端时,Nginx的worker进程会试图派生或与外部Unix socket通信。一旦配置文件里定义了子进程执行路径,而AppArmor策略没有显式放行,你就会在系统日志里看到一连串的DENIED信息,子进程直接被内核拦截,Nginx返回502或504。这不是Nginx的bug,而是AppArmor的强制访问控制在工作。

理解Nginx子进程“逃逸”的本质

所谓“子进程逃逸”,并不是真的有人入侵了你的服务器。它指的是Nginx master进程fork出来的worker,在需要调用外部CGI程序、反向代理到本地服务、或者通过proxy_pass连接本地Unix socket时,被AppArmor的配置文件阻止了execve、unix套接字读写、甚至是目录遍历权限。你会在/var/log/syslog里看到类似“apparmor=DENIED operation=exec profile=/usr/sbin/nginx name=/path/to/your/script”的记录。此时Nginx worker无法完成既定动作,请求失败。

很多运维人员的第一反应是直接禁用AppArmor,执行aa-teardown或者systemctl stop apparmor。这等于把整个强制访问控制体系关掉了,所有进程都失去了这层防护。更合理的做法是精准修改Nginx的AppArmor配置文件,让子进程在受控范围内获得必要的权限。

定位当前Nginx的AppArmor策略

先确认Nginx是否真的被AppArmor管控。执行aa-status并过滤nginx,你会看到类似“/usr/sbin/nginx”处于enforce模式。配置文件通常位于/etc/apparmor.d/usr.sbin.nginx。用cat查看这个文件,你会看到类似下面的结构:

#include 

/usr/sbin/nginx {
  #include 
  #include 

  capability net_bind_service,
  capability setgid,
  capability setuid,
  capability dac_override,

  /etc/nginx/ r,
  /usr/share/nginx/ r,
  /var/log/nginx/* w,
  /var/run/nginx.pid w,
  /run/nginx.pid w,
  /var/www/ r,
  /etc/ssl/openssl.cnf r,
  /etc/ssl/private/ r,
}

这个默认策略只允许Nginx读取静态文件、写日志、绑定80和443端口。任何exec权限、对/tmp或/dev/shm的访问、对其他Unix socket的读写,统统被禁止。如果你的Nginx配置了proxy_pass http://unix:/var/run/backend.sock,或者fastcgi_pass unix:/var/run/php-fpm.sock,AppArmor会直接拦截。

放行Unix socket通信的精准配置

假设你的后端服务是PHP-FPM,它监听在/run/php/php8.1-fpm.sock。你需要在Nginx的AppArmor策略文件里添加对该socket文件的读写权限。打开/etc/apparmor.d/usr.sbin.nginx,在花括号内追加:

  /run/php/php8.1-fpm.sock rw,

如果PHP-FPM的socket放在其他自定义路径,比如/var/run/php-fpm/custom.sock,那就把路径改成实际值。修改后保存文件,执行apparmor_parser -r /etc/apparmor.d/usr.sbin.nginx重新加载策略。此时Nginx worker就能正常与PHP-FPM通信了,502错误立刻消失。

对于proxy_pass到本地其他Unix socket的场景,同理添加对应路径。比如你有一个Gunicorn服务监听在/run/gunicorn/socket,就加上:

  /run/gunicorn/socket rw,

这里有一个容易被忽略的细节:socket文件所在的目录层级必须具有执行权限,否则进程无法遍历到该文件。如果/run/gunicorn目录本身没有被放行,即使socket文件加了rw也无效。你需要同时添加目录的访问权限:

  /run/gunicorn/ r,
  /run/gunicorn/socket rw,
处理Nginx反向代理到本地TCP端口

有些后端服务监听在127.0.0.1的高位端口,比如127.0.0.1:8080。Nginx的proxy_pass http://127.0.0.1:8080需要建立TCP连接。默认的AppArmor策略通常已经允许网络操作,但如果你的策略特别严格,或者使用了自定义网络命名空间,可能需要显式添加网络权限:

  network inet stream,
  network inet dgram,

这两行允许Nginx使用TCP和UDP套接字。一般情况下默认策略已经包含,但如果你是从最小化模板开始构建的,记得补上。

允许Nginx执行外部子进程的极端场景

有些老旧应用依赖Nginx的ngx_http_cgi_module或者通过Lua脚本调用os.execute来运行外部程序。这种情况下,Nginx worker需要execve权限。这是安全风险极高的操作,AppArmor默认严格禁止。如果你确实需要,比如运行一个位于/usr/local/bin/convert的图像处理脚本,你需要在策略文件里添加:

  /usr/local/bin/convert rix,

rix表示read、execute、inherit,允许Nginx以自身权限执行该程序。但这里有一个陷阱:如果convert脚本内部又调用了其他命令,比如调用/bin/sh或/usr/bin/rm,AppArmor会再次拦截,因为子进程继承了Nginx的profile,而Nginx的profile没有放行那些命令。你需要把整个调用链上的所有可执行文件都加进去,或者使用子配置文件为特定脚本创建独立profile。这种做法维护成本极高,而且严重削弱安全隔离效果。更好的方案是把这类任务交给消息队列或systemd服务,让Nginx只负责触发,实际执行由独立进程完成。

调试AppArmor拒绝日志的实用技巧

遇到Nginx返回502或504时,第一时间检查AppArmor日志。使用journalctl -k | grep DENIED或者直接查看/var/log/syslog。日志里会明确写出被拒绝的操作类型、目标文件路径、以及对应的profile名称。例如:

apparmor="DENIED" operation="connect" profile="/usr/sbin/nginx" name="/run/php/php8.1-fpm.sock" pid=12345 comm="nginx" requested_mask="wr" denied_mask="wr"

这条日志告诉你Nginx的worker进程(pid 12345)试图连接/run/php/php8.1-fpm.sock,被拒绝了。你只需要把日志里的name字段对应的路径加到策略文件里,赋予相应的mask权限即可。

如果日志量太大,可以临时把Nginx的profile切换到complain模式,让AppArmor只记录不拦截。执行aa-complain /usr/sbin/nginx,然后复现问题,收集所有DENIED日志,一次性补全策略,最后再用aa-enforce /usr/sbin/nginx切回强制模式。这个流程比反复修改配置文件高效得多。

防止Nginx子进程逃逸到敏感目录

安全加固的另一个方向是限制Nginx worker能访问的目录范围。默认策略里/var/www/ r这一行允许Nginx读取/var/www下所有文件。如果你的多个虚拟主机共享同一台服务器,某个站点被入侵后,攻击者可能通过Nginx读取其他站点的配置文件或源码。你可以把这一行细化,为每个站点单独指定路径:

  /var/www/site1/ r,
  /var/www/site2/ r,

同时禁止Nginx访问/etc/passwd、/etc/shadow等系统敏感文件。虽然默认策略没有放行这些路径,但如果你之前为了调试而添加了过于宽泛的规则,比如/etc/ r,现在应该立即收紧。

对于/tmp和/dev/shm这类所有用户可写的目录,Nginx默认没有访问权限。但有些应用会在/tmp下创建临时文件供Nginx读取。如果你必须放行,务必限定到特定子目录:

  /tmp/nginx_temp/ rw,

千万不要直接放行/tmp/,这会让Nginx worker能够读取任何用户在/tmp下的临时文件,包括其他服务的敏感数据。

处理Nginx与Lua模块的AppArmor冲突

OpenResty或ngx_lua模块在Nginx内部执行Lua脚本,这些脚本可能会调用os.execute、io.popen、或者通过FFI调用C库。AppArmor对这类行为的拦截尤为严格。如果你使用Lua来读取本地文件或执行外部命令,需要在策略文件里添加对应的文件路径和exec权限。但更推荐的做法是把Lua脚本的权限控制在Nginx内部,避免调用外部可执行文件。如果必须调用,考虑把外部程序封装成HTTP接口,让Nginx通过proxy_pass访问,这样就不需要exec权限了。

对于Lua通过FFI加载的.so文件,AppArmor要求这些库文件具有可读和可执行权限。你需要在策略里添加:

  /usr/local/lib/lua/ rm,
  /usr/local/openresty/ rm,

路径根据你的实际安装位置调整。注意这里只需要rm(read、mmap),不需要ix,因为.so文件是通过dlopen加载的,不是通过execve执行。

多层级AppArmor策略与Nginx的嵌套限制

如果你的Nginx worker本身又启动了其他服务,比如通过proxy_pass连接到一个同样受AppArmor保护的本地服务,两个profile会叠加生效。此时需要确保两个profile之间没有冲突。例如Nginx的profile允许访问某个socket,但后端服务的profile禁止该socket被外部访问,这会导致连接失败。排查这种问题时,需要同时检查两个profile的日志,确保权限链完整。

还有一种情况是Nginx运行在容器里,宿主机的AppArmor和容器内的AppArmor策略同时生效。这时的调试复杂度更高,建议在容器内执行aa-status确认当前生效的策略,然后逐层排查。

通过精准修改AppArmor策略,你完全可以让Nginx在严格沙箱里正常运行,同时保留AppArmor对系统其他进程的全面保护。这种细粒度的权限控制,远比直接关闭AppArmor来得安全,也是生产环境必须掌握的运维技能。