1. 为什么要在 Ubuntu 20.04.3 LTS 上折腾 CGI 运行环境
先把话说在前头:CGI 这门技术确实有年头了,1993 年就随着早期 Web 服务器进入大众视野,后来被 FastCGI、WSGI、各种应用服务器和反向代理架构挤到了边缘。但边缘不等于没用。我在实际工作中至少遇到过三类场景,仍然需要把 CGI 跑起来:一是维护十几年前遗留的运维面板或监控页面,它们就是一堆.cgi脚本,重写成本远高于直接跑起来;二是教学和实验环境,需要让学生直观理解“Web 服务器如何把请求交给外部程序处理”这件事,CGI 是最朴素的模型;三是一些轻量级内部工具,用 Shell 或 Perl 写个 CGI 脚本,几十行就能解决表单提交和结果展示,比引入一整套框架省事得多。
Ubuntu 20.04.3 LTS 是 20.04 系列的一个点版本,底层依然是 Focal Fossa,官方支持周期到 2025 年 4 月(标准支持),ESM 还能再延五年。它的软件源里 Apache2 是 2.4.x 系列,默认就带mod_cgi模块,配置 CGI 不需要额外编译,这是它比某些精简发行版省心的地方。这篇文章要解决的问题很具体:在一台干净的 Ubuntu 20.04.3 LTS 上,从零把 CGI 运行环境搭起来,让浏览器访问一个.cgi地址时能正确执行并返回结果,而不是把源码当文本吐出来。
适合谁看?如果你是刚接触 Linux 服务端、想搞明白 Web 服务器和外部程序怎么协作的新手,这篇可以当作一条完整的实操路径;如果你是有经验但很久没碰 CGI 的老手,这里面的权限坑、目录映射坑、SELinux/AppArmor 相关的排查思路,应该能帮你省下重新踩一遍的时间。全文基于 Apache2 +mod_cgi这条最主流、最稳妥的路线展开,同时会说明为什么我不推荐一上来就用 Nginx 做 CGI——Nginx 本身不支持原生 CGI,必须借助额外的 FastCGI 包装层,对新手来说多一层就多一堆变量要调。
需要提前说明的是,下面所有操作都在一台全新安装的 Ubuntu 20.04.3 LTS 虚拟机上验证过,网络环境是普通内网,没有做任何特殊网络配置。涉及的命令和配置我会给出完整内容,你可以直接照着敲。文中出现的路径、脚本名、测试用例都是通用示例,你可以按自己的项目替换。
2. 环境准备与软件选型:为什么是 Apache2 而不是别的
2.1 先更新系统并确认版本信息
动手之前先做两件事:确认系统版本,更新软件包索引。很多人跳过更新这一步,结果装出来的 Apache 版本和文档对不上,配置语法有细微差异,排查起来很费劲。
lsb_release -a正常输出里应该能看到Description: Ubuntu 20.04.3 LTS和Codename: focal。如果版本号不是 20.04.x,后面的配置大体通用,但个别路径可能不同,需要你自己核对。
接着更新索引并升级已有包:
sudo apt update sudo apt upgrade -y提示:
apt upgrade在有内核更新时会提示是否重启相关服务,CGI 环境搭建阶段可以直接回车接受默认选项,搭完再统一重启一次系统更干净。
2.2 Apache2 与 Nginx 在 CGI 场景下的取舍
这是选型环节最该讲清楚的地方。Nginx 处理静态文件和反向代理的性能确实优秀,但它从设计上就不支持直接执行 CGI 程序,官方立场是让你用 FastCGI。要在 Nginx 上跑传统 CGI,得额外装fcgiwrap这类包装程序,再由 Nginx 把请求转发给它。多出来的这一层意味着:多一个服务要管理、多一组 socket 权限要配、多一类 502 错误要排查。
Apache2 则内置了mod_cgi,加载模块、指定目录、给执行权限,三步就能跑通。对于“我就是要让一个.cgi脚本能被访问执行”这个需求,Apache2 的路径最短、变量最少、出错面最小。所以本文选 Apache2,不是因为它更先进,而是因为它在这个具体任务上更直接。
| 对比项 | Apache2 + mod_cgi | Nginx + fcgiwrap |
|---|---|---|
| 原生 CGI 支持 | 内置模块,加载即用 | 不支持,需外部包装 |
| 配置步骤 | 3 步左右 | 5 步以上,含 socket 权限 |
| 常见故障点 | 权限、目录映射 | 502、socket 权限、用户不匹配 |
| 适合场景 | 遗留脚本、教学、轻量工具 | 已有 Nginx 架构且必须兼容 CGI |
2.3 安装 Apache2 及必要组件
安装命令很直接:
sudo apt install -y apache2 apache2-utilsapache2-utils里带了htpasswd、ab等工具,后面做访问控制和压测会用到,建议一起装上。装完后 Apache 会自动启动,用下面命令确认状态:
sudo systemctl status apache2看到active (running)就对了。此时在浏览器访问这台机器的 IP,应该能看到 Apache 的默认欢迎页。如果访问不了,先检查防火墙:
sudo ufw status如果ufw是启用状态且没放行 80 端口,执行:
sudo ufw allow 80/tcp sudo ufw allow 443/tcp注意:生产环境不要图省事直接
ufw disable,放行具体端口比关掉整个防火墙安全得多。这是我在早期运维里吃过亏的地方——图快关防火墙,结果机器暴露在公网被扫,教训很直接。
3. 核心配置拆解:让 mod_cgi 真正干活
3.1 理解 CGI 目录的两种映射方式
Apache 处理 CGI 有两种典型配置思路,理解它们的区别能帮你少走弯路。
第一种是ScriptAlias 方式。它把 URL 路径直接映射到一个专门存放 CGI 脚本的目录,并且自动给这个目录加上“可执行”语义。也就是说,只要脚本放在这个目录里,Apache 就认为它是程序而不是普通文件。这是最传统、最省心的方式,适合脚本集中管理的场景。
第二种是Options +ExecCGI 方式。它不要求脚本放在特定目录,而是在某个普通目录上开启执行权限,再通过AddHandler把.cgi后缀关联到 CGI 处理器。这种方式更灵活,适合脚本和静态资源混放的场景,但配置项更多,容易漏。
我一般推荐新手先用 ScriptAlias,跑通之后再按需切换到第二种。下面两种都会讲,你可以按自己的情况选。
3.2 加载模块并确认状态
Ubuntu 的 Apache2 用a2enmod/a2disconf这套工具管理模块,比手动改配置文件再重启要规范。先确认mod_cgi是否已加载:
apache2ctl -M | grep cgi如果输出里有cgi_module (shared),说明已经加载。如果没有,执行:
sudo a2enmod cgi sudo systemctl restart apache2a2enmod的本质是在/etc/apache2/mods-enabled/下创建指向mods-available/的软链接,这样配置和模块文件分离,升级时不会互相覆盖。理解这一点,以后你手动排查模块问题时就知道该去哪个目录看。
3.3 用 ScriptAlias 配置一个 CGI 目录
打开 CGI 相关配置文件。Ubuntu 默认把 CGI 配置放在/etc/apache2/conf-available/下,但更常见的做法是直接编辑站点配置或新建一个独立配置文件。我倾向于新建独立文件,便于管理和回滚:
sudo nano /etc/apache2/conf-available/cgi-bin.conf写入以下内容:
ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ <Directory "/usr/lib/cgi-bin"> AllowOverride None Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch Require all granted </Directory>逐行解释一下,这些参数不是随便写的:
ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/:把 URL 里的/cgi-bin/映射到文件系统的/usr/lib/cgi-bin/。注意结尾的斜杠两边都要有,少一个就可能导致路径拼接错误。AllowOverride None:禁止该目录下的.htaccess覆盖配置。CGI 目录通常不需要用户自定义配置,关掉能减少一层潜在风险。Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch:开启执行权限,关闭 MultiViews(避免 URL 模糊匹配带来的意外行为),符号链接仅在属主匹配时允许。Require all granted:允许所有来源访问。生产环境如果要做访问控制,这里换成具体的 IP 段或配合认证。
启用配置并重载:
sudo a2enconf cgi-bin sudo systemctl reload apache2提示:
reload比restart温和,它不会中断正在处理的连接。改配置优先用reload,只有模块增删这种结构性变更才需要restart。
3.4 用 Options +ExecCGI 的灵活方式
如果你不想把脚本都塞进/usr/lib/cgi-bin/,可以在站点配置里对某个目录单独开权限。比如你有一个/var/www/mytools/目录,里面既有 HTML 又有.cgi:
<Directory "/var/www/mytools"> Options +ExecCGI AddHandler cgi-script .cgi .pl .sh Require all granted </Directory>AddHandler cgi-script .cgi .pl .sh这行的意思是:凡是这些后缀的文件,都交给 CGI 处理器执行。这里我特意把.sh也加进去,因为很多内部工具就是 Shell 脚本。但要注意,后缀越多,误执行的风险越大,生产环境建议只保留你真正需要的后缀。
两种方式不要在同一目录上叠加使用,否则行为会变得难以预测。选一种,配清楚,就够了。
4. 实操全流程:从写脚本到浏览器验证
4.1 写第一个 CGI 脚本并设置权限
在/usr/lib/cgi-bin/下创建一个测试脚本:
sudo nano /usr/lib/cgi-bin/hello.cgi内容如下:
#!/bin/bash echo "Content-Type: text/html" echo "" echo "<html><head><title>CGI Test</title></head><body>" echo "<h1>CGI is working</h1>" echo "<p>Current time: $(date)</p>" echo "<p>Server: $SERVER_SOFTWARE</p>" echo "<p>Client: $REMOTE_ADDR</p>" echo "</body></html>"这个脚本虽短,但包含了 CGI 的几个关键点,值得逐条说清楚:
- 第一行 shebang
#!/bin/bash必须存在且路径正确。Apache 执行脚本时不会猜你用什么解释器,它完全依赖这一行。写错路径,脚本直接 500。 - Content-Type 头必须输出,后面跟一个空行。这个空行是 HTTP 头和正文的分界,少了它,浏览器会把整个输出当正文,或者服务器报错。这是新手最常犯的错误,没有之一。
- 环境变量如
$SERVER_SOFTWARE、$REMOTE_ADDR是 Apache 在执行脚本前注入的,CGI 协议规定了一整套标准变量,脚本可以直接读取。这也是 CGI 模型直观的地方——请求信息通过环境变量传递,响应通过标准输出返回。
给脚本加执行权限:
sudo chmod +x /usr/lib/cgi-bin/hello.cgi注意:权限要加在文件上,不是目录上。目录需要的是可进入权限(
x),文件需要的是可执行权限。两者混淆会导致“目录能访问但脚本不执行”或“脚本能执行但目录列不出来”这类怪问题。
4.2 权限与属主的那些坑
Apache 在 Ubuntu 下默认以www-data用户运行。脚本要能被它执行,需要满足:文件对www-data或其所属组有执行权限。上面用chmod +x给所有用户加了执行权限,简单直接。但更规范的做法是把脚本属主设为www-data或把www-data加入脚本所属组:
sudo chown www-data:www-data /usr/lib/cgi-bin/hello.cgi sudo chmod 755 /usr/lib/cgi-bin/hello.cgi755的含义是:属主可读写执行,组和其他用户可读可执行。对 CGI 脚本来说这个权限足够,不要给777,那等于把脚本暴露给任何能登录的用户修改。
还有一个容易忽略的点:脚本所在目录的每一级父目录,www-data都必须有执行权限,否则它连进都进不去。/usr/lib/cgi-bin/默认权限通常是755,一般没问题,但如果你自己建了嵌套目录,记得逐级检查。
4.3 浏览器验证与命令行验证
配置和脚本都就位后,浏览器访问:
http://你的服务器IP/cgi-bin/hello.cgi如果看到 “CGI is working” 和当前时间,说明整条链路通了。如果没通,先别急着改配置,用命令行从服务器本机验证,能快速定位问题在哪一层:
curl -i http://localhost/cgi-bin/hello.cgi-i参数会显示响应头。如果返回200且正文正确,说明服务端没问题,问题在浏览器或网络;如果返回500,看 Apache 错误日志:
sudo tail -f /var/log/apache2/error.log日志会明确告诉你失败原因,比如 “Premature end of script headers” 通常意味着脚本没输出正确的 Content-Type 头,或者脚本本身执行报错。养成看日志的习惯,比盲目改配置高效十倍。
4.4 一个更实用的表单处理示例
光跑个 hello 不够说明问题,来个能接收表单输入的脚本,更贴近真实用途。先写一个 HTML 表单页面,放在/var/www/html/form.html:
<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>CGI Form</title></head> <body> <form action="/cgi-bin/echo.cgi" method="post"> <input type="text" name="username" placeholder="输入名字"> <input type="submit" value="提交"> </form> </body> </html>再写处理脚本/usr/lib/cgi-bin/echo.cgi:
#!/bin/bash echo "Content-Type: text/html" echo "" read -n "$CONTENT_LENGTH" POST_DATA echo "<html><body>" echo "<h2>收到数据</h2>" echo "<pre>$POST_DATA</pre>" echo "</body></html>"这里的关键是read -n "$CONTENT_LENGTH"。POST 请求的数据通过标准输入传给 CGI 脚本,长度由环境变量CONTENT_LENGTH给出。不读这个长度,脚本可能读不到完整数据或阻塞。这是 CGI 处理 POST 的标准做法,值得记住。
提示:上面这个示例为了演示简洁,没有做输入转义。真实环境里把用户输入直接回显到 HTML 是有风险的,至少要做 HTML 实体转义,否则可能被注入脚本。这一点在内部工具里也常被忽视,务必留意。
5. 常见问题排查与避坑经验
5.1 高频故障速查表
下面这张表是我这些年遇到最多的 CGI 问题,按现象归类,方便你对照排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 浏览器显示脚本源码 | mod_cgi 未加载或目录未开 ExecCGI | 检查apache2ctl -M和目录 Options |
| 500 Internal Server Error | 脚本无执行权限、shebang 错误、缺 Content-Type | 看 error.log,逐项核对 |
| 403 Forbidden | 目录权限不足、Require 配置限制 | 检查目录逐级权限和 Require 指令 |
| 404 Not Found | ScriptAlias 路径拼写错误 | 核对 URL 与文件系统路径映射 |
| 脚本执行但输出为空 | 脚本内部报错被吞、输出缓冲问题 | 手动命令行执行脚本看报错 |
| POST 数据读不到 | 未按 CONTENT_LENGTH 读取 | 检查 read 逻辑 |
5.2 几个只有踩过才知道的细节
第一,脚本里的相对路径不可靠。CGI 脚本执行时的工作目录不一定是脚本所在目录,可能是 Apache 的某个工作目录。所以脚本里引用文件一律用绝对路径,或者用$(dirname "$0")动态取脚本目录。我早期写过一个脚本,本地测试好好的,部署后读配置文件失败,查了半天才发现是工作目录变了。
第二,输出缓冲会骗你。有些语言(比如 Perl)默认对标准输出做缓冲,脚本报错时错误信息可能和正常输出混在一起,导致 HTTP 头解析失败。解决办法是在脚本里显式关闭缓冲,或者确保错误信息输出到标准错误而不是标准输出。Shell 脚本相对简单,但如果你调用外部命令,也要注意它们的输出流向。
第三,环境变量比你想的少。CGI 执行时的环境变量是 Apache 精心挑选后注入的,不是你登录 shell 的那一套。PATH可能很短,导致脚本里调用的命令找不到。稳妥做法是在脚本开头显式设置PATH:
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin这一行能解决相当一部分“命令行能跑、CGI 里跑不了”的诡异问题。
第四,别在 CGI 目录放敏感文件。ScriptAlias 映射的目录,Apache 会尝试执行里面的东西。如果你把配置文件、备份文件也丢进去,轻则报错,重则泄露内容。CGI 目录就只放脚本,干干净净。
5.3 日志是你的第一手线索
Apache 的日志分两块:access.log记录每个请求的结果码,error.log记录错误详情。排查时两个都要看。access.log里如果某个请求返回 500,去error.log找对应时间点的记录,通常会有明确的错误描述。我习惯用tail -f实时盯着 error.log,然后刷新浏览器,错误信息立刻跳出来,比猜快得多。
如果日志里信息不够,可以临时提高日志级别。在站点配置里加:
LogLevel debug然后reload。debug 级别会输出大量细节,包括模块加载、请求处理流程。排查完记得改回warn,否则日志会迅速膨胀。
6. 安全加固与后续扩展方向
6.1 最小化的安全措施
CGI 的本质是让 Web 服务器执行外部程序,这天然带有风险。哪怕只是内部工具,也建议做几件基本的事。
一是限制访问来源。如果只有内网用,把Require all granted换成具体网段:
Require ip 192.168.1.0/24二是给 CGI 目录单独设限,禁止列目录、禁止覆盖:
<Directory "/usr/lib/cgi-bin"> Options +ExecCGI -Indexes AllowOverride None </Directory>-Indexes能防止别人直接访问目录时看到文件列表,减少信息暴露。
三是脚本输入一律校验。CGI 脚本收到的所有数据都来自外部,包括环境变量和标准输入。不要假设它们是干净的。Shell 脚本里尤其注意命令注入,凡是拼接到命令里的变量,都要引号包裹并做校验。
6.2 从 CGI 平滑过渡到更现代的方案
如果你的项目在长大,CGI 每次请求都启动一个新进程的开销会变成瓶颈。这时候可以考虑迁移到 FastCGI 或 WSGI。好消息是,CGI 脚本的逻辑往往可以复用——把输入输出那层适配一下,核心处理代码基本不用动。Apache 的mod_fcgid或mod_proxy_fcgi都能承接这个过渡。
另一个方向是把 CGI 脚本包装成 systemd 服务,通过 socket 激活,这样既能保留脚本的简单性,又能避免每次请求重启进程。不过这就超出本文范围了,属于后续可以单独展开的话题。
6.3 我个人的几点体会
搭这套环境,最耗时间的从来不是敲命令,而是排查那些“看起来配了但就是不生效”的问题。我的经验是:每改一处配置,就 reload 一次并立即验证,不要攒一堆改动一起测。这样出问题时,范围永远锁定在最后一次改动上。
另外,把配置文件和脚本都纳入版本管理。CGI 环境往往配一次用很久,等哪天需要迁移或重建,有版本记录能省下大量回忆时间。哪怕只是本地 git 仓库,也比什么都没有强。
最后,别小看curl -i这个命令。它把响应头和正文一起打出来,很多浏览器里看不清的问题,在命令行下一目了然。这个习惯我从刚接触服务端保持到现在,依然是最有效的排查手段之一。