基于 Docker 的 Flarum 轻论坛部署方案
date
Jun 19, 2020
note
slug
flarum-docker-deployment
type
Post
status
Published
tags
技术
Flarum
Docker
summary
Flarum 是一个简洁的轻论坛程序,交互体验做的十分不错,也有良好的插件扩展机制。接触过的人可能知道,它目前还在 beta,在功能更新和迭代方面不算稳定,部署、修改与定制功能更是一件麻烦的事情。
在 2018 年,我基于它构建了 0xFFFF 社区。经过两年的不断推翻与修改,慢慢沉淀下了一套适合持续迭代的 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 证书签发的问题,我们再加上这俩:
- acme.sh:一键向 Let's Encrypt 申请证书
在申请到 Let's Encrypt 证书之前,为了完整提供 HTTPS,Nginx 需默认提供使用自签名证书的选项。PHP-FPM 需要安装各种 PHP 扩展,所以 Nginx 与 PHP-FPM 会在基础镜像之上再做一些自定义修改。
为了开发迭代的方便,我们把网站主体文件放在宿主机,然后通过 Volume 的方式绑定 Docker 容器,这一点接下来会提到。
目录结构
Docker 容器在设计用途上不考虑状态的持久化,每次更新配置,都会通过重新创建新的容器替换原本的容器,原本容器会被销毁。为了数据的持久化,Docker 提供了 Volume 的机制,将 Volume 挂载到容器文件系统的指定路径,写入的数据会通过 Volume 保留。我们把宿主机的特定路径作为 Volume,实现容器内目录和宿主机的映射。需持久化的有:
- 数据库数据的文件(MySQL 一般在
/var/lib/mysql
)
- Nginx 的 Web 访问日志、配置文件
- 证书签发相关文件
本着 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
镜像的基础上再加上 gd
、pdo_mysql
、exif
扩展(缺啥补啥)。还需要考虑 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 需要的文件已经准备好了。
部署
无论是线上部署还是本地开发,套路都很一致。
- 首先在宿主机安装 Docker CE 与 Docker Compose
- 克隆项目代码(你也可以用这个 Template 创建自己的项目,再克隆,这样可以自己更新)
cd /var/www git clone https://github.com/zgq354/flarum-docker-env.git cd flarum-docker-env
- 创建符号链接(若不想创建符号链接,也可以在
www/flarum
里面执行composer create-project flarum/flarum . --stability=beta
加入安装文件)
ln -s /var/www/flarum www/flarum
- 创建环境变量配置
.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
- 修改 nginx 配置,把
pma.conf
、flarum.conf
里面的server_name
配置为对应的域名。
- 启动
docker-compose up -d
然后把域名解析至服务器所在 IP,就能打开安装界面了,安装时需注意,MySQL Host 应为 MySQL Docker 容器对应的
site-db
。
没有现成的域名?没关系,你可以参考接下来的本地环境的方案来将任意域名指向服务器的 IP。
完成以上步骤后,若需要跑在线上环境,还需按照前文 acme.sh 的部分的方式,申请 Let's Encrypt 认证的 ssl 证书。
本地环境
本地环境开发,推荐使用 LightProxy 作为开发环境调试的代理工具,LightProxy 是开源抓包工具 whistle 的桌面版封装,可以用类似 hosts 的语法指定域名和 IP 的对应关系。
example.com 127.0.0.1
若在本地部署,按
127.0.0.1
的方式就可以在本地访问,开发环境与生产环境保持同一域名。限于篇幅,关于本地开发环境的搭建、调试、版本管理等方案,我们下一篇文章再具体介绍。
最后
使用有任何问题,可在文末留言,或在 项目 issue 提出,也欢迎加入 0xFFFF 社区交流群,一起玩耍!
参考: