基于 Docker 的 Flarum 轻论坛部署方案

Docker

Flarum

技术

2023.05.11 更新: 本方案的复杂度仍较高,不建议使用,本文仅作存档 目前的最新方案已有更方便的 沉淀改动、打包部署 流程 可以参考:https://github.com/0xffff-one/flarum-0x

Flarum 是一个简洁的轻论坛程序,交互体验做的十分不错,也有良好的插件扩展机制。接触过的人可能知道,它目前还在 beta,在功能更新和迭代方面不算稳定,部署、修改与定制功能更是一件麻烦的事情。

在 2018 年,我基于它构建了 0xFFFF 社区。经过两年的不断推翻与修改,慢慢沉淀下了一套适合持续迭代的 Flarum 部署与开发迭代方案。

这里主要介绍 Flarum 在服务器和本地开发环境的部署方案。本文假定读者对 Linux 命令行操作、Docker 与 Docker Compose 有基本的了解。相关文件均已开源在 GitHub: zgq354/flarum-docker-env

Why Docker

在 Linux 折腾 LAMP/LNMP 的同学可能经常被各种环境配置的细节问题折磨,诸如 Nginx 配置、“伪静态”(URL Rewrite)、各种文件权限、所有者问题等等。好不容易配置好了,过一两个月可能已经完全忘记,在未来需要修改或更新之时,如西西弗斯受罚一般,重重复复做着相似的事。

基于 Docker,只需要一系列配置文件,就可以从各种各样的针对手动配置解放出来,通过 Git 管理配置的历史版本。可以随时切换环境配置,而不担心因时间的流逝忘记当初是怎么搞的。

接下来会介绍这个方案的细节,若只想把项目跑起来,可以直接跳到本文的 “使用” 小节。

镜像的选择

官方 安装文档 对环境的要求:

  • Apache (with mod_rewrite enabled) or Nginx
  • PHP 7.2.9+ with the following extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
  • MySQL 5.6+ or MariaDB 10.0.5+
  • SSH (command-line) access to run Composer

本质上来说是一个基于 LAMP/LNMP 架构的应用,所以我们只需要准备三个东西:Web 服务器、PHP 和 数据库,这里用到三个应用容器:

  • Nginx:Web 服务器,负责输出静态文件、将需要 PHP 处理的请求通过 FastCGI 协议 转发给 PHP-FPM
  • PHP-FPM:PHP 的 FastCGI 进程管理器,接收 Web 服务器的 FastCGI 请求,执行对应的 PHP 脚本
  • MySQL 5.7:网站专用数据库

再考虑到数据库管理、还有 HTTPS 证书签发的问题,我们再加上这俩:

在申请到 Let's Encrypt 证书之前,为了完整提供 HTTPS,Nginx 需默认提供使用自签名证书的选项。PHP-FPM 需要安装各种 PHP 扩展,所以 Nginx 与 PHP-FPM 会在基础镜像之上再做一些自定义修改。

为了开发迭代的方便,我们把网站主体文件放在宿主机,然后通过 Volume 的方式绑定 Docker 容器,这一点接下来会提到。

目录结构

Docker 容器在设计用途上不考虑状态的持久化,每次更新配置,都会通过重新创建新的容器替换原本的容器,原本容器会被销毁。为了数据的持久化,Docker 提供了 Volume 的机制,将 Volume 挂载到容器文件系统的指定路径,写入的数据会通过 Volume 保留。我们把宿主机的特定路径作为 Volume,实现容器内目录和宿主机的映射。需持久化的有:

  1. 数据库数据的文件(MySQL 一般在 /var/lib/mysql
  2. Nginx 的 Web 访问日志、配置文件
  3. 证书签发相关文件

本着 Docker 容器产生的文件都归于一处的原则,我们把相关的文件都归在宿主机下的 ./data 之下。网站主体代码也通过 Volume 挂载,这里放在 ./www 之下,整体目录结构安排如下:

.
├── data
│   ├── db-data           # MySQL 数据文件
│   ├── logs              # 日志文件
│   └── ssl               # ssl 证书相关配置
├── docker-compose.yml
├── nginx                 # Nginx 镜像相关文件
│   ├── Dockerfile
│   ├── conf              # Nginx 配置
│   └── start.sh
├── php-fpm               # php-fpm 镜像相关
│   └── Dockerfile
└── www                   # 站点相关文件

各容器配置

本节将展开介绍各个容器的配置细节,包括 MySQL、Nginx、php-fpm、phpMyAdmin 以及 acme.sh 的证书申请机制。

MySQL

MySQL 容器直接用官方镜像,通过 .env 设置环境变量,加载 MySQL 初始化的连接密码等。

services:
  database:
    image: mysql:5.7
    restart: always
    container_name: site-db
    expose:
      - 3306
    environment:
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASS}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
    volumes:
      - ./data/db-data:/var/lib/mysql

