Wasm Labs

LAMP Stack, but make it Wasm

By Asen Alexandrov
At 2023 / 04 10 mins reading

TL;DR

WebAssembly is an exciting new technology that, among other things, allows running existing code and applications in a secure, capabilites-based sandbox. In particular, we already released mod_wasm, which allows you to run PHP-based Web applications such as WordPress using Apache

This article builds on that work and introduces a new project that enables any WebServer to run Wasm-based PHP applications, including those using MySQL databases, not just SQLite. It uses the FastCGI protocol and the improved socket support in WasmEdge.

The article will show how to do a traditional end-to-end WordPress deployment, with a frontend Nginx proxy, a (WebAssembly based) PHP server and a MySQL service.

If you want to dive right into the setup, you can find the details inside the Wasm Language Runtimes repository.

Background

One of our goals at WasmLabs is to enable web developers to quickly adopt Wasm. One way of doing that is making it easy to run existing Web application (such as WordPress and Drupal) unmodified. One of the major obstacles to this is the lack of full socket support in WASI, which prevents traditional workloads from using remote services over the network.

WasmEdge is a popular Wasm runtime that has experimental support for sockets that covers more scenarios. We build on past work with WasmEdge and PHP and extend it further for supporting end-to-end WordPress deployments with a separate database service.

Why WordPress?

WordPress runs a large percentange of websites. If we can make WordPress run well with WebAssembly, this is a first step towards:

  • Helping make the web more secure, as Wasm's sandboxing provides security improvements, especially in terms of filesystem access.
  • Running WordPress in more places, as Wasm's portability allows deploying apps in more flexible ways, such as using Edge nodes without a traditional, POSIX OS.

Previous work

We have already done a few steps toward our goal. First, we worked on porting PHP for Wasm+Emscripten running WordPress in the browser. Then we ported PHP to Wasm+WASI and got WordPress running server-side with mod_wasm. As a next step, we worked on leveraging WasmEdge socket support to get a PHP development server running on Wasm+WASI. Later on, we got WordPress running on that PHP server with WasmEdge.

All our previous attempts were relying on SQLite-based deployments, which allowed us to focus on just one thing at a time not having to handle communication with other services. Now it is possible to extend PHP server side and client side connectivity and implement database access and FastCGI support.

Deployment

Quick overview of deployed service

To demonstrate how this all works we created a deployment example of Nginx, php-cgi.wasm and MySQL that can be used to set up and run WordPress.

We use docker-compose to deploy the different services and some bash commands to configure them to run together. This last part could also be moved to a separate configuration service, but we decided to not go there for simplicity.

All services mount folders in the same $PWD/wlr-tmp folder specified by a WNPFM_TEST_DIR variable and each of them exports listening ports on the host. This is useful for testing but also necessary for easier setup of the PHP-to-MySQL connection. The reason is that with WasmEdge sockets we don't have DNS resolution and can only connect to hosts by IP address.

Configuring Nginx as an FCGI frontend

proxy-nginx configuration

The proxy-nginx service is configured as follows.

  proxy-nginx:
container_name: wnpfm-proxy-nginx
image: nginx:alpine
ports:
- ${WNPFM_HOST_PORT}:80
volumes:
- ${WNPFM_TEST_DIR}/wordpress:/var/www/wordpress
- ${WNPFM_TEST_DIR}/logs/nginx:/var/log/nginx
- ./proxy-nginx/default.conf.template:/etc/nginx/templates/default.conf.template
environment:
- WNPFM_PHP_FCGI_HOST=server-php-fcgi-wp
- WNPFM_PHP_FCGI_PORT=${WNPFM_PHP_FCGI_PORT}
depends_on:
- server-php-fcgi-wp

The Nginx image supports configuration templates based on environment variables, which get expanded on the first run and allow us to configure the service with the proper hostname and port of the PHP FCGI server. Thus we overwrite default.conf by the template specified in WLR/php/examples/wp-nginx-php-fcgi-mysql/proxy-nginx/default.conf.template.

For debugging purposes, all Nginx logs will be available in $PWD/wlr-tmp/logs/nginx.

The document root is configured at /var/www/wordpress and available in $PWD/wlr-tmp/wordpress. Note that the mounted path should match the one seen by the FCGI server.

The variables WNPFM_PHP_FCGI_HOST and WNPFM_PHP_FCGI_PORT are used by the fastcgi_pass directive in default.conf.template.

The most notable part of that configuration is shown below.

  • We pass the FCGI requests to a server specified by the above variables.
  • We inform the FCGI server of the full path to the script that has to be handled (which is relative to the configured document root).
  • We add a good amount of time to keepalive_timeout, fastcgi_read_timeout and fastcgi_send_timeout to make sure we are well over slow handling of requests in debug scenarios.
