☰
CentOS离线部署Apache+WSGI:从依赖闭环到生产调优
2026/10/14 1:29:55 网站建设 项目流程

1. 为什么内网环境还要折腾 Apache + WSGI

有些部署场景天生就跟外网绝缘。比如某公司的内部业务系统跑在隔离网段里,服务器只有内网 IP,yum 源够不着,pip 也连不上公网。这种环境下要部署一个 Python Web 应用,很多人第一反应是"那用 Flask 自带的开发服务器凑合一下",但那个东西单线程、无并发、重启就断,放到生产环境里纯粹是给自己埋雷。

Apache + WSGI 这套组合在内网环境里其实非常能打。Apache 本身足够稳定,模块化设计让它可以只加载必要的组件,资源占用可控;WSGI 作为 Python Web 应用和 Web 服务器之间的标准接口,把请求转发这件事标准化了,应用代码不需要关心底层是 Apache 还是别的什么。两者配合起来,既能扛住内网里几十上百人的并发访问,又不需要引入额外的大型组件。

这篇文章面向的是需要在完全离线的 CentOS 服务器上搭建 Apache + WSGI 环境的运维人员或后端开发者。我会把整个流程拆开,从依赖包的离线获取、编译参数的取舍、模块加载的配置,一直到应用接入和常见故障排查,每一步都给出具体的命令和背后的理由。你照着做,大概率能一次跑通;就算中间卡住了,排查思路也在里面。

需要提前说明的是,离线搭建的核心难点不在于"装什么",而在于"怎么把装的东西弄进去"以及"编译时依赖怎么闭环"。这两件事想清楚了,剩下的就是按部就班的配置工作。

2. 离线包的获取与依赖闭环

2.1 在一台联网机器上准备所有 RPM 包

离线环境最怕的就是装到一半发现缺依赖。CentOS 的 RPM 包依赖关系有时候藏得很深,比如 httpd 依赖 apr、apr-util、pcre,而 mod_wsgi 又依赖 httpd-devel 和 python-devel,这些包之间还有交叉依赖。手动一个个下载几乎必然漏东西。

正确的做法是找一台跟目标服务器版本完全一致的联网 CentOS 机器(最好是同一个小版本,比如都是 CentOS 7.9),用yumdownloader把依赖树一次性拉全:

# 安装 yum-utils,它提供了 yumdownloader 和 repotrack 工具 yum install -y yum-utils # 创建存放 RPM 包的目录 mkdir -p /tmp/offline-packages cd /tmp/offline-packages # 用 repotrack 下载 httpd 及其全部依赖 repotrack httpd httpd-devel mod_wsgi python-devel # 如果 repotrack 不可用,用 yumdownloader 配合 --resolve 也可以 yumdownloader --resolve --destdir=/tmp/offline-packages httpd httpd-devel mod_wsgi python-devel

repotrack和yumdownloader --resolve的区别在于:repotrack会把所有层级的依赖都拉下来,包括那些可能已经装了的;yumdownloader --resolve只拉当前系统缺失的依赖。在离线场景下,我建议用repotrack,宁可多下几个包,也别到了目标机器上发现少东西。

下载完成后,把整个目录打包:

tar czf offline-apache-wsgi.tar.gz -C /tmp/offline-packages .

2.2 目标服务器上的本地源配置

把压缩包传到目标服务器后,解压到一个固定目录,比如/opt/offline-repo。然后创建一个本地 yum 源指向这个目录:

mkdir -p /opt/offline-repo tar xzf offline-apache-wsgi.tar.gz -C /opt/offline-repo # 创建本地 repo 文件 cat > /etc/yum.repos.d/local-offline.repo << 'EOF' [local-offline] name=Local Offline Repository baseurl=file:///opt/offline-repo enabled=1 gpgcheck=0 EOF # 清理缓存并重建 yum clean all yum makecache

这里gpgcheck=0是因为我们自己下载的包没有经过官方签名验证流程,内网环境下这个风险可以接受。如果你对包完整性有要求,可以在联网机器上把 RPM 包的校验和记下来,传到目标机器后逐一比对。