Nginx

Nginx 采用了基于 alpine 的镜像,体积较小。在配置上,大体参考了 Nginx 在发行版中的目录结构,并参考了 Debian 的 nginx 包的目录安排,再考虑 Nginx 镜像内部的结构,绑定了三个路径。

 - ./nginx/conf/nginx.conf:/etc/nginx/nginx.conf
 - ./nginx/conf/conf.d:/etc/nginx/conf.d
 - ./nginx/conf/snippets:/etc/nginx/snippets

各个路径的作用:

  • nginx.conf:覆盖原始的配置文件
  • conf.d:Nginx HTTP 服务与站点相关的配置,会被 nginx.conf include 进去
  • snippets:各种代码段,这里放了一个 SSL 相关的配置

对于 Web 站点的文件,我们把容器内部 /www/flarum 绑定到本地的 ./www/flarum

nginx.conf 参考:

user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events { worker_connections 1024; }

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    log_format main '$http_x_forwarded_for - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"';

    access_log /var/log/nginx/access.log;

    sendfile on;
    keepalive_timeout 65;
    client_max_body_size 20M;

    include conf.d/*.conf;
}

SSL 相关参数,毕竟我们不是安全人员,自己配置并不稳妥,所以还是用 Mozilla 提供的工具 生成吧。

snippets/ssl-params.conf 参考:

# generated 2020-05-21, Mozilla Guideline v5.4, nginx 1.17.7, OpenSSL 1.1.1d, intermediate configuration, no HSTS, no OCSP
# https://ssl-config.mozilla.org/#server=nginx&version=1.17.7&config=intermediate&openssl=1.1.1d&hsts=false&ocsp=false&guideline=5.4

ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;  # about 40000 sessions
ssl_session_tickets off;

# curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam
ssl_dhparam /etc/ssl/dhparam.pem;

# intermediate configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;

其中有一个比较关键的 DH 参数,也用 Mozilla 推荐的,我们把这个逻辑加到 Dockerfile。

curl https://ssl-config.mozilla.org/ffdhe2048.txt > /etc/ssl/dhparam.pem

站点相关配置,SSL 证书默认放在 /etc/ssl/certs/ 的以域名命名的目录下,参考以下配置,这里证书相关的参数,引用了 snippets/ssl-params.conf

conf.d/flarum.conf 的配置参考如下:

server {
    listen 80;
    listen 443 ssl http2;

    ssl_certificate /etc/ssl/certs/example.com/full.pem;
    ssl_certificate_key /etc/ssl/certs/example.com/key.pem;
    include snippets/ssl-params.conf;

    # should be changed
    server_name example.com;

    root /www/flarum/public;
    index index.php index.html;

    server_tokens off;
    access_log /var/log/nginx/flarum-access.log;
    error_log /var/log/nginx/flarum-error.log;

    # for let's encrypt
    location /.well-known/ {
        alias /.well-known/;
    }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php-fpm-service:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

    # Pass requests that don't refer directly to files in the filesystem to index.php
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # The following directives are based on best practices from H5BP Nginx Server Configs
    # https://github.com/h5bp/server-configs-nginx

    # Expire rules for static content
    location ~* \.(?:manifest|appcache|html?|xml|json)$ {
        add_header Cache-Control "max-age=0";
    }

    location ~* \.(?:rss|atom)$ {
        add_header Cache-Control "max-age=3600";
    }

    location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|mp4|ogg|ogv|webm|htc)$ {
        add_header Cache-Control "max-age=2592000";
        access_log off;
    }

    location ~* \.(?:css|js)$ {
        add_header Cache-Control "max-age=31536000";
        access_log off;
    }

    location ~* \.(?:ttf|ttc|otf|eot|woff|woff2)$ {
        add_header Cache-Control "max-age=2592000";
        access_log off;
    }

    # Gzip compression
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_proxied any;
    gzip_vary on;
    gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/vnd.api+json
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;
}

在 Dockerfile 的配置上,为了避免进程无法停止、僵尸进程等问题,容器加入 dumb-init 作为入口程序。

考虑到证书可能不存在的情况,修改启动脚本加入检测证书是否存在的机制。若证书不存在,就调用 OpenSSL 自签一个证书,避免启动失败(但这个证书也不会被客户端信任),具体的域名则通过环境变量传入。

启动脚本 start.sh:

#!/bin/sh -
CERT_DOMAIN=${DOMAIN:-example.com}

if [[ ! -e /etc/ssl/certs/$CERT_DOMAIN/key.pem ]]; then
    mkdir -p /etc/ssl/certs/$CERT_DOMAIN
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/certs/$CERT_DOMAIN/key.pem -out /etc/ssl/certs/$CERT_DOMAIN/full.pem \
      -subj "/C=CN/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=$CERT_DOMAIN"
fi

nginx -g "daemon off;"

也就是说,这里预置了一个自签名的 ssl 证书,若不向 Let's Encrypt 申请证书,你对这一系列容器的 HTTPS 请求是不受浏览器信任的。

php-fpm

php-fpm 镜像较为简单,直接配置 Dockerfile,在 php:7.4-fpm-alpine 镜像的基础上再加上 gdpdo_mysqlexif 扩展(缺啥补啥)。

还需要考虑 Docker 内用户的 UID 与宿主机用户的 UID 的对应关系,涉及到写入权限的问题。(Docker Volume 的文件所有者的 UID 与宿主机是同步的,可能同一 UID 对应不同的用户名)。

Dockerfile 如下:

FROM php:7.4-fpm-alpine

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US.UTF-8
ENV LC_ALL=en_US.UTF-8

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories && \
    echo "Asia/Shanghai" > /etc/timezone

RUN apk add \
        freetype \
        freetype-dev \
        libpng \
        libpng-dev \
        oniguruma-dev \
        libjpeg-turbo \
        libjpeg-turbo-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd \
    && apk del \
        freetype-dev \
        libpng-dev \
        libjpeg-turbo-dev

RUN docker-php-ext-install pdo_mysql opcache exif

RUN apk --no-cache add shadow \
    && usermod -u 1000 www-data \
    && groupmod -g 1000 www-data \
    && rm /var/cache/apk/*

ENTRYPOINT ["docker-php-entrypoint"]

STOPSIGNAL SIGQUIT

EXPOSE 9000
CMD ["php-fpm"]

phpMyAdmin

引入 phpmyadmin/phpmyadmin:fpm-alpine 镜像,镜像内的文件都在 /var/www/html,这里我们将 phpMyAdmin 内的 /var/www/html 通过 Volume 映射到 Nginx 的 /www/pma 目录下,这样 Nginx 遇到静态文件请求可以直接通过 /www/pma 访问到,遇到动态文件请求时,则转发给 phpMyAdmin 的容器。

location ~ \.php$ {
    try_files $uri /index.php$is_args$args;
    fastcgi_pass pma-service:9000;
    fastcgi_hide_header X-Powered-By;

    # 传给 phpMyAdmin 容器的 php-fpm 的路径 (/var/www/html)
    fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
    include fastcgi_params;
}

如上,在写处理 .php 后缀的 location 的转发配置时需要留意 /www/pma 与 /var/www/html 的差异。这时候我们需要引入 fastcgi_params 文件的预置参数,然后硬编码 SCRIPT_FILENAME

完整配置参考:conf.d/pma.conf

Let's Encrypt 证书申请

这里申请签发证书的部分,我们采用 acme.sh 的 Docker 方案,acme.sh 容器以守护进程的形式运行。

所有的证书相关文件都放在了容器的 /acme.sh 目录中,这里我们把它映射到 ./data/ssl/acmeout 里(具体参考 docker-compose.yml 的配置)。

Let's Encrypt 签发证书有多种验证方式,acme.sh 均有封装。若不希望配置 DNS,可以使用 HTTP 的方式验证,本方案将 acme.sh 容器的 /.well-known 映射到了宿主机的 ./data/ssl/.well-known ,Nginx 把 ./data/ssl/.well-known 映射到了 /.well-known 。

通过 alias 指令实现访问验证文件的效果,如 flarum.conf 中的例子:

# for let's encrypt
location /.well-known/ {
    alias /.well-known/;
}

然后我们可以用 docker exec ,采用 HTTP 验证的途径来执行申请命令,稍等片刻即可申请好:

docker exec acme.sh --issue -d example.com -w /

申请好的证书需执行 acme.sh 的 deploy 部署到 nginx 中,用环境变量加载参数,同样以 example.com 为例。

docker exec \
  -e DEPLOY_DOCKER_CONTAINER_LABEL=sh.acme.autoload.domain=example.com \
  -e DEPLOY_DOCKER_CONTAINER_KEY_FILE=/etc/ssl/example.com/key.pem \
  -e DEPLOY_DOCKER_CONTAINER_CERT_FILE="/etc/ssl/example.com/cert.pem" \
  -e DEPLOY_DOCKER_CONTAINER_CA_FILE="/etc/ssl/example.com/ca.pem" \
  -e DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/etc/ssl/example.com/full.pem" \
  -e DEPLOY_DOCKER_CONTAINER_RELOAD_CMD="kill 1" \
  acme.sh --deploy -d example.com --deploy-hook docker

然后 acme.sh 的守护进程将会定期检查,在证书快过期的时候自动执行续期逻辑。在执行完续期逻辑后,会在标记了 sh.acme.autoload.domain=example.com 的标签的 nginx 容器执行 kill 1,干掉这个容器的进程,自动重启容器,实现证书的重新加载。

完整 docker-compose.yaml

version: "3.6"

services:
  database:
    image: mysql:5.7
    restart: always
    container_name: site-db
    expose:
      - 3306
    environment:
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASS}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
    volumes:
      - ./data/db-data:/var/lib/mysql

  nginx:
    image: nginx-flarum
    build:
      context: ./nginx
      args:
        - DOMAIN=${DOMAIN}
    container_name: site-nginx
    restart: always
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./data/logs:/var/log/nginx
      - ./data/ssl/.well-known:/.well-known
      - ./data/ssl/certs:/etc/ssl/certs
      - ./nginx/conf/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/conf/conf.d:/etc/nginx/conf.d
      - ./nginx/conf/snippets:/etc/nginx/snippets
      - ./www/flarum:/www/flarum
      - pma-root:/www/pma # phpMyAdmin
    environment:
      - DOMAIN=${DOMAIN}
    extra_hosts:
      - "localhost:127.0.0.1"
    labels:
      - sh.acme.autoload.domain=${DOMAIN}
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider --proxy off http://localhost/get-health || exit 1"]
      interval: 5s
      retries: 12
    logging:
      driver: "json-file"
      options:
        max-size: "100m"

  acme.sh:
    image: neilpang/acme.sh
    container_name: acme.sh
    command: daemon
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data/ssl/acmeout:/acme.sh
      - ./data/ssl/.well-known:/.well-known
    environment:
      - DEPLOY_DOCKER_CONTAINER_LABEL=sh.acme.autoload.domain=${DOMAIN}
      - DEPLOY_DOCKER_CONTAINER_KEY_FILE=/etc/ssl/certs/${DOMAIN}/key.pem
      - DEPLOY_DOCKER_CONTAINER_CERT_FILE="/etc/ssl/certs/${DOMAIN}/cert.pem"
      - DEPLOY_DOCKER_CONTAINER_CA_FILE="/etc/ssl/certs/${DOMAIN}/ca.pem"
      - DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/etc/ssl/certs/${DOMAIN}/full.pem"
      - DEPLOY_DOCKER_CONTAINER_RELOAD_CMD="kill 1"

  php-fpm-service:
    image: php-fpm-flarum
    build: ./php-fpm
    container_name: site-php-fpm
    restart: always
    expose:
      - 9000
    volumes:
      - ./data/logs:/var/log
      - ./www/flarum:/www/flarum
    healthcheck:
      test: ["CMD-SHELL", "pidof php-fpm"]
      interval: 5s
      retries: 12
    logging:
      driver: "json-file"
      options:
        max-size: "100m"

  pma-service:
    image: phpmyadmin/phpmyadmin:fpm-alpine
    container_name: site-pma
    restart: always
    environment:
      - PMA_HOST=site-db
    volumes:
      - pma-root:/var/www/html

volumes:
  pma-root:

使用

创建 Flarum 文件

在开始使用本方案的环境之前,你需要在宿主机本地先把 Flarum 站点的文件准备好。

首先安装 PHP 包管理器 Composer:

wget -O composer-setup.php https://getcomposer.org/installer
php composer-setup.php --install-dir=bin --filename=composer

设置国内镜像(避免加载过慢,这里可以用阿里云的镜像)

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

这里我们假设站点文件都放在 /var/www/flarum 中(假设你有 /var/www 的所有者,若不是,可 sudo chown <你的用户名>:<你的用户名> /var/www ),执行安装。

cd /var/www/
mkdir flarum && cd flarum
composer create-project flarum/flarum . --stability=beta

等 composer 跑完,安装 Flarum 需要的文件已经准备好了。

部署

无论是线上部署还是本地开发,套路都很一致。

  1. 首先在宿主机安装 Docker CE 与 Docker Compose
  2. 克隆项目代码(你也可以用这个 Template 创建自己的项目,再克隆,这样可以自己更新)
cd /var/www
git clone https://github.com/zgq354/flarum-docker-env.git
cd flarum-docker-env
  1. 创建符号链接(若不想创建符号链接,也可以在 www/flarum 里面执行 composer create-project flarum/flarum . --stability=beta 加入安装文件)
ln -s /var/www/flarum www/flarum
  1. 创建环境变量配置 .env 文件,可参考 .env-example
cp .env-example .env
vim .env

DB_PASS,DB_ROOT_PASS 需改成实际想要的密码,Flarum:

DOMAIN=example.com

DB_NAME=flarum_db
DB_USER=flarum_db_user
DB_PASS=xxxxx
DB_ROOT_PASS=xxxxx
  1. 修改 nginx 配置,把 pma.confflarum.conf 里面的 server_name 配置为对应的域名。
  2. 启动
docker-compose up -d

然后把域名解析至服务器所在 IP,就能打开安装界面了,安装时需注意,MySQL Host 应为 MySQL Docker 容器对应的 site-db

2291687024.jpg2291687024.jpg

安装界面

没有现成的域名?没关系,你可以参考接下来的本地环境的方案来将任意域名指向服务器的 IP。

完成以上步骤后,若需要跑在线上环境,还需按照前文 acme.sh 的部分的方式,申请 Let's Encrypt 认证的 ssl 证书。

本地环境

本地环境开发,推荐使用 LightProxy 作为开发环境调试的代理工具,LightProxy 是开源抓包工具 whistle 的桌面版封装,可以用类似 hosts 的语法指定域名和 IP 的对应关系。

example.com 127.0.0.1

若在本地部署,按 127.0.0.1 的方式就可以在本地访问,开发环境与生产环境保持同一域名。

限于篇幅,关于本地开发环境的搭建、调试、版本管理等方案,我们下一篇文章再具体介绍。

最后

使用有任何问题,可在文末留言,或在 项目 issue 提出,也欢迎加入 0xFFFF 社区交流群,一起玩耍!

参考: