使用 SCGI 加速 Web 应用程序

作者:Jeroen Vermeulen

如果您正在运营 Web 服务器,那么很可能您不仅仅是在提供静态文本和图像。您可能还在运行一些 Web 应用程序,这些应用程序的页面是由某些程序或脚本使用 CGI(通用网关接口)动态生成的。想想博客软件、缺陷跟踪器、新闻站点和内容管理系统——任何将浏览器从文档查看器变成用户界面的东西。而且,您可能自己编写或至少调整其中一些。

本文介绍了如何使用 CGI 的替代方案 SCGI(简单通用网关接口)构建更快的 Web 应用程序。SCGI 是一种协议,而不仅仅是一个程序,但其作者也提供了参考实现,这就是我们在这里使用的。它包括从 Apache 或 lighttpd 使用 SCGI 的模块,以及帮助您创建 SCGI 应用程序的 Python 类。其他语言的实现也可用,但我们在此处检查 Apache 2.x 和 Python 的组合。

时间都去哪儿了?

通常,Web 应用程序在 Web 服务器的子进程中短暂但非常频繁地运行。当客户端请求页面时,Web 服务器会查阅其配置,并发现该请求应发送到应用程序。它将请求委托给子进程,子进程又加载并运行应用程序程序。该程序可以是二进制文件或 Perl、Python 或 PHP 中的脚本、shell 命令,或几乎任何其他内容。CGI 标准定义了程序如何接收有关请求的详细信息,包括请求的 URL、请求的主体、经过身份验证的用户身份和来源 IP 地址。程序读取这些信息,生成一个页面来响应客户端的请求,然后退出。所有这些都在下一个请求时再次发生。

加载、运行和退出程序可能会很耗费资源。对于粗制滥造的程序来说,这确实有意义:例如,它们可能会使用内存而永远不会再次释放它。在这种情况下,您希望程序短暂运行,然后让操作系统在它之后进行清理。但是,对于当今流行的语言——Perl、Python、PHP、Java 和 shell 脚本——这真的没有太多问题。一个编写良好的应用程序真的应该能够在单次运行中处理多个请求。

使用 SCGI 更快的服务

SCGI 让您的程序启动一次并持续为请求提供服务,只要它愿意。它的工作原理如下:一个单独的服务器进程,称为 SCGI 服务器,与 Web 服务器分开运行,并管理一个 Web 应用程序。Web 服务器将该应用程序的所有请求转发到应用程序的 SCGI 服务器。它以与常规 CGI 几乎相同的形式传递有关请求的详细信息。

SCGI 服务器将请求委托给子进程,就像 Web 服务器对常规 CGI 应用程序所做的那样。子进程也运行应用程序,但相似之处到此为止。应用程序在完成一个请求后,不会退出,而是可以坐等新的请求。每个 SCGI 服务器的子进程都运行应用程序的一个实例,每个实例都处于休眠状态,直到有工作要做。

当没有可用的子进程来处理最新请求时,SCGI 服务器会生成一个新的子进程——当然,最多达到可配置的最大值。它还会清理崩溃或退出的子进程,因此即使出现问题,您的 Web 应用程序仍然可以退出。但是,在大多数情况下,当请求到达时,应用程序已准备就绪并等待它。这就是 Ruby on Rails(Web 应用程序框架)附带在 SCGI 上运行的选项的原因;否则会太慢。

其他优势

如果速度提升对您来说还不够,还有更多优势。SCGI 服务器进程可以与 Web 服务器在同一系统上运行,但不必如此。您可以通过将某些 Web 应用程序委托给单独的系统来卸载服务器,最好是在防火墙后面,只有 Web 服务器可以访问它们。

即使只有一个服务器,您也可以使用 SCGI 来控制漏洞。正常的 CGI 应用程序开始时以与 Web 服务器进程相同的用户身份运行。如果攻击者设法破坏了正常的 CGI 应用程序,您的整个网站可能会面临风险。另一方面,SCGI 服务器可以在其自己的用户身份下运行,因此即使它失控,也不容易影响 Web 服务器或其他应用程序。反过来,您也不再需要向 Web 服务器授予对应用程序代码或数据的访问权限;只有 SCGI 服务器运行的应用程序才需要访问权限。其他所有人都必须通过 Web 服务器,Web 服务器又与 SCGI 服务器对话。

您还可以在 chroot 环境或虚拟化服务器中运行应用程序。使用 CGI,这很快变得昂贵且难以管理。当使用 SCGI 时,您只需在隔离的环境(无论是 chroot 监狱、虚拟化服务器、不同的用户身份还是另一台机器)中启动一个服务器进程,整个应用程序都将保留在那里。

安装 SCGI

您需要两个组件:用于构建 SCGI 应用程序的 Python 类,以及用于您的 Web 服务器使其“说 SCGI”与应用程序通信的模块。如果您使用 Red Hat 包管理 (RPM),则可以使用以下命令安装它们yum install python-scgi apache2-mod_scgi; Debian 的 apt 用户可以使用apt-get install python-scgi libapache2-mod-scgi.

您也可以手动安装任一组件。Apache 模块需要 C 编译器和 Apache 的 apxs 脚本。某些发行版将 apxs 保存在单独的开发包中,而不是将其作为常规 Apache 包的一部分安装。

假设您现在拥有这些组件,接下来下载源 tarball scgi-1.12.tar.gz,并运行清单 1 中显示的命令。

清单 1. 手动安装 SCGI

# Unpack source directory scgi-1.12 from tarball
tar xzf scgi-1.12.tar.gz
cd scgi-1.12
# Build the Python part
python setup.py build
# Install Python module; we'll need root privileges
sudo python setup.py install
# Now build and install the Apache module
cd apache2
sudo make install
# Enable the SCGI module in Apache. This may fail,
# depending on your Apache version, but no matter.
sudo a2enmod scgi
# Make Apache's new configuration take effect
sudo /etc/init.d/apache2 force-reload
测试运行

现在,让我们确保一切正常。Python 包是一个包含一些类的模块,通常,您会将您的应用程序编写为导入该模块的程序。但是,为了进行调试,您也可以将其作为独立应用程序运行。当它收到来自 Web 服务器的请求时,它只是将请求的详细信息打印为文本页面。非常适合第一次测试——无需编码!

在您的系统中找到 scgi_server.py 模块。它应该安装在 /usr/lib/python2.4/site-packages/scgi 中(您的系统上 2.4 可能是 2.3 或 2.5)。然后,运行该模块

cd /usr/lib/python2.4/site-packages/scgi
python scgi_server.py

这会监听来自 Web 服务器的 TCP 端口请求,默认使用端口 4000。您可以通过将所需的端口号作为命令行参数传递来使其监听不同的端口,例如

python /usr/lib/python2.4/site-packages/scgi/scgi_server.py 63000

该模块会一直运行,直到您杀死它,因此请在单独的 shell 中启动它。请记住,您不需要以 root 用户身份甚至在 Web 服务器的身份下运行 SCGI 服务器。

现在 SCGI 应用程序正在等待请求,请在您的网站上选择一个位置委托给该应用程序。假设您希望它回答此服务器上对“/scgitest”的所有请求。将 Apache 配置片段(如清单 2 所示)写入 /etc/apache2/conf.d 中的新文件。

清单 2. Apache 配置片段

# Load the SCGI module. This is really only needed
# if you installed manually and the "a2enmod scgi"
# command failed.
LoadModule scgi_module /usr/lib/apache2/modules/mod_scgi.so

<Location "/scgitest">
    # Enable SCGI
    SCGIHandler On
    # Other properties for /scgitest, such as access
    # control
    # ...
</Location>

# Hostname and port number where SCGI server for
# /scgitest is running.
# Port 4000 on localhost (127.0.0.1) is the default.
SCGIMount /scgitest 127.0.0.1:4000

如您所见,SCGI 服务器实际上不需要与 Web 服务器在同一台机器上运行。只需确保 SCGI 服务器的端口已正确防火墙,以便只有您的 Web 服务器可以访问它!这样,您的应用程序就可以确信所有 CGI 参数都已首先由 Web 服务器验证。如果攻击者可以直接连接到您的 SCGI 应用程序,您将无法信任该信息。例如,CGI 参数 AUTHENTICATED_USER 告诉您的应用程序请求来自特定的已登录用户。只有当您从正确配置的 Web 服务器听到它时,您才能相信这一点。

使 Apache 重新加载其配置,使用sudo /etc/init.d/apache2 reload。您的服务器现在应该提供一个新的位置 /scgitest,当您访问它时,它只会打印您的请求的 CGI 参数。通过在浏览器中查找来验证这一点。如果您的服务器地址是 example.org,请将您的浏览器指向 http://example.org/scgitest。您应该看到一个类似于清单 3 的页面。

清单 3. scgi_server.py 返回请求详细信息。

SERVER_SOFTWARE: 'Apache'
SCRIPT_NAME: '/scgitest'
REQUEST_METHOD: 'GET'
SERVER_PROTOCOL: 'HTTP/1.1'
QUERY_STRING: ''
CONTENT_LENGTH: '0'
HTTP_ACCEPT_CHARSET: 'UTF-8,*'
HTTP_USER_AGENT: 'Mozilla/5.0'
SERVER_NAME: 'testserver.example.org'
REMOTE_ADDR: '10.99.11.99'
SERVER_PORT: '80'
SERVER_ADDR: '192.0.34.166'
DOCUMENT_ROOT: '/srv/www/'
SERVER_ADMIN: 'webmaster@example.org'
HTTP_HOST: 'testserver.example.org'
REQUEST_URI: '/scgitest'
HTTP_ACCEPT:'text/html,text/plain,*/*;q=0.5'
REMOTE_PORT: '47088'
HTTP_ACCEPT_LANGUAGE: 'en'
SCGI: '1'
HTTP_ACCEPT_ENCODING: 'gzip,deflate'

如果这不是您所看到的,请查看您运行模块的 shell。它可能在那里打印了一些有用的错误消息。或者,如果 SCGI 服务器没有任何反应,则请求可能根本没有到达它;检查 Apache 错误日志。

一旦您让它运行起来,恭喜您——最糟糕的已经过去了。停止您的 SCGI 服务器进程,以免它干扰我们接下来要做的事情。

编写应用程序

现在,让我们用 Python 编写一个简单的 SCGI 应用程序——一个打印时间的应用程序。

我们导入 SCGI Python 模块,然后将我们的应用程序编写为处理通过 Web 服务器传入的 SCGI 请求的处理程序。处理程序的形式是我们从 SCGIHandler 派生的类。请叫我缺乏想象力,但我将示例处理程序类称为 TimeHandler。我们稍后将填写实际代码,但首先从这个骨架开始

#! /usr/bin/python
import scgi
import scgi.scgi_server

class TimeHandler(scgi.scgi_server.SCGIHandler):
    pass  # (no code here yet)

# Main program: create an SCGIServer object to
# listen on port 4000.  We tell the SCGIServer the
# handler class that implements our application.
server = scgi.scgi_server.SCGIServer(
    handler_class=TimeHandler,
    port=4000
    )
# Tell our SCGIServer to start servicing requests.
# This loops forever.
server.serve()

您可能会认为我们必须将处理程序类而不是处理程序对象传递给 SCGIServer 很奇怪。原因是服务器对象将根据需要创建我们给定类的处理程序对象。

TimeHandler 的第一个化身本质上仍然与原始的 SCGIHandler 相同,因此它所做的只是打印出请求参数。要查看它的实际效果,请尝试运行此程序并像以前一样在浏览器中打开 scgitest 页面。您应该再次看到类似于清单 3 的内容。

现在,我们想以浏览器能够理解的形式打印时间。我们不能简单地开始发送文本或 HTML;我们首先必须发出一个 HTTP 标头,告诉浏览器期望的输出类型。在这种情况下,让我们坚持使用简单的文本。在您的程序的顶部附近,在 TimeHandler 类定义正上方添加以下内容

import time
def print_time(outfile):
    # HTTP header describing the page we're about
    # to produce. Must end with double MS-DOS-style
    # "CR/LF" end-of-line sequence. In Python, that
    # translates to "\r\n.
    outfile.write("Content-Type: text/plain\r\n\r\n")

    # Now write our page: the time, in plain text
    outfile.write(time.ctime() + "\n")

到现在为止,您可能想知道我们将如何使我们的处理程序类调用此函数。使用 SCGI 1.12 或更高版本,这很容易。我们可以编写一个方法 TimeHandler.produce() 来覆盖 SCGIHandler 的默认操作

class TimeHandler(scgi.scgi_server.SCGIHandler):
    # (remove the "pass" statement--we've got real
    # code here now)

    # This is where we receive requests:
    def produce(self, env, bodysize, input, output):
        # Do our work: write page with the time to output
        print_time(output)

我们在这里忽略它们,但 produce() 接受几个参数:env 是一个将 CGI 参数名称映射到其值的字典。接下来,bodysize 是请求主体或有效负载的大小(以字节为单位)。如果您对请求主体感兴趣,请从以下参数 input 中读取最多 bodysize 字节。最后,output 是我们将输出页面写入的文件。

