LAMP Stack, but make it Wasm
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
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
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
andfastcgi_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
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 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 indocker-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
tomysql_native_password
. We don't yet havelibopenssl
for WASI so php-cgi.wasm cannot calculate SHA checksums. - Setting up the Wordpress DB and user
Setting up 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.
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:
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 theignore_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!