AsciiDoc と Antora でマニュアルサイトを構築しました

弊社 LMS WebClass には、学生の学習履歴を可視化する「学習記録ビューア」という機能があります。学習記録ビューアを使い始めるために必要な権限設定、プラグインによる機能拡張もあり、ユーザーの方に使っていただくためにはドキュメントの充実が必須です。学習記録ビューア専用のマニュアルサイトを以前から公開していたのですが、先日プチリニューアル(内部的にはなかなか大きな更新)しました。その際、サイト構築にドキュメントサイトジェネレータ Antora を使用することになりました。Antora 使用事例として興味を持つ人もいるかなと思い、構築プロセスを公開します。

今回リニューアルしたマニュアルサイトはこちらです。

Antora とは

  • Antora は、AsciiDoc からドキュメントサイトを生成するツール
  • デモサイト
  • Antora のドキュメントサイト 自体も Antora で生成されている
  • 複数のリポジトリにコミットされている AsciiDoc から一つのサイトを生成できる
  • Git ブランチやタグからドキュメントの「バージョン」を規定できる
    • 例えば “v1”, “v2” のようなタグまたはブランチを作り、バージョン毎にドキュメントを管理する
    • サイトにアクセスした人は見たいバージョンを切り替えることができるようになる

リニューアル前の状況

  • マニュアル本文は全て AsciiDoc で書いている
    • 1 ページ 1 adoc ファイル
  • サイトの外殻は Pug で書いていた。Pug を HTML に変換する際、AsciiDoc を Asciidoctor (Asciidoctor.js) で HTML に変換し埋め込んでいた
  • Asciidoctor PDF を使って、AsciiDoc を PDF に変換し、サイトからダウンロードできるようにしている
  • 社内でセルフホストしている GitLab でバージョン管理

課題

  • マニュアル本文は AsciiDoc を編集するので楽だが、ナビゲーションメニューを更新するために Pug を編集する必要があり、面倒くささは拭いきれなかった
    • ドキュメントの充実のためには更新のしやすさは必須
  • 検索機能がなかった
  • 目次がページのタイトル下に表示されるが、下にスクロールすると見えなくなるので、文の多いページだと全体が俯瞰しづらかった

リニューアル後

構成

  • マニュアルリポジトリには、複数ある製品のマニュアル AsciiDoc ファイルが全て登録されている
  • マニュアルサイトリポジトリには、antora-playbook.yml という設定ファイルが登録されている
    • antora-playbook.yml に AsciiDoc が登録されているリポジトリやバージョン情報を記述することで、Antora のコマンド一発でサイトが生成できるようになる
  • CI/CD
    1. マニュアルリポジトリで AsciiDoc ファイルを変更しコミットする
    2. GitLab CI の Multi-project pipelines によりマニュアルサイトリポジトリのパイプラインが起動
    3. マニュアルサイトリポジトリのパイプライン内でサイト生成ジョブ実行
    4. サイト生成完了後、Manual Job でデプロイ可能となる

リニューアル前の課題は解決したか

  • ナビゲーションメニュー等の高頻度で更新される部分は全て、 Antora の設定ファイル及び AsciiDoc を編集することで変更できるため、マニュアル更新が楽になった
  • 検索機能を簡単に付けることができた
    • 実装の詳細は後述
  • ページをスクロールしても常に目次が表示されるようになった

多言語化

こちらを参考に、バージョン切り替えの仕組みを使って言語切り替えを実現しました。

antora-playbook.yml:

content:
  sources:
  - url: https://git.lan.datapacific.co.jp/webclass/manual-integrated-portfolio.git
    start_paths:
      - integrated-portfolio/admin/ja
      - integrated-portfolio/admin/en

この方法だと、逆に通常のバージョン切り替えができなくなります。言語毎に別サイトを生成してもよかったかもしれません。

検索機能

  • Antora Lunr Extension を使っている
  • Antora コマンドによるサイト生成時に検索用インデックスも生成される
    • サイト表示後はオフライン検索できる

antora-playbook.yml:

antora:
  extensions:
    - require: '@antora/lunr-extension'
      languages: [ja]

まとめ

マニュアルサイトに必要な機能は全て揃い、マニュアル更新のハードルはかなり下がりました。これから一番大切なコンテンツ改善を進めていきます。

Docker で検証用 CAS サーバ

CAS との連携を確認するのに、Docker でサクッと建てようと思ったら思いのほか手を焼いたのでメモ。

やりたいこと

CAS でSSOするサービス側モジュールの検査のために、とりあえずのCASサーバを立てて使いたい。細かい設定はいらない。

昔は仮想マシンに Tomcat 入れて動かしていましたが、今ならもう Docker でサクッと立つはず。。。

コンテナイメージの配布状況

本家のドキュメント、この素っ気なさからしていやな予感。

本家のコンテナイメージと、ビルドソース

いろいろ試した結果、配布されているコンテナイメージをそのまま何とか動かしても思ったように扱えない。

このコンテナイメージは SRC1 をビルドしたもので、SRC1 は gradle 実行環境と SRC2 をコンテナイメージの中に展開して gradle でビルドをしている。ただし、SRC1 は SRC2 のビルドオプションを最小限しか設定しておらず、実用性に難がある。

SRC2 が本命だが、SRC2 には gradle のビルド環境構築手続きが含まれていない。それを書くと結局は SRC1 を引っ張り出すことになるので、SRC1 を調整してカスタムイメージをビルドすることにする。

念のために Vagrant も探してみたけどいまいちでしたので、やっぱりコンテナのビルド。

予備知識、前提

はまりポイント、特に Java のビルド環境についてあまり経験の無い方は、ビルドオプションを調整するところかと思います(私がそうでした)。

ビルド環境の用意

まずは github.com/apereo/cas-webapp-docker をクローンしてビルドの準備をします。

git clone https://github.com/apereo/cas-webapp-docker.git
cd cas-webapp-docker
# git reset --hard a2d664fd006d86f0bb09d65c2ffb0eeb25018fdf

README.md の SSL セクションを実行します。ここはローカル開発用と割り切って、鍵もPASSもそのままです。

keytool -genkeypair -alias cas -keyalg RSA -keypass changeit \
        -storepass changeit -keystore ./thekeystore \
        -dname "CN=cas.example.org,OU=Example,OU=Org,C=AU" \
        -ext SAN="dns:example.org,dns:localhost,ip:127.0.0.1"

sudo su root -c " echo '127.0.0.1 cas.example.org' >> /etc/hosts"

実際に使うにはまだ設定が必要ですが、念のために一度ビルドして動かしてみます。

./build.sh 6.4
./run.sh 6.4

うまくいけば、コンソール出力に大きく「READY」と表示されます。(ここのコードブロックでの表示では形が崩れているかもしれません)。

2021-12-21 05:02:57,689 INFO [org.apereo.cas.web.CasWebApplication] - <>
2021-12-21 05:02:57,689 INFO [org.apereo.cas.web.CasWebApplication] - <

  ____  _____    _    ______   __
 |  _ \| ____|  / \  |  _ \ \ / /
 | |_) |  _|   / _ \ | | | \ V / 
 |  _ <| |___ / ___ \| |_| || |  
 |_| \_\_____/_/   \_\____/ |_|  

>
2021-12-21 05:02:57,689 INFO [org.apereo.cas.web.CasWebApplication] - <>
2021-12-21 05:20:05,860 INFO [org.apereo.cas.web.CasWebApplication] - <Ready to process requests @ [2021-12-21T05:20:05.859Z]>
2021-12-21 05:20:05,916 INFO [org.apereo.cas.services.AbstractServicesManager] - <Loaded [0] service(s) from [InMemoryServiceRegistry].>

特に、コードブロックにコピーしたログの一番最後の行、 [org.apereo.cas.services.AbstractServicesManager] - <Loaded [0] service(s) from [InMemoryServiceRegistry].> というのがポイントです。これは、Application ServiceRegistry に登録がないよ、と言っています。後で改善します。

この状態でブラウザで https://cas.example.org:8443/cas/login を開くとログイン画面を表示できるはずですが、実際には証明書が INVALID なために警告が表示されてしまいます。以下のコマンドで証明書を吐き出して許可リストに加えるか、ブラウザで開いたときに強制的に表示させます。

keytool -export -alias cas -keystore thekeystore -rfc -file cas-self.cert

ログイン画面が表示されると、casuser アカウントで認証は成功するはずです。

アプリケーションサーバの連携確認

アプリケーションサーバにこのCASサーバを登録するには、以下を確認してください。

  • ブラウザは、CASサーバとアプリケーションサーバのどちらにもアクセスできること
  • アプリケーションサーバがこのCASサーバに 8443:tcp でアクセスできること
  • アプリケーションサーバの hosts ファイルにも cas.example.org を足しておくこと
  • アプリケーションサーバの CAS 連携モジュールは証明書チェックを無視する設定になっていること

特に、PHPのCURLについては以下の設定を使います。

curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, FALSE);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, FALSE);

アプリケーションサーバでCAS認証しようとすると、CASサーバにリダイレクトされますが、以下の表示になると思います。

また、CASサーバのログでは試した直後には以下のログが出ているはずです。

2021-12-21 04:13:02,682 ERROR [org.apereo.cas.services.web.support.RegisteredServiceResponseHeadersEnforcementFilter] - <Service unauthorized>

つまり、許可サービスリストの登録が0で、いま試したアプリケーションサーバもはじかれている状態になります。

Docker のカスタマイズと Application ServiceRegistry の設定

ここからは、チェックアウトしているDocker のビルドキットをカスタマイズして、Application ServiceRegistry を設定し直したコンテナイメージを作り直していきます。

まずは2つファイルを新規追加します。1つは cas サーバの起動設定ファイルで、JSONリポジトリを有効に指定します。1つはテスト用アプリケーションサーバの定義ファイルで、http://192.168.56.??/ で動作することを前提としています。

etc/cas/config/cas.properties

cas.server.name=https://cas.example.org:8443
cas.server.prefix=${cas.server.name}/cas

logging.config=file:/etc/cas/config/log4j2.xml

# for json services ( need build option
cas.service-registry.core.init-from-json=true
cas.service-registry.json.watcher-enabled=true
cas.service-registry.json.location=file:/etc/cas/services
#cas.service-registry.yaml.location=file:/etc/cas/services

# cas.authn.accept.enabled=false

etc/cas/services/test-2.json

{
     "@class" : "org.apereo.cas.services.RegexRegisteredService",
            "id":2,
            "serviceId":"http://192.168.*",
            "name":"DEV",
            "description":"dev",
            "allowedToProxy":true,
            "enabled":true,
            "ssoEnabled":true,
            "anonymousAccess":false,
            "allowedAttributes":["uid", "mail"]
            "extraAttributes":{
                "someCustomAttribute":"Custom attribute value"
            },
            "evaluationOrder":2
}

続いて、gradle のビルド定義ファイルを置き換えます。ちょっと面倒なのですが、以下の build.grade ファイルを取得して、手元のファイルを置き換えてしまいます。

その上で、L930 くらいにある dependencies のセクションに ServiceRegistry の JOSN ストレージモジュールを足します。${project.'cas.version'} の変数も、このまま書きます。

dependencies {
    /**
     * CAS dependencies and modules may be listed here.
     *
     * ...
     */
     ...

    /* add */
    implementation "org.apereo.cas:cas-server-support-json-service-registry:${project.'cas.version'}"
}

続いて、Dockerfile を編集します。2カ所はファイルコピー操作を追加、gradle のビルドコマンドは、./gradlew dependencies コマンドを追加しています。2ステップに分解していますが、まとめてもかまいません。

FROM centos:centos7

MAINTAINER Apereo Foundation

ENV PATH=$PATH:$JRE_HOME/bin
ARG cas_version

RUN yum -y install wget tar unzip git \
    && yum -y clean all

# Download Azul Java, verify the hash, and install \
RUN set -x; \
    java_version=11.0.3; \
    zulu_version=11.31.11-ca; \
    java_hash=20218b15ae5ef1318aed1a3d5dde3219; \
    cd / \
    && wget http://cdn.azul.com/zulu/bin/zulu$zulu_version-jdk$java_version-linux_x64.tar.gz \
    && echo "$java_hash  zulu$zulu_version-jdk$java_version-linux_x64.tar.gz" | md5sum -c - \
    && tar -zxvf zulu$zulu_version-jdk$java_version-linux_x64.tar.gz -C /opt \
    && rm zulu$zulu_version-jdk$java_version-linux_x64.tar.gz \
    && ln -s /opt/zulu$zulu_version-jdk$java_version-linux_x64/ /opt/java-home;

# Download the CAS overlay project \
RUN cd / \
    && git clone --depth 1 --single-branch -b $cas_version https://github.com/apereo/cas-overlay-template.git cas-overlay \
    && mkdir -p /etc/cas \
    && mkdir -p cas-overlay/bin;

COPY thekeystore /etc/cas/
COPY bin/*.* cas-overlay/
COPY etc/cas/config/*.* /cas-overlay/etc/cas/config/
COPY etc/cas/services/*.* /cas-overlay/etc/cas/services/
# 追記
COPY build.gradle /cas-overlay/build.gradle

RUN chmod 750 cas-overlay/gradlew \
    && chmod 750 cas-overlay/*.sh \
    && chmod 750 /opt/java-home/bin/java;

EXPOSE 8080 8443

WORKDIR /cas-overlay

ENV JAVA_HOME /opt/java-home
ENV PATH $PATH:$JAVA_HOME/bin:.

# 書き換え
RUN mkdir -p ~/.gradle  && echo "org.gradle.daemon=false" >> ~/.gradle/gradle.properties
RUN ./gradlew dependencies && ./gradlew clean build --parallel \
    && rm -rf /root/.gradle

# 追記
RUN mkdir -p /etc/cas/config && mkdir -p /etc/cas/services && cp etc/cas/config/* /etc/cas/config/ && cp etc/cas/services/* /etc/cas/services/

CMD ["/cas-overlay/run-cas.sh"]

あらためてビルドと実行を行います。

./build.sh 6.4
./run.sh 6.4

もし JSON のサービス定義ファイルをロードできていれば、「READY」の後に以下のログが出ます。 [org.apereo.cas.services.AbstractServicesManager] - <Loaded [1] service(s) from [JsonServiceRegistry].> となっていれば、JSON のアプリケーション定義をロードするのに成功しています。

2021-12-21 09:07:28,348 INFO [org.apereo.cas.web.CasWebApplication] - <Ready to process requests @ [2021-12-21T09:07:28.342Z]>
2021-12-21 09:07:28,780 INFO [org.apereo.cas.services.AbstractServicesManager] - <Loaded [1] service(s) from [JsonServiceRegistry].>

アプリケーション定義ファイルのURL正規表現と、アプリケーションのURLがマッチしていれば、SSO認証したときに無事に CAS のログイン画面が表示されるはずです。パスワード認証に成功すると、CASサーバはチケットを発行してアプリケーションにリダイレクトします。ログは以下が残ります。

2021-12-21 09:39:39,055 INFO [org.apereo.cas.DefaultCentralAuthenticationService] - <Granted service ticket [ST-11-Li75-erdbw7sPZ5vdnW5QG1IcAY-66a57e874b0d] for service [http://192.168.56.76/login.php?auth_mode=CAS] and principal [casuser]>
2021-12-21 09:39:39,055 INFO [org.apereo.inspektr.audit.support.Slf4jLoggingAuditTrailManager] - <Audit trail record BEGIN
=============================================================
WHO: casuser
WHAT: {service=http://192.168.56.76/login.php?auth_mode=CAS, return=ST-11-Li75-erdbw7sPZ5vdnW5QG1IcAY-66a57e874b0d}
ACTION: SERVICE_TICKET_CREATED
APPLICATION: CAS
WHEN: Tue Dec 21 09:39:39 UTC 2021
CLIENT IP ADDRESS: 172.17.0.1
SERVER IP ADDRESS: 172.17.0.2
=============================================================

アプリケーションサーバがリダイレクトURLより認証チケットを受け取ると、アプリケーションサーバからCASサーバにチケットのValidationの問い合わせを行います。Validation に成功すると、CASサーバには以下のログが残り、アプリケーションサーバではユーザ名など属性情報を受け取ります。

2021-12-21 09:39:39,159 INFO [org.apereo.inspektr.audit.support.Slf4jLoggingAuditTrailManager] - <Audit trail record BEGIN
=============================================================
WHO: casuser
WHAT: {ticket=ST-11-Li75-erdbw7sPZ5vdnW5QG1IcAY-66a57e874b0d, service=http://192.168.56.76/login.php?auth_mode=CAS}
ACTION: SERVICE_TICKET_VALIDATE_SUCCESS
APPLICATION: CAS
WHEN: Tue Dec 21 09:39:39 UTC 2021
CLIENT IP ADDRESS: 172.17.0.1
SERVER IP ADDRESS: 172.17.0.2
=============================================================

今後

Java のビルドツールはあまり使ったことがないので、gradle の設定ファイルの記入位置を見つけるのに思いのほか時間がかかりました。

ですが、これですぐに検証サーバが用意できます。CASサーバのスタブを書くまでの間、クライアントのライブラリ更新やリファクタリングなどで頼もしく動いてくれるのを期待します。

PDFの証明書生成

先週に ディジタルバッジの規格や今のサービスを調べた記事 を書きました。

ディジタルバッジではなくとも、一定の条件を満たした人にその証明を発行する機会はあると思います。ここでは、PDFの証明書を生成する WebClass の機能を紹介します。

使い方

WebClass では一部の学校で、施設利用の研修を終えたときの修了証のような修了証発行する機能を使っていただいています。修了証は PDF ファイルで出力します。次の画像のように、フォーマットが決まっていて、日付や終了した人の名前などが埋め込まれます。ネットワークを使うためのセキュリティ講習など、主に事務的な用途で使っていただいています。

この証明書は、PDF出力設定をした資料教材を開いたときに生成されるようになっています。

証明書のデザインは以下のようにHTMLで記述し、置き換える文字のところにマークを仕込んでいます。ですので、HTML+CSS でやりくりできる範囲でデザインやタイトル文字などは自由に変えることができます。画像も使えます。

  <div class="cert_title">{CERT_TITLE}</div>

  <div class="given_to">
    <div class="given_to_layout">
      <div class="given_to_title">
        <div class="name">{USER_NAME}</div>
        <div class="userid">{USER_ID_LABEL}:{USER_ID}</div>
      </div>
      <div class="honor_title">{HONORIFIC_TITLE}</div>
    </div>
  </div>

  <div class="subject">
    <div class="text">{SUBJECT}</div>
        <table class="detail">
          <tr>
            <td>コース名</td>
            <td>{COURSE_NAME}</td>
          </tr>
          <tr>
            <td>教材名</td>
            <td>{CONTENTS_NAME}</td>
          </tr>
        </table>
  </div>

  <div class="signature">
    <div class="date">{FIRST_EXEC_DATE.JPYMD}</div>
    <div class="given_by">{GIVEN_BY}</div>
  </div>

この証明書の発行条件は、ユニット教材の「順番に進める」設定と、テスト教材の合格点設定を組み合わせています。このユニット教材に合格点設定付きのテストの後で証明書発行の資料教材を並べると、テストに合格したときに証明書発行の資料にたどり着けるというものです。

PDFの生成

内部では、HTML+CSS のWebページソースを生成して、ヘッドレスブラウザに読み込ませてPDFに変換させています。

ヘッドレスブラウザとは、画面表示なしで動くブラウザです。コマンドラインツールから操作でき、その最中にはブラウザがどんな表示をしようとしているか表示することはできませんが、あたかも普通のブラウザで操作したかのように振る舞ってくれます。JS の処理なども対応しており、そうして表示シミュレーションした結果をスクリーンショットして保存したり、PDF に印刷させたりできます。

以前は wkhtmltopdf, PhantomJS などの専用ヘッドレスブラウザを使うことも多かったですが、いまでは Firefox も Chrome もヘッドレスモードをサポートしています。Chrome などをヘッドレスモードで使うことが増えており、wkhtmltopdf などは開発が下火になっているようです。wkhtmltopdf の歴史と現在の状況

Chrome のヘッドレスモードでの使い方はこちらに解説があります。

Linux サーバ上でヘッドレスブラウザを使ってPDFをレンダリングするため、フォントについてはサーバ上にインストールされているフォントに成約されます。

PDF 以外の証明書、認定の仕方

WebClass のユニットとテストの合格点の機能を使って、テストをPassしたら何か証明書を作るという処理を実現しています。その仕組はそのまま使って、証明書のデザインを調整できるようにしているのが PDF 証明書の発行機能です。

PDF ではなく、ディジタルバッジを発行する処理に置き換えることもできます。ですがディジタルバッジでは以下の課題があります。

  • 今実現していた実装は、OpenBadge のバージョンが古い
  • コース内で柔軟にバッジ発行するには、条件の設定の仕方が限られる

WebClass はできる限りテストの結果や掲示板の書き込みなども CSV でダウンロードして、先生が手元で自由に処理できるようにしています。そうして授業の内容や目的に応じて学生の活動をできる限り多角的に評価できるようにと考えていますが、集計も、結果を学生にどうフィードバックするか手作業になります。

このあたりをうまく仕組み化できたら面白いかもしれないと考えています。

デジタルバッジのサービスの様子

Open Badges の今の様子の調査まとめになります。

Open Badges については WikiPedia にページがありますが、英語/日本語でほぼ同じようです。翻訳になっている感じでしょうか。

規格について

本家はこちらです

Open Badges に関して IMS での動きや仕様書はこちらにまとまっています。

Open Badges の規格は 2.0 までリリースされており、2.1 が Candidate Final のステータスで評価検証されているようです。OpenBadge v2.1 ではOAuth について記載があり、やり取りのセキュリティについても整備されていくのだと思います。

Open Badges に対応している製品は以下で検索できます。

https://site.imsglobal.org/certifications?refinementList%5Bstandards_lvlx%5D%5B0%5D=Open%20Badges

サービス:Badgr

もともと Mozilla が Backupack でデジタルバッジ管理サービスを運営していました。これが本家だったのですが、Mozilla は辞めてしまい、Badgr が引き継いでいます。と言っても、アカウント情報などが引き継がれたわけではなく、新しいサービスとして稼働しています。

4つのリージョンでサーバが異なっています。どこか選んでアカウントを作る、もしくはそれぞれにアカウントを作ることができます。

ログインすると、ここで自分の獲得したバッジを集めて管理することができます。なお、アカウントのメニューには言語の切り替えがなかったので、英語だけかもしれません。

アカウントは無料で登録できますが、Badgr をホストする方法も紹介されています。サーバのプログラムが GitHub で公開されているようです。

Blockcerts (ブロックチェーン証明書) を使ったサービス

もともとの Open Badges は、基本的なバッジのデータ構造と受け渡しのプロトコルが主です。発行元(Issuer)から比較的気軽に発行ができます。バッジを集めるホストでは、Issuer の確認などをしますが、仕組みとしてそこまでかっちりと改ざんチェックをするものではありません。なので、学習のなかの細かな達成事項をどんどんバッジにしてあげて、学習が進んでいく感じを学習者に感じてもらうのには良いのですが、例えば大学や国家資格の修了証書には心もとないです。

改ざんが難しく信頼性の高い証明に使う技術として Blockchain が注目されており、それを使った証明書の技術が Blockcerts です。

日本の企業で NetLearning 社もブロックチェーン型オープンバッジの仕組みを実現しているようです。「オープンバッジとは」のセクションの *2 に Blockcerts を利用していると書かれています。

https://www.netlearning.co.jp/openbadge/index.html

デジタル・ナレッジ社でも、オープンバッジのソリューションとして OpenBadgeとBlockcerts が用意されているようです。

Open Badge 準拠なわけではないかもしれませんが、同じく Blockcerts を使ったブロックチェーン証明書発行システムのサービスが日本にもあるようで、それを運営している会社のブログ記事に解説がありました。

日本語の Wikipedia ページにもリンクがあった一般財団法人 オープンバッジ・ネットワークでも、「オープンバッジとは」のところを見るとブロックチェーン技術を取り入れているとあり、Blockcertsとは書いていませんがおそらく同様のことをしているのではないかと思います。

日本の大学でのオープンバッジ利用

中央大学でオープンバッジの実装実験を行っているそうです。

https://www.chuo-u.ac.jp/aboutus/communication/press/2021/08/55724/

具体的なことはわからないのですが、特定の科目で修了証を発行するそうです。

WebClass での状況

OpenBadge v2.1 は Final Candidate の段階ですが、OAuth について記載がありました。やり取りのセキュリティについても整備されていくのだと思います。それはそれとして、証明書自体が本当に信頼できるものかどうかより確かにするための Blockcerts もあることが分かりました。

WebClass ではこれまでに OpenBadge の初期のAPIを使ってコースや教材のバッジ発行を試していました。コースの中で達成したことを、先生が用意する教材に応じてバッジが発行できるのも面白そうです。

Apache mpm_prefork と PHP-FPM の効率の違い

こんにちは。福岡です。

Apache mpm_prefork + mod_php と mpm_event + PHP-FPM を使ったときの、同時アクセス数が増えたときの効率を調査しましたので紹介します。

WebClass はWebサーバにApacheと、スクリプトに PHP を用いています。同時多数のアクセスを扱うためのチューニングは常に問題です。同時多数のアクセスに立ち向かうため、Apache は今では mpm_event をデフォルトとしていますし、PHP も PHP-FPM を使うことで mpm_event と組み合わせることができます。PHP-FPM も正式機能になって10年経つので、すでに利用されている環境もかなりあると思います。

ネットの記事などを読んで MPM_EventとPHP-FPM が仕組みとして mpm_prefork より効率が良さそうだというのはわかっていたのですが、定量的にどこがどれくらい良いのか、いまいちしっくりくる説明を見たことがありませんでした。なので、今更かもしれませんが、測ってみました。

ここでは具体的に、以下について調査しました。

  • 同じ同時アクセスを受けた時、mpm_prefork とくらべて、PHP-FPM はどれくらい効率的に、もしくは安定してリクエストを処理できるか
  • プロセスの立ち上げ速度に差はあるか

結果は以下でした。

  • 用意したPHPのプロセス数よりも多くの同時アクセスを受けている時には PHP-FPM のほうがレスポンスタイムを1/2倍にするくらい効率が良いことが分かりました。
  • プロセスを立ち上げる速度は Kernel や CPU 等の影響が大きく差がありませんでしたが、一気に同時アクセスを受けてからプロセスを立ち上げ始める初動では PHP-FPM のほうが良い動きでした。

ベンチマークに使う PHP プログラム

ベンチマークコマンドは以下のようにして単一URLに ab コマンドで同時アクセスをかけ、レスポンス時間を確認しました。

ab -c 300 -n 1000 http://10.0.0.104/webclass/wait1.php

アクセス先PHPプログラム wait1.php は、1±0.25 [s] だけ待って 'OK' の文字を返します。想定として、プログラムファイルはキャッシュメモリ上に乗っていてIO負荷が殆どかからない、PHPのスクリプトとしても処理はDBサーバからの結果を待つだけでほとんどやることがない状況を想定しています。

<?php
function msleep($milliseconds) {
    return usleep($milliseconds * 1000); 
}
$interval = 1000;
$var = 500; 
msleep(1000 + (rand(0, $var)-$var/2));

echo 'OK';

したがって、スクリプトの処理時間はCPUの負荷状況に影響を受けにくく、CPUが空いていても1秒より早く応答は返さないし、CPUに多少負荷がかかっていてもスクリプトの処理自体はあまり時間が延びません。

このプログラムに対するアクセスのレスポンス時間を計測し、どれだけ1sから離れるかを比較することで、Apache と PHP-FPM の効率だけを比較・評価できると考えました。

計測環境

AWS EC2 インスタンス(m4.large) でDebian 10 + WebClass 11.11.3 を構築しました。

  • CPU Intel® Xeon CPU E5-2686 v4 @ 2.30GHz x2
  • Mem 8G + Swap File 3G
  • Debian 10.9
    • Apache 2.4.38 (Debian)
    • PHP 7.3.29-1~deb10u1

同一VPC内に m4.large のDebian10インスタンスを構築し、http プロトコルで ab コマンドによるベンチマークを計測しました。

  • CPU Intel® Xeon CPU E5-2686 v4 @ 2.30GHz x2
  • Mem 8G
  • Debian 10.9
  • Ab 2.3 Rev.1843412

計測中にはどちらのサーバでも Swap と Hardware Interrupt(Steal)が発生しないのを確認してあります。

計測環境チェック

wait1.php はほぼ待つだけなので、同時アクセスが多数発生してもCPUはあまり消費せず、ApacheとPHPの同時アクセス・複数プロセスのマネジメントの効率が良いほど応答時間は1sに近づくはず、という前提を確認します。

この前提条件の確認のため、mpm_prefork とphp7.3-fpm のそれぞれについて、300 プロセス用意した状態で100同時アクセスをかけて応答時間が1s程度になることをチェックしておきました。

ab -c 100 -n  10000 http://10.0.0.104/webclass/wait1.php

MPM_Prefork + Mod_php 7.3 (MaxRequestWorkers=300 ) の結果

Mpm_prefork のケースでは、Apache のMPM設定は以下のようにしました。

StartServers   300
MinSpareServers 300
MaxSpareServers 300
MaxRequestWorkers 300
ServerLimit    300

abコマンドの結果は以下です。

Concurrency Level:      100
Time taken for tests:   101.782 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      2260000 bytes
HTML transferred:       20000 bytes
Requests per second:    98.25 [#/sec] (mean)
Time per request:       1017.816 [ms] (mean)
Time per request:       10.178 [ms] (mean, across all concurrent requests)
Transfer rate:          21.68 [Kbytes/sec] received

応答時間が1sで、CPU負荷もほとんどかからなかったため、問題なさそうです。

MPM_event + php7.3-fpm (max_children=300 ) の結果

Mpm_event のケースでは、Apache のMPM設定は以下のようにしました。スレッドを100ずつ増やしていき、上限も2000にしてあるので、プロセスの起動とスレッド上限があまり影響しないようにしています。

StartServers      2
MinSpareServers 100
MaxSpareServers 100
ThreadLimit        300
ThreadsPerChild  100
MaxRequestWorkers 2000

PHP-FPM のプロセスモードは以下の設定です。

pm = dynamic
pm.max_children = 300
pm.start_servers = 50
pm.min_spare_servers = 20
pm.max_spare_serves = 50

なお、この設定は Prefork に揃えるには static にすればよかったと後で気づきました。

abコマンドの結果は以下です。

Concurrency Level:      100
Time taken for tests:   103.935 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      2300000 bytes
HTML transferred:       20000 bytes
Requests per second:    96.21 [#/sec] (mean)
Time per request:       1039.351 [ms] (mean)
Time per request:       10.394 [ms] (mean, across all concurrent requests)
Transfer rate:          21.61 [Kbytes/sec] received

dynamic/static の点は気になりますが、一応は応答時間が1sで、CPU負荷もほとんどかからなかったため、問題なさそうです。

レスポンス時間の計測結果

Prefork と PHP-FPM のワーカ数上限と、同時アクセス数を変えながら ab コマンドでレスポンス時間をプロットしたのが以下です。

グラフの凡例の名前「Prefork」もしくは「PHP-FPM」の横に書いた数字が、それぞれに設定したワーカーの上限です。

wait1.php は1±0.25s 待つだけなので、同時アクセスが多数発生しても CPU に掛かる負荷はわずかでした。ですので、MPM と PHP-FPM の効率がレスポンスタイム1sよりどれくらい上に離れてくか、に反映されると考えられます。1sでフラットなグラフになるほど、同時アクセスが増えても効率が落ちないことを意味します。

MPM_Prefork は、用意したプロセス未満の同時アクセスに対しては非常に効率が良いです。しかし、プロセス数を超える同時アクセスに対しては、著しく効率が悪化し、ab はエラーを計上することもありました。MaxRequestWorkers 900 で同時アクセス1500のケースでもCPUの利用時間は大して増えませんが、Load Average は 9.3 ほどになりました。エラーを計上したところより上の同時アクセス数はデータを取っていません。

PHP-FPM はプロセス数未満の同時アクセスに対して効率が良いのはMPM_Preforkと変わりません。一方、同時アクセス数がプロセス数よりも増えた時は、MPM_Prefork よりもレスポンス時間を1/2 程度に保てました。Load Averageは1いくか行かないか程度で、CPU使用率は低いままです。

プロセス数が足りない方に寄った計測をしているため、どちらの方式でもプロセス数を増やすことによる効率の悪化の様子は観察できませんでした。ただし、実際の運用ではプロセス数が増えるとスクリプトによるCPU利用量も増えるため、プロセス管理の効率の問題以前にCPUがボトルネックになることも考えられます。

プロセスの立ち上がり速度の計測

MPM_Prefork も PHP-FPM もワーカとしてプロセスを立ち上げます。一般にプロセスのフォークは重い処理になります。また、待機しているワーカ数よりも多くの同時アクセスがワッと来た時の反応にも影響します。WebClassですと、10時半から2限では試験をします!というとき、授業の開始時刻で一斉に学生が操作し始めるので侮れないパラメータになります。

2つの環境のそれぞれに、ワーカの最大値を500に、初期待機ワーカ数を50にした状態で待機させ、ab コマンドで同時500アクセスをかけました。そこからプロセス数を1秒単位で数えていって、500に達したところまでをグラフにしています。

グラフの凡例の名前「PHP-FPM」の横に書いた数字は min_spaire_servers の値です。最初は10にして計測したところ著しくプロセス起動が遅かったので、50の結果を加えてPreforkと比べられるようにしています。なお、これ以上値を増やしても、もしくは同時アクセス数を増やしても、速度は変わりませんでした。

プロセスの立ち上げ速度(グラフの傾き)は MPM_PreforkとPHP-FPM50で差がありません。これは Kernel や CPU などのもっと下のレイヤーの性能によるものと思います。ですが、同時アクセスが急にきてからプロセスを増やし始める反応はPHP−FPMのほうが良く、500プロセス揃うまでの時間に3sの差が出ました。その結果、平均応答時間は初動の速いPHP-FPM のほうが短くなりました。

CPUへの負荷を見ると、MPM_Prefork はプロセス上限に至るまでは CPU も Load Averageも低いままですが、PHP-FPM のほうはCPU使用率と Load Average が Prefork よりも高くなりました。もしPHPにそれなりの処理が有る時、PHP-FPMのプロセスの立ち上げにどれくらい影響するかちょっと気になります。

まとめ

まずは基本的なところの違いを知りたいと思って、できるだけプロセス管理効率に的を絞った計測をしてみました。

MPM_Event については、PHP-FPMの計測でほぼ無視できるほどサクサクと1500同時リクエストなどを抱えてくれていて、数字では示せませんでしたが流石だなと思いました。そのおかげで、PHP-FPM はワーカ数を絞っていても効率的に処理できるようになっていると思います。PHPのプロセス数を絞ることができれば、接続先のDBのワーカ数も減らすことができます。コア数とメモリが限られる環境での安定性に繋がります。また、この安定性を保ったまま、CPU とメモリを増やした分だけワーカ数を増やしていければ、1台のプロセッサでカバーできる同時アクセス数も増やしやすいかもしれません。このあたり、今後詰めていきたいところです。