注意:本地源的baseurl路径一定要写对,file://后面跟的是绝对路径。如果路径写错,yum makecache会直接报错,不会静默失败,这一点比网络源友好。

2.3 用 yum 安装还是 rpm 手动安装

有了本地源之后,安装就简单了:

yum install -y httpd httpd-devel mod_wsgi python-devel

用 yum 而不是 rpm 手动安装的好处是,yum 会自动处理依赖顺序,该先装 apr 就先装 apr,不会出现"先装了 httpd 再装 apr 导致 httpd 找不到库"这种问题。

如果本地源里包不全,yum 会明确告诉你缺哪个包,这时候回到联网机器上补下就行。最怕的是用rpm -ivh一个个装,装到一半报依赖错误,然后陷入"装 A 需要 B,装 B 需要 C,装 C 又需要 A"的死循环。

安装完成后验证一下关键组件:

httpd -v # 应该输出类似:Server version: Apache/2.4.6 (CentOS) python -c "import sys; print(sys.version)" # 确认 Python 版本,CentOS 7 默认是 2.7.x ls /etc/httpd/modules/ | grep wsgi # 应该能看到 mod_wsgi.so

3. mod_wsgi 的两种工作模式与选型逻辑

3.1 嵌入模式与守护进程模式的本质区别

mod_wsgi 有两种运行模式,这个选择直接影响到应用的稳定性和资源隔离程度,很多人装完就用默认配置,结果应用一崩整个 Apache 跟着挂。

嵌入模式(Embedded Mode)下,Python 应用跑在 Apache 的子进程里面,跟 Apache 共享生命周期。配置简单,性能开销小,但缺点是应用代码里的任何崩溃(比如段错误)都可能把 Apache 子进程带走。而且多个应用如果都跑在同一个 Apache 实例里,它们共享同一个 Python 解释器,容易出现模块版本冲突。

守护进程模式(Daemon Mode)下,Python 应用跑在独立的守护进程里,Apache 只负责转发请求。应用崩溃了不影响 Apache 本身,多个应用可以各自跑在不同的守护进程组里,互不干扰。缺点是配置稍微复杂一点,多了一层进程间通信的开销。

我的建议很明确:只要不是极简的单应用场景,一律用守护进程模式。内网环境里稳定性比那一点点性能开销重要得多。

3.2 守护进程模式的关键配置项

在 httpd 的配置里,守护进程模式的核心指令是WSGIDaemonProcess和WSGIProcessGroup:

# 定义一个守护进程组 WSGIDaemonProcess myapp user=apache group=apache threads=5 processes=2 # 指定某个 WSGI 应用使用哪个进程组 WSGIProcessGroup myapp # 指定 WSGI 脚本的路径 WSGIScriptAlias /myapp /var/www/myapp/myapp.wsgi

processes=2和threads=5意味着总共 10 个并发处理能力。这个数字怎么定?一个简单的估算方法是:内网应用通常并发不高,processes设为 CPU 核心数,threads设为 2 到 5 就够用了。设太多反而会增加内存占用和上下文切换开销。

user和group指定守护进程以哪个系统用户身份运行。这里有个坑:如果指定的用户对应用目录没有读权限,守护进程启动时会直接失败,而且错误信息不一定很明显。所以配置之前先确认权限:

chown -R apache:apache /var/www/myapp chmod -R 755 /var/www/myapp

3.3 一个容易忽略的 Python 路径问题

守护进程模式下,Python 的sys.path跟你在命令行里执行时可能不一样。因为守护进程的工作目录和解释器初始化环境跟交互式 shell 不同,应用代码里import自己的模块时经常报ModuleNotFoundError。

解决办法是在.wsgi文件里显式地把应用目录加到sys.path:

import sys import os # 把应用根目录加入 Python 搜索路径 sys.path.insert(0, '/var/www/myapp') # 如果有虚拟环境,也要把虚拟环境的 site-packages 加进去 # sys.path.insert(0, '/var/www/myapp/venv/lib/python2.7/site-packages') from myapp import app as application

这个操作看起来多余,但在守护进程模式下是必须的。我见过太多人在这里卡住,以为是 mod_wsgi 没装好,其实是路径没配对。