如果您有 SCGI 1.11 或更旧版本,则需要一些包装代码才能使其工作。在这些旧版本中,您覆盖了不同的方法 SCGIHandler.handle_connection(),并自己完成更多工作。只需将清单 4 中的样板代码复制到 TimeHandler 类中即可。它将正确设置一切并调用 produce(),因此没有其他更改,我们可以像拥有较新版本的 SCGI 一样编写 produce()。

清单 4. SCGI 1.11 或更旧版本的样板代码

  # Insert this definition into your handler class:
class TimeHandler(scgi.scgi_server.SCGIHandler):

    # ...

    def handle_connection(self, conn):
        input = conn.makefile("r")
        output = conn.makefile("w")
        env = self.read_env(input)
        bodysize = int(env.get('CONTENT_LENGTH',0))
        try:
            self.produce(env,bodysize,input,output)
        finally:
            output.close()
            input.close()
            conn.close()

再次运行应用程序并检查它是否在您的浏览器中显示时间。

接下来,为了使事情更有趣,让我们将一些参数传递给请求,并让程序处理它们。Web 应用程序参数的约定是在 URL 上附加一个问号,后跟一系列用 & 符号分隔的参数。每个参数的形式为 name=value。如果我们想将一个名为 pizza 的参数传递给程序,其值为 hawaii,另一个名为 drink 的参数,其值为 beer,我们的 URL 看起来像 http://example.org/scgitest?pizza=hawaii&drink=beer。

访问者传递给程序的任何参数最终都会出现在单个 CGI 参数 QUERY_STRING 中。在这种情况下,参数将读取“pizza=hawaii&drink=beer”。这是我们的 TimeHandler 可能对它做的事情

class TimeHandler(scgi.scgi_server.SCGIHandler):
    def produce(self, env, bodysize, input, output)
        # Read arguments
        argstring = env['QUERY_STRING']
        # Break argument string into list of
        # pairs like "name=value"
        arglist = argstring.split('&')

        # Set up dictionary mapping argument names
        # to values
        args = {}
        for arg in arglist:
          (key, value) = arg.split('=')
          args[key] = value

        # Print time, as before, but with a bit of
        # extra advice
        print_time(output)
        output.write(
         "Time for a pizza. I'll have the %s and a swig of %s!\n" %
         (args['pizza'], args['drink'])
        )

现在我们编写的应用程序不仅会打印时间,还会建议在 URL 中传递的披萨和饮料。试试看!您还可以尝试清单 3 中的其他 CGI 参数,以找到您的 SCGI 应用程序可以做的更多事情。

移植应用程序

一旦您习惯了使用 SCGI 编写程序,您可能想尝试调整现有应用程序以使用它。一些著名的 Web 应用程序,例如 MoinMoin(一个 wiki)和 Trac(一个基于 wiki 的协作开发环境),都是作为 Python 模块实现的。这两个示例都附带了 Python 中的 CGI 脚本,可以从 Apache 调用。CGI 脚本非常短;它们实际上除了导入应用程序的模块并在其上调用函数之外什么都不做。

如果您找到这样的应用程序,那么您真正需要做的就是使其与 SCGI 一起工作,就是获取一小段 Python 代码并将其移动到 produce() 方法中,就像您在这里看到的示例一样。如果您有 SCGI 1.12 或更高版本,您可能还想查看替代的 SCGIHandler 方法 produce_cgilike()。

结论

这就是我们所能容纳的全部内容。如果您想知道 CGI 参数是如何工作的,请尝试查看 CGI 标准,它将它们称为“请求元变量”(请参阅资源)。

最后,一个警告。您会注意到,如果您未能传递预期的参数,最后一个示例程序会惨死。SCGI 服务器会替换失败的进程,因此在这种情况下,没有实际问题。但是,这应该提醒您在编写 Web 应用程序时需要多么小心。永远不要相信您从外部收到的输入!如果程序可以崩溃,则可能有人可以破坏它或使其停止运行。世界各地的人们出于乐趣或利益而做这类事情,因此请认真对待风险。

Jeroen Vermeulen 在泰国软件产业促进署的开源部门工作。他目前正在开发 Suriyan,这是一个为那些没有时间处理服务器系统的人设计的服务器系统。

加载 Disqus 评论