...
listen 80;
...
root /var/www/wordpress;
...
location ~ \.php$ {
fastcgi_pass ${WNPFM_PHP_FCGI_HOST}:${WNPFM_PHP_FCGI_PORT};
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
...
keepalive_timeout 3600s;
...
fastcgi_send_timeout 3600s;
fastcgi_read_timeout 3600s;

Setting up php-cgi.wasm

proxy-nginx + server-php-fcgi-wp configuration

The server-php-fcgi-wp service is configured as follows.

  server-php-fcgi-wp:
container_name: wnpfm-php-cgi-wasmedge
image: wnpfm-php-cgi-wasmedge
platform: wasi/wasm
build:
context: php-cgi-wasmedge
ports:
- ${WNPFM_PHP_FCGI_PORT}:9000
volumes:
- ${WNPFM_TEST_DIR}/wordpress:/var/www/wordpress
restart: unless-stopped
runtime: io.containerd.wasmedge.v1

To prepare the PHP FCGI server we first need to build a container image with php-cgi-8.2.0-wasmedge.wasm, which can be found in the latest PHP release. The container image is described in WLR/php/examples/wp-nginx-php-fcgi-mysql/php-cgi-wasmedge/Dockerfile.

FROM --platform=$BUILDPLATFORM debian:buster-slim AS builder
...
WORKDIR /opt/work
RUN curl -L -o php-cgi-8.2.0-wasmedge.wasm \
https://github.com/vmware-labs/.../releases/.../php-cgi-8.2.0-wasmedge.wasm

...
RUN /root/.wasmedge/bin/wasmedgec \
--optimize 0 \
/opt/work/php-cgi-8.2.0-wasmedge.wasm \
php-cgi-8.2.0-wasmedge-aot.wasm


FROM scratch
ENTRYPOINT [
"php-cgi-8.2.0-wasmedge.wasm",
"-b", "0.0.0.0:9000",
"-d", "ignore_user_abort=On"]
COPY --link --from=builder \
/opt/work/php-cgi-8.2.0-wasmedge-aot.wasm \
/php-cgi-8.2.0-wasmedge.wasm

Please note:

  • For performance reasons we do an AOT optimization of the binary, which will make it run with close-to-native speed on the specific host platform. This may take a few minutes to complete.
  • When running the server we will always listen on port 9000, which gets mapped to WNPFM_PHP_FCGI_PORT on the host, as configured in docker-compose.yml
  • Also, note the -d ignore_user_abort=On option. The reasoning behind it is described below in the performance section.

When running the service we mount /var/www/wordpress from $PWD/wlr-tmp/wordpress as Nginx will send us FCGI requests for .php files based in that root path.

Configuring the MySQL DB

Setting up a pure MySQL image is straightforward, as we do all the additional configurations (creation of DB, users, policies, etc) later.

  db-mysql:
container_name: ${WNPFM_DB_CONTAINER_NAME}
image: mysql
environment:
MYSQL_ROOT_PASSWORD: ${WNPFM_DB_ROOT_PASSWORD}
ports:
- 3306:3306
volumes:
- ${WNPFM_TEST_DIR}/db:/var/lib/mysql

For debugging purposes, the database can be found at $PWD/wlr-tmp/db.

The rest of the DB configuration steps can be seen in the run_me.sh orchestration script and cover:

  • Changing the default-authentication-plugin to mysql_native_password. We don't yet have libopenssl for WASI so php-cgi.wasm cannot calculate SHA checksums.
  • Setting up the WordPress DB and user

Setting up WordPress

proxy-nginx + server-php-fcgi-wp + db-mysql + WordPress

Finally, to set up WordPress we need to do a few more steps written up in run_me.sh:

  • Download and extract WordPress into $PWD/wlr-tmp/wordpress. If you examine the script you will notice that we keep a local /tmp/wnpfm-temp-download cache to avoid downloading on each run.
  • Set up the wp-config.php file with the proper DB host and credentials.
  • Do a POST call to http://localhost:${WNPFM_HOST_PORT}/wp-admin/install.php?step=2 to complete the final setup.

After this, it is just as easy as opening http://localhost:8080/wp-admin and authenticating with wp_admin / wp_admin_password.

Deep dive

Let's take a look at the PHP code improvements, which allowed us to get to where we are. Namely - what we did to be able to connect to a remote MySQL server and to be able to handle FastCGI requests.

Both things that we did build on top of our previous work that added wasmedge_socket_ext to the PHP codebase, which is a C-based API over the WasmEdge socket methods.

The approach we used is to patch PHP to have two builds - a typical WASI build and a WASI+WasmEdge build, which would have more code using the extended socket support.

MySQL Client side sockets

The diagram below shows how a hypothetical db_test.php script would use most of the PHP functionality based on wasi-libc, but the client-side socket connection needed by mysqlnd would go through WasmEdge's socket extensions.

Making PHP+MySQL work on WasmEdge

The code in wasmedge_socket_ext tries to act as a drop-in replacement for the <netdb.h> API so making this work with PHP did not require too many changes to the original PHP code. Most of the work was related to enabling code in the php_network.c module that was previously disabled for all WASI builds.

To connect to a remote host (MySQL in this case) you need to be able to do DNS resolution. As there is no DNS in WASI we had to do with a partially working solution. In simple words, we can now connect to the server "10.113.63.149", but not to "my.host.name.com" even if the IP is the actual hostname of the latter. To achieve this we just extended the API in wasmedge_socket_ext by adding a getnameinfo method which always fails. This is handled beautifully by the dns.c code in PHP, which falls back to parsing an IP address from the given hostname if it's a valid IP address.

After the above work, enabling MySQL with PHP was just as easy as adding --enable-mysqlnd --with-pdo-mysql --with-mysqli to the ./configure step during the build plus patching the mysqlnd code to use wasmedge_socket_ext instead of <netdb.h> (drop-in).

A simple self-contained example with MySQL and PDO can be found at WLR/php/examples/mysql.

Enabling FastCGI in php-cgi.wasm

FastCGI (or FCGI for short) improves on CGI by allowing us to start a server process once and let it handle multiple "CGI" requests. PHP has built-in support for a FastCGI server, which was so far disabled for WASI builds. We again leveraged the wasmedge_socket_ext to enable this code for WasmEdge-specific builds. Of course, as WASI has no support for processes we skip the code that manages multiple FastCGI children for better load balancing.

The PHP FCGI server can either listen on a port or poll and read from a pipe (when spawned by external process managers). The original POSIX code would read/write on the respective file descriptor in either case. However, with WasmEdge sockets we could only get it to work if we used recv/send. We did not investigate whether this is a limitation of wasi-libc or WasmEdge, but we believe it is worth sharing.

Another important note is that the address translation in wasmedge_socket_ext from POSIX to the WasmEdge extended WASI types is not working so the server is always listening on 0.0.0.0. We decided not to invest time in this, as it can be worked around at a DevOps level.

Here is a diagram of what this looks like in action:

Making PHP+MySQL work on WasmEdge

Testing the FCGI server during development was easy using adoy/PHP-FastCGI-Client. Just mind that this client requires a native PHP installation and also fetched file paths are relative to the FS root as seen by the fcgi server. For example

# Create a test file
$$ echo '<?php print("Hello from PHP"); ?>' > /real/path/index.php

# Direct CGI flow
$$ wasmedge \
--dir /virtual/path/:/real/path \
php-cgi-8.2.0-wasmedge \
/virtual/path/index.php
X-Powered-By: PHP/8.2.0
Content-type: text/html; charset=UTF-8

Hello from PHP

# FastCGI flow
$$ wasmedge \
--dir /virtual/path/:/real/path \
php-cgi-8.2.0-wasmedge \
-b 0.0.0.0:9000 &
...
$$ php ./fcgiget.php localhost:9000/virtual/path/index.php
Call: /test.php on localhost:9000

X-Powered-By: PHP/8.2.0
Content-type: text/html; charset=UTF-8

Hello from PHP

Performance

Using the PHP FCGI server with an un-optimized binary (while perfect for debugging) can be quite slow. A single request for a real-world application like WordPress could sometimes take 30 to 60 seconds. Here is what one can do to handle this:

  • Configure read timeouts on the FCGI client side (be it the PHP-FastCGI-Client or Nginx or something else) appropriately to allow for enough delay.
  • Do an AOT compilation of the php-8.2.0-wasmedge.wasm binary for the host platform on which it runs. This can be done by wasmedgec, which comes as part of the WasmEdge installation.
  • Configure php-8.2.0-wasmedge.wasm to ignore cases where connection gets aborted by clients. This is necessary because php.wasm has no low-level error handling (due to lack of setjmp/longjmp) and the whole program will exit if a client aborts a connection. The way to disable this is by changing the ignore_user_abort PHP config value. You can do this by adding -d ignore_user_abort=On to the command line, as seen in WLR/php/examples/wp-nginx-php-fcgi-mysql/php-cgi-wasmedge/Dockerfile.

Future work

There are a lot of things we would want to do from here, but to name a few:

  • Test this deployment setup with existing WordPress websites - for performance and correctness
  • Test a similar setup with Drupal
  • Create an alternative option to use with Apache
  • Extract out the wasmedge_sock_ext code to a common library and reuse it with Python and Ruby to enable similar workloads there (Django and Rails)

Most importantly, WebAssembly Language Runtimes is an Open Source project, so you can do your own future work on top of what we have and share it with the community.

Try it out

If you have not done it yet, give it a try by running the example script and playing around with the fresh WordPress instance.

Or you can just use the php-cgi-8.2.0-wasmedge.wasm binary from the latest PHP release and experiment with it.

We would love to get your feedback!

Finally, if you are interested in this CGI-style approach for backwards compatibility with existing language runtimes and applications, check out WAGI, which was one of the very first projects in this area. It is explained in detail by this Fermyon article.

Do you want to stay up to date with WebAssembly and our projects?