4. Apache 虚拟主机与 WSGI 应用的对接细节

4.1 虚拟主机配置的完整写法

内网环境里通常一个 Apache 实例要跑多个应用,用虚拟主机按域名或端口区分是最清晰的做法。下面是一个完整的虚拟主机配置示例:

<VirtualHost *:80> ServerName myapp.internal.local DocumentRoot /var/www/myapp/static # 静态文件直接由 Apache 处理,不经过 Python Alias /static /var/www/myapp/static <Directory /var/www/myapp/static> Require all granted </Directory> # WSGI 应用挂载点 WSGIDaemonProcess myapp user=apache group=apache threads=5 processes=2 WSGIProcessGroup myapp WSGIScriptAlias / /var/www/myapp/myapp.wsgi <Directory /var/www/myapp> <Files myapp.wsgi> Require all granted </Files> </Directory> ErrorLog /var/log/httpd/myapp-error.log CustomLog /var/log/httpd/myapp-access.log combined </VirtualHost>

这里有几个细节值得展开说。

DocumentRoot指向静态文件目录,同时用Alias把/static映射过去。这样静态文件请求根本不会进入 Python 进程,直接由 Apache 的静态文件处理模块返回,性能差距是数量级的。很多人把所有请求都丢给 WSGI 处理,然后抱怨应用响应慢,问题往往就出在这里。

WSGIScriptAlias /把根路径挂载到 WSGI 脚本。注意这里的/是挂载点,不是文件路径。如果你想让应用跑在/myapp子路径下,就写成WSGIScriptAlias /myapp /var/www/myapp/myapp.wsgi。

<Directory>块里的Require all granted是 Apache 2.4 的语法,2.2 时代用的是Order allow,deny和Allow from all。CentOS 7 自带的是 2.4,所以用新语法。如果你从旧文档里抄配置,注意这个区别。

4.2 静态文件与动态请求的分流策略

静态文件和动态请求的分流不只是性能问题,还涉及到安全。静态文件目录如果配置不当,可能把.py源文件、配置文件甚至数据库文件暴露出去。

一个稳妥的做法是:静态文件目录只放确实需要公开的资源(CSS、JS、图片),应用代码和配置文件放在 Web 根目录之外。如果做不到物理隔离,至少在 Apache 配置里限制访问:

<Directory /var/www/myapp> # 禁止访问 .py 文件和 .wsgi 文件(除了入口脚本) <FilesMatch "\.(py|pyc|cfg|conf|ini)$"> Require all denied </FilesMatch> <Files myapp.wsgi> Require all granted </Files> </Directory>

这个配置的逻辑是:默认拒绝所有敏感后缀的文件,然后单独放行入口.wsgi文件。这样即使应用目录不小心放在了 Web 根目录下,源码也不会被直接下载。

4.3 日志配置与问题定位

Apache 的日志分两块:访问日志和错误日志。访问日志记录每个请求的来源、路径、状态码;错误日志记录服务器级别的错误和 WSGI 应用抛出的异常。

在虚拟主机里单独指定日志路径是个好习惯,多个应用混在一个日志文件里排查起来很痛苦:

ErrorLog /var/log/httpd/myapp-error.log LogLevel warn CustomLog /var/log/httpd/myapp-access.log combined

LogLevel warn是生产环境的合理选择。调试阶段可以临时改成debug,但记得调回来,否则日志文件会迅速膨胀。

WSGI 应用里的print输出和未捕获的异常堆栈会出现在错误日志里。如果你在应用里用了 Python 的logging模块,默认情况下日志会输出到 stderr,也就是被 Apache 的错误日志捕获。但更推荐的做法是在应用里单独配置文件日志,避免跟 Apache 的日志混在一起。

5. 从零跑通一个最小 WSGI 应用的完整链路

5.1 最小化应用代码与目录结构

先不急着上 Flask 或 Django,用一个最朴素的 WSGI 应用验证环境是否通了。创建一个目录结构:

mkdir -p /var/www/myapp cd /var/www/myapp

创建myapp.wsgi:

import sys sys.path.insert(0, '/var/www/myapp') def application(environ, start_response): status = '200 OK' output = b'Hello from WSGI on CentOS offline environment!\n' response_headers = [ ('Content-Type', 'text/plain'), ('Content-Length', str(len(output))) ] start_response(status, response_headers) return [output]

这个应用不依赖任何第三方库,纯粹用标准库实现 WSGI 接口。如果这个能跑通,说明 Apache、mod_wsgi、Python 三者的对接没有问题。跑不通的话,问题一定出在环境配置上,跟应用代码无关。

5.2 权限设置与 SELinux 处理

CentOS 默认开启 SELinux,这是离线部署时最容易踩的坑之一。Apache 进程以apache用户运行,但 SELinux 的策略限制了它能访问哪些目录。

先设置文件权限:

chown -R apache:apache /var/www/myapp chmod -R 755 /var/www/myapp chmod 644 /var/www/myapp/myapp.wsgi

然后处理 SELinux。有两种思路:一是直接关闭 SELinux(简单粗暴),二是配置正确的 SELinux 上下文(更规范)。

关闭 SELinux 的方法:

setenforce 0 # 永久关闭需要编辑 /etc/selinux/config,把 SELINUX=enforcing 改成 SELINUX=disabled

配置 SELinux 上下文的方法:

# 给应用目录打上 httpd 可读的标签 semanage fcontext -a -t httpd_sys_content_t "/var/www/myapp(/.*)?" restorecon -Rv /var/www/myapp # 如果应用需要写文件(比如上传目录),还需要允许 httpd 写权限 setsebool -P httpd_unified 1

我个人的建议是:如果内网安全策略允许,直接关掉 SELinux 最省事。如果必须开启,那就老老实实配上下文,别用chcon临时改,因为chcon的修改在文件系统重新标记后会丢失。

5.3 启动服务与验证请求

配置写完后,先检查语法:

apachectl configtest # 应该输出 Syntax OK

然后启动或重启 Apache:

systemctl restart httpd systemctl enable httpd

用 curl 验证:

curl -v http://localhost/ # 应该返回 Hello from WSGI on CentOS offline environment!

如果返回 403 Forbidden,检查目录权限和 SELinux;如果返回 500 Internal Server Error,查看错误日志/var/log/httpd/myapp-error.log,里面会有具体的 Python 异常信息。

5.4 接入真实框架应用的注意事项

最小应用跑通后,换成 Flask 或 Django 应用时,有几个地方需要调整。

Flask 应用的.wsgi文件写法:

import sys sys.path.insert(0, '/var/www/myapp') from myflaskapp import create_app application = create_app()

Django 应用的.wsgi文件写法:

import sys sys.path.insert(0, '/var/www/myapp') import os os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') from django.core.wsgi import get_wsgi_application application = get_wsgi_application()

关键点是:变量名必须是application,因为 mod_wsgi 默认找的就是这个名字。如果你用了别的名字,需要在配置里用WSGICallableObject指定。

另外,框架的依赖包必须安装在系统 Python 能访问到的路径下。离线环境下不能用 pip 在线安装,需要提前在联网机器上把依赖包下载好,传到目标服务器后用pip install --no-index --find-links=/path/to/packages安装。

6. 离线环境下的典型故障与排查路径

6.1 模块加载失败:mod_wsgi.so 找不到或版本不匹配

Apache 启动时报Cannot load modules/mod_wsgi.so into server,通常有三种原因。

第一种是文件确实不存在。检查/etc/httpd/modules/mod_wsgi.so是否存在,如果不存在,说明 mod_wsgi 包没装上或者装到了别的路径。

第二种是编译时的 Python 版本跟运行时的 Python 版本不一致。mod_wsgi 在编译时会链接特定版本的 Python 库,如果编译时用的是 Python 2.7,运行时系统里只有 Python 3.x,就会加载失败。用ldd /etc/httpd/modules/mod_wsgi.so查看它依赖的 Python 库路径,确认这个路径下的库文件存在。

第三种是 Apache 的模块加载配置没写对。检查/etc/httpd/conf.modules.d/目录下是否有10-wsgi.conf之类的文件,内容应该是:

LoadModule wsgi_module modules/mod_wsgi.so

如果这个文件不存在,手动创建即可。

6.2 500 错误:从错误日志反推应用问题

500 错误是最常见的,但也是最容易定位的,因为错误日志里会有完整的 Python 异常堆栈。

一个典型的错误日志长这样:

[wsgi:error] [pid 12345] mod_wsgi (pid=12345): Exception occurred processing WSGI script '/var/www/myapp/myapp.wsgi'. [wsgi:error] [pid 12345] Traceback (most recent call last): [wsgi:error] [pid 12345] File "/var/www/myapp/myapp.wsgi", line 5, in <module> [wsgi:error] [pid 12345] from myapp import app [wsgi:error] [pid 12345] ImportError: No module named myapp

这个例子就是典型的路径问题。sys.path里没有应用目录,导致import失败。解决办法就是在.wsgi文件开头加上sys.path.insert(0, '/var/www/myapp')。

另一种常见情况是权限问题导致的 500:

[wsgi:error] [pid 12345] (13)Permission denied: mod_wsgi (pid=12345): Unable to connect to WSGI daemon process 'myapp' on '/etc/httpd/logs/wsgi.12345.0.1.sock'

这是守护进程的 socket 文件权限问题。检查/etc/httpd/logs/目录的权限,确保apache用户可写。有时候 SELinux 也会导致这个问题,用ausearch -m avc -ts recent查看是否有 SELinux 拒绝记录。

6.3 守护进程启动超时与资源限制

如果应用启动时需要加载大量数据(比如加载机器学习模型),可能会遇到守护进程启动超时的问题。默认情况下,mod_wsgi 等待守护进程就绪的时间是有限的。

可以在WSGIDaemonProcess里增加startup-timeout参数:

WSGIDaemonProcess myapp user=apache group=apache threads=5 processes=2 startup-timeout=60

这个参数的单位是秒,根据应用启动的实际耗时来调整。但要注意,启动超时往往意味着应用初始化逻辑太重,更好的做法是把耗时操作放到第一次请求时懒加载,而不是在进程启动时就做完。

另一个资源限制是maximum-requests,控制每个守护进程处理多少个请求后自动重启。这个参数可以用来规避潜在的内存泄漏:

WSGIDaemonProcess myapp user=apache group=apache threads=5 processes=2 maximum-requests=1000

设成 1000 意味着每个进程处理 1000 个请求后重启一次。对于内网低并发场景,这个数字可以设大一些,比如 5000 或 10000,避免频繁重启带来的开销。

6.4 中文乱码与编码问题

内网应用如果涉及中文内容,编码问题几乎必然遇到。Apache 默认的字符集配置可能跟应用不一致,导致返回的中文变成乱码。

在虚拟主机配置里显式指定字符集:

AddDefaultCharset UTF-8

同时在 WSGI 应用的响应头里也要正确设置:

response_headers = [ ('Content-Type', 'text/html; charset=utf-8'), ]

两个地方都设置成 UTF-8,基本能解决大部分乱码问题。如果还有问题,检查应用代码里读写文件时是否指定了正确的编码,Python 2 默认用 ASCII 编码读写文件,处理中文时需要显式指定encoding='utf-8'。

7. 性能调优与长期维护的实操经验

7.1 并发参数的实测调整方法

前面提到的processes和threads参数,理论估算是一回事,实际调整是另一回事。我的做法是先设一个保守值,然后用ab或wrk做压力测试,观察响应时间和错误率。

# 用 ab 做简单压测,100 个并发,总共 1000 个请求 ab -n 1000 -c 100 http://localhost/

关注两个指标:Requests per second 和 Time per request。如果增加threads后吞吐量没有明显提升,说明瓶颈不在并发数上,可能在数据库查询或外部接口调用上。如果错误率上升,说明并发数超过了应用的处理能力,需要调低。

内网应用通常不需要追求极致的吞吐量,稳定压倒一切。我一般会把并发能力设在预估峰值的 1.5 倍左右,留出余量就行。

7.2 日志轮转与磁盘空间管理

Apache 的日志文件如果不做轮转,迟早会把磁盘写满。CentOS 自带的logrotate可以自动处理这件事。

检查/etc/logrotate.d/httpd文件是否存在,内容大致如下:

/var/log/httpd/*log { missingok notifempty sharedscripts postrotate /bin/systemctl reload httpd.service > /dev/null 2>/dev/null || true endscript }

默认配置是每周轮转一次,保留 4 份历史日志。对于内网低流量应用,这个频率足够了。如果应用日志量特别大,可以改成每天轮转,并增加保留份数。

WSGI 应用自己的日志文件也需要单独配置轮转,不要指望 Apache 的 logrotate 会管到应用日志。

7.3 离线环境下的更新策略

离线环境不是装完就一劳永逸了。应用代码会更新,依赖包可能有安全补丁,这些都需要一套可操作的更新流程。

我的建议是维护一个"离线更新包"目录,每次更新时在联网机器上把新的 RPM 包或 Python wheel 包下载好,传到目标服务器后先用rpm -Uvh或pip install --upgrade做升级,升级前备份配置文件和数据库。

对于应用代码的更新,直接用rsync或scp覆盖到应用目录,然后systemctl reload httpd触发守护进程重启。注意是reload不是restart,reload会平滑重启,不会中断正在处理的请求。

# 备份当前版本 cp -r /var/www/myapp /var/www/myapp.bak.$(date +%Y%m%d) # 同步新版本代码 rsync -avz --delete /tmp/myapp-new/ /var/www/myapp/ # 修正权限 chown -R apache:apache /var/www/myapp # 平滑重启 systemctl reload httpd

这套流程跑熟之后,每次更新也就几分钟的事。

7.4 监控与告警的最小化方案

内网环境不一定有完善的监控系统,但至少要做到"应用挂了能知道"。最简单的方案是写一个定时脚本,定期请求应用的健康检查接口,失败时发邮件或写系统日志。

#!/bin/bash # /opt/scripts/check-myapp.sh URL="http://localhost/health" STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$URL") if [ "$STATUS" != "200" ]; then echo "$(date): myapp health check failed with status $STATUS" >> /var/log/myapp-health.log # 可以在这里加邮件通知或重启逻辑 systemctl restart httpd fi

加到 crontab 里每分钟跑一次:

* * * * * /opt/scripts/check-myapp.sh

这个方案很粗糙,但在没有专业监控工具的内网环境里,能解决"应用挂了没人知道"的问题。等条件允许了再上更完善的监控方案。

8. 一些踩过坑之后才明白的事

离线部署 Apache + WSGI 这件事,技术难度其实不高,但细节特别多。我前后在不同环境里部署过十几次,每次都会遇到新的小问题。总结下来,最容易出问题的环节往往不是配置本身,而是环境差异和权限。

环境差异方面,最稳妥的做法是在联网机器上把目标服务器的rpm -qa输出导出来,对比两台机器的已安装包列表,确保依赖版本一致。如果目标服务器上已经装了旧版本的 httpd 或 Python,升级时要注意配置文件兼容性,Apache 2.2 到 2.4 的配置语法变化不小。

权限方面,记住一个原则:Apache 进程以什么用户运行,那个用户就必须对应用目录有读权限,对日志目录有写权限,对 socket 目录有读写权限。这三个权限缺一个都会导致启动失败或运行异常。SELinux 开启时还要额外考虑安全上下文,用ls -Z查看文件的 SELinux 标签,跟/var/www/html下的标准文件对比,不一致就说明需要调整。

还有一个容易被忽略的点是 Python 的字节码缓存。mod_wsgi 在守护进程模式下会生成.pyc文件,如果应用目录对apache用户不可写,字节码缓存会失败,虽然不影响运行,但每次请求都要重新编译 Python 代码,性能会下降。解决办法是确保应用目录对apache用户可写,或者在.wsgi文件里设置sys.dont_write_bytecode = True明确禁用字节码写入。

最后说一个关于文档的习惯。离线环境的配置一旦跑通,一定要把完整的配置文件和安装步骤记录下来,包括每个 RPM 包的版本号、每个配置项的取值理由。因为下次再部署时,你可能已经忘了当时为什么把threads设成 5 而不是 10。好记性不如烂笔头,在离线环境里尤其如此。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询