PG-Stromなう

最近、方々で『GPUイイよ!GPU!』と言って回っている訳ですが、今現在、PG-Stromの開発がどんなもんじゃいというのをまとめておこうと思います。

振り返ってみると、2012年1月、最初にPG-Stromのプロトタイプを作ってみた時は、まさにPG-Strom管理下の外部テーブル(Foreign Scan)に対して非常に複雑な条件句を与えた場合、という非常に限られた条件下でGPUオフロードが効果を発揮するものでした。しかも、プログラミングに使ったのはCUDAなので、NVIDIA専用だったし。
f:id:kaigai:20141111222738p:plain

その後、紆余曲折を経て

  • FDW(Foreign Data Wrapper)を使うのはやめ、新たに Custom-Plan APIを設計した。
    • 外部テーブルでない、普通のテーブルに対してもGPUアクセラレーションを適用可能に
    • 全件探索以外のワークロードへも対応が可能に
  • CUDAを使うのはやめ、OpenCLライブラリを使用するようにした。
    • NVIDIA以外のGPUへの対応や、(効率はベストでないものの)CPU並列も可能に
  • 列指向データ構造を捨て、PostgreSQLに合った行指向データを扱えるように
  • 全件スキャンだけでなく、表結合(Join)と集約演算(Aggregate)に対応した

アーキテクチャ的にはこんな感じ。
f:id:kaigai:20141111222746p:plain
Custom-Plan APIを介して、全件スキャン(GpuScan)、表結合(GpuHashJoin)、集約演算(GpuPreAgg)を実装するCustom-Nodeがプランナ・エグゼキュータから呼び出される。その裏でOpenCLバイスを管理するバックグランドワーカが立ち上がり、これらのCustom-Nodeから自動生成されたGPUコードと処理すべきデータの組がメッセージキューを介して送信されるので、OpenCL Serverはこれを適宜ディスパッチしてGPUで処理する事ができる。

ま、以前の実装だとかなり適用可能なワークロードが限られていて、こんな感じの手厳しいコメントを頂くこともあったが、特にJoinをGPUで高速化できるってのは結構インパクトがあると思う。

以下の図はCPUによるHash-Joinのロジックと、PG-StromのGpuHashJoinを比較したもの。
CPUであれば、①Hash表を作り ②Outer側から一行取り出してHash表を探索 ③Projectionを行って左右の表の内容をマージした行を作り、次の処理へ回す。この②と③のプロセスをひたすらループする事になるワケだ。
一方、GpuHashJoinの場合、①Hash表を作る 部分は同じだけども、②と③の部分をGPUのコアに並列実行させる。つまり、数百~数千コア分の並列度が期待できる訳である。
f:id:kaigai:20141111222816p:plain

以下のグラフは、2億件 × 10万件 × 10万件 × .... と、結合すべき表の数を増やしていった時の処理時間を、標準PostgreSQL(9.5devel)と、PG-Strom有効化の場合で比較したもの。
一番得意なワークロードで比較しているので、多少、恣意的な部分はあるにせよ、3個のテーブルを結合する時の処理時間で11倍、9個のテーブルを結合する時の処理時間で28倍もの違いが出ている。
ま、この辺は実際に世の中で使われているワークロードと、GPUにオフロードできる部分をどういう風に近づけるかで、コストパフォーマンスが決まってくるんだろうけども。
f:id:kaigai:20141111222822p:plain

こんな感じでプロファイルを取ってみると、どの処理にどれくらい時間を要しているかが判る。
(この例では 2000万件 × 4万件 × 4万件 を結合してみた)

postgres=# SET pg_strom.perfmon = on;
SET
postgres=# EXPLAIN (ANALYZE, COSTS OFF)
           SELECT * FROM t0 NATURAL JOIN t1 NATURAL JOIN t2;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Custom (GpuHashJoin) (actual time=105.020..4153.333 rows=20000000 loops=1)
   hash clause 1: (t0.aid = t1.aid)
   hash clause 2: (t0.bid = t2.bid)
   Bulkload: On
   number of requests: 145
   total time for inner load: 29.73ms
   total time for outer load: 825.77ms
   total time to materialize: 1359.44ms
   average time in send-mq: 62us
   average time in recv-mq: 1372us
   max time to build kernel: 13us
   DMA send: 5759.42MB/sec, len: 4459.39MB, time: 774.28ms, count: 722
   DMA recv: 5661.55MB/sec, len: 2182.02MB, time: 385.41ms, count: 290
   proj kernel exec: total: 197.28ms, avg: 1360us, count: 145
   main kernel exec: total: 250.03ms, avg: 1724us, count: 145
   ->  Custom (GpuScan) on t0 (actual time=6.226..825.598 rows=20000000 loops=1)
         number of requests: 145
         total time to load: 787.28ms
   ->  Custom (MultiHash) (actual time=29.717..29.718 rows=80000 loops=1)
         hash keys: aid
         Buckets: 46000  Batches: 1  Memory Usage: 99.99%
         ->  Seq Scan on t1 (actual time=0.007..5.204 rows=40000 loops=1)
         ->  Custom (MultiHash) (actual time=16.106..16.106 rows=40000 loops=1)
               hash keys: bid
               Buckets: 46000  Batches: 1  Memory Usage: 49.99%
               ->  Seq Scan on t2 (actual time=0.018..6.000 rows=40000 loops=1)
 Execution time: 5017.388 ms
(27 rows)

GPUでの処理時間は、Joinそれ自身(main kernel exec)と結果の生成(proj kernel exec)で計450msくらい。
つまり計算能力のポテンシャルはこれ位はあるので、あとは、いかに効率的にCPU/GPUでデータを受渡しするかという所がこれからのテーマになってくるのかな。

次のエントリで、PG-Stromのデプロイについて紹介します。

さらばドイツ

2011年2月にドイツへ赴任し、以降2年10ヵ月間、SAP社とのアライアンス業務に携わっていたわけですが、11月末で任地での業務を終了し日本へと戻る事になりました。

振り返れば、初めてフランクフルトの空港に到着した後、一歩外へ出たら氷点下の世界(注:ドイツの2月です)で『こりゃトンデモない場所に来たもんだ』と思ったものですが、まぁ、住めば都で何とかなるもんですな。

オフィスはドイツ南西のWalldorf市(SAP本社がある)。住むには少し規模が小さな街なので、隣のHeidelberg市の旧市街のマンションを借り、1月遅れで嫁さんも日本からやってきました。
f:id:kaigai:20120331145434j:plain

お仕事的には、2011年前後というのはちょうどSAP社がSUSE LinuxベースのインメモリDB製品 "SAP HANA" をぶち上げ始めた頃で、主に技術面でSAPやSUSEとの折衝や、HANA認定取得の実作業というのが駐在中のメインのお仕事でした。

NEC SAP HANA向けアプライアンスサーバを販売開始
http://www.nec.co.jp/press/ja/1203/0901.html

その他にも、NECのHA製品CLUSTERPROで、SAP NW~HA製品間で連携するためのインターフェース認証を取得したりとか。

NEC、「SAP HA Interface Certification」認定を取得した「CLUSTERPRO X 3.1 for SAP NetWeaver」を発売
http://jpn.nec.com/press/201210/20121002_01.html

この手の "○○認証" という仕事が多かったのですが、SAPの認定プログラムはちょくちょくおかしくて、例えば SAP Linux 認証プログラムは80コア/160HTのNECサーバの認定を通すために、SAPの認定プログラムの方を手直しして認定を通したりとか。

一方、OSS活動は工数の20%~30%を充当して良いという事を条件に赴任したので、まだ中途であるSE-PostgreSQLの開発も継続する事ができた。これは会社に感謝。ただ、OSS活動に専従という訳ではないので、MLでの反応が遅れたりと、コミュニティに対して少し申し訳ない。
ドイツに居る間に新たに開発してみたのが PG-Strom というモジュールで、クリスマス休暇の間にCUDAを使ってプロトタイプの実装を行ってみた。これがもう一つ、自分にとって軸となるソフトウェアになるかもしれない。
http://kaigai.hatenablog.com/entry/20120106/1325852100

f:id:kaigai:20131201153401j:plain
これをプラハでのPGconf.EUで発表した際に、非常に大きな反響があった事は"コレは行けるんじゃないか?"という確信に繋がった。これはNECに限った事なのか分からないけども、自分の組織の外に対して何かを発表し、フィードバックを得るという機会が極端に少ない、あるいはナイーブになっているような気がする。
そうすると、自分の提案したい事が第三者の視点から見て価値ある事なのか、そうでないのか確信できないので、上司にちょっとダメ出しされただけで萎縮してしまうという事に繋がらないか。
先日、晴れて四捨五入したらオッサン年代に突入したので偉そうな事を書くと、若い人は公式非公式を問わず、自分のアイデアを社外で発表する機会を持った方がよい。そうすると、上司のダメ出しが的を得ているのか、的外れなのか、自分の判断基準を持てるようになるから。

この辺SAPが偉いなと思うのが、SCN(SAP Community Network)という仕組みを用意し、Wikiやブログを通して開発者やパートナー企業とエンドユーザが直接意見交換できる仕組みが機能している事。例えば製品の変な使い方やプロトタイプ的に作ってみた機能(当然公式にはサポート外)をオープンにして、それに対するフィードバックを受けられるようにしている。
もちろん、これはSAPのように非常に強力なパートナー網、顧客網というエコシステムを持っているからこそできる事。同じ事ができる企業は非常に限られている。
ただ、エコシステムの主催者でなくともコントリビュータとして社外の意見フィードバックを活用するというのは可能。まさにOSSコミュニティが日常的に行っている事で、だからこそイノベーションがそこにある。

仕事環境で言うとドイツ人が偉いのは、きっかり時間通りに仕事を切り上げる事。
2011~2013シーズンはBaden-Hillsカーリングクラブでプレーしていたのだけども、毎週火曜日19:30~のリーグ戦に皆きっちり集まるのがすごい。日本のカルチャーでは難しい所もあるけど、ぜひ見習いたい。目前の業務にばかり時間を費やして、別の領域にアンテナを張る時間や余暇の時間がなければ新しい発想なんて生まれないし、それが中長期的には組織を徐々に蝕んでいく事になるんじゃないかと思った。
f:id:kaigai:20111011183217j:plain

その他、生活面では嫁さんが考えて日本食を作ってくれたので、病気をする事もなくきっちり職務を遂行する事ができた。感謝。結婚してからの3年間、風邪をひく事すら無くなった。
ヨーロッパに住んでいる事で、なかなか日本からは行きにくい場所を旅行できた事は役得だけど、書き出すとキリがないのでないのでこれはまた別の機会に。

f:id:kaigai:20120331154344j:plain

日本に帰った後は、SAPから離れて再びOSSの仕事に戻ります。
自分で手を動かしてコードを書くと同時に、それをお金に換えていく方法をデザインする事が求められているので、タフな仕事だけども、一つのロールモデルとして後進に示せれば良いかなと。

それでは明日、ドイツを発ちます。

Custom Executor 試作版

現状、PostgreSQLのエグゼキュータを拡張するにはいくつか方法が考えられる。

  1. Executor(Start|Run|End)_hookを使う
    • エグゼキュータ全体を乗っ取る。逆に言えば、一部の処理(例えば集約演算)だけを俺様実装にしたい時には、本体側のコードをコピーするなりしないとダメ。
  2. Foreign Data Wrapperを使う
    • 特定のテーブルスキャンに限定すればアリ。しかし、集約演算やJOIN、ソートといった処理を俺様実装にする用途には使用できない。
  3. ブランチして俺様パッチを当てる
    • まぁ、これならどうにでもできますがね…。

しかし、あまり使い勝手がよろしくない。
例えば、GPUを使って集約演算を1000倍早くしたいと考えても、それを実装するために、モジュール側でエグゼキュータ全体のお守りをしなければならないというのは、全く嬉しくない。
という訳で、プラン木の一部を差し替えて、特定のエグゼキュータ・ノードだけをモジュール側で実装したコードで実行できるよう機能拡張にトライしてみた。名称は Custom Executor Node で、名付け主はRobert Haasなり。

イメージとしては以下の通り。
f:id:kaigai:20130831231525p:plain

planner_hookを利用して、PostgreSQLのプラナーがPlannedStmtを作成した"後"でモジュールがプラン木の一部を書き換え、CustomExecノードを追加する、あるいはCustomExecノードで既存のエグゼキュータ・ノードを差し替える。

で、クエリ実行の開始、一行取り出し、クエリ実行の終了時にそれぞれモジュール側で実装されたコードが呼び出されるというワケだ。

APIは以下の通り。機能的に似ているのでFDWのAPIに似ておる。

void BeginCustomExec(CustomExecState *cestate, int eflags);
TupleTableSlot *GetNextCustomExec(CustomExecState *node);
void ReScanCustomExec(CustomExecState *node);
void EndCustomExec(CustomExecState *node);
void ExplainCustomExec(CustomExecState *node, ExplainState *es);

PoCとして、クエリ実行に要した時間を出力する xtime という拡張を自作してみた。
Custom Executorとして本体からコールバックを受けるには、予め自分自身を登録しておく必要がある。これがRegisterCustomExec()関数で、ここでは一連のコールバックの組に対して"xtime"という名前を付けている。

void
_PG_init(void)
{
    CustomExecRoutine   routine;
        :
    /*
     * Registration of custom executor provider
     */
    strcpy(routine.CustomExecName, "xtime");
    routine.BeginCustomExec   = xtime_begin;
    routine.GetNextCustomExec = xtime_getnext;
    routine.ReScanCustomExec  = xtime_rescan;
    routine.EndCustomExec     = xtime_end;
    routine.ExplainCustomExec = xtime_explain;
    RegisterCustomExec(&routine);
        :
}

そして、プラン木に"xtime"のCustomExecノードを挿入するのがplanner_hookでの処理。標準のプラナーが生成したPlannedStmtに手を加えてCustomExecノードを追加する。(ここでは再帰的にプラン木を捜査して、各ノードの頭にCustomExecノードを追加している)

static PlannedStmt *
xtime_planner(Query *parse,
              int cursorOptions,
              ParamListInfo boundParams)
{
    PlannedStmt *result;

    if (original_planner_hook)
        result = original_planner_hook(parse, cursorOptions,
                                       boundParams);
    else
        result = standard_planner(parse, cursorOptions,
                                  boundParams);

    /* walk on underlying plan tree to inject custom-exec node */
    result->planTree = xtime_subplan_walker(result->planTree, 0);

    return result;
}

これによって、クエリ実行計画はどのように修正されるのか。
例えば以下のようなクエリを実行するとする。

postgres=# EXPLAIN (costs off)
           SELECT * FROM t1 JOIN t2 ON t1.a = t2.x
                    WHERE a < 5000 LIMIT 100;
                    QUERY PLAN
--------------------------------------------------
 Limit
   ->  Hash Join
         Hash Cond: (t2.x = t1.a)
         ->  Seq Scan on t2
         ->  Hash
               ->  Index Scan using t1_pkey on t1
                     Index Cond: (a < 5000)
(7 rows)

これが、xtimeモジュールにより以下のように書き換えられる。

postgres=# LOAD '$libdir/xtime';
LOAD
postgres=# EXPLAIN (costs off)
           SELECT * FROM t1 JOIN t2 ON t1.a = t2.x
                    WHERE a < 5000 LIMIT 100;
                             QUERY PLAN
--------------------------------------------------------------------
 CustomExec:xtime
   ->  Limit
         ->  CustomExec:xtime
               ->  Hash Join
                     Hash Cond: (x = t1.a)
                     ->  CustomExec:xtime on t2
                     ->  Hash
                           ->  CustomExec:xtime
                                 ->  Index Scan using t1_pkey on t1
                                       Index Cond: (a < 5000)
(10 rows)

数ヶ所にCustomExecノードが挿入されている。t2へのSeqScanが消えているのは、SeqScanの場合にはxtimeモジュールが自力でテーブルをスキャンするため(コードサンプルとして使う事を想定しているので)。

で、このクエリを実行すると以下のように表示される。CustomExecノードをプラン木の間に挟み込み、下位ノードの実行に要した時間を記録しているのである。

postgres=# \timing
Timing is on.
postgres=# SELECT * FROM t1 JOIN t2 ON t1.a = t2.x WHERE a < 5000 LIMIT 100;
INFO:  execution time of Limit:  9.517 ms
INFO:   execution time of Hash Join:  9.487 ms
INFO:    execution time of CustomExec:xtime on t2:  0.047 ms
INFO:     execution time of Index Scan on t1:  4.947 ms
        :
     (省略)
        :
Time: 12.935 ms

ひとまず、CommitFest:Sepまでにちゃんと提案できるブツを作れてよかった。
あとはドキュメントとソースコードコメントをちゃんと書いて投稿である。

単体型GPUへのDMA転送コストを考える

PostgreSQLの検索処理(特にスキャン周り)をGPUにオフロードするにあたって、できるだけCPU処理の負荷を軽減したい。というのも、PostgreSQLは基本シングルスレッドなので、GPUがバリバリ並列計算しようとしてもデバイスへのデータ供給が追い付かなければ計算機を遊ばせてしまう事になる。

大雑把に言うと、PG-Stromは以下のような構造を持ち、PostgreSQLバックエンドがデータベースファイルから読み込んだデータを、共有メモリを介してOpenCL Serverが見える所に置いてあげる。OpenCL ServerはデータをDMA転送してGPU Kernel関数を起動、計算結果をまたPostgreSQLバックエンドに戻してやる。
f:id:kaigai:20130825174718p:plain

もう少し細かく仕事を分割すると、以下の通り。
f:id:kaigai:20130825174734p:plain

  1. ストレージからデータをロード(共有バッファに載ってなければ)
  2. 共有バッファからタプルを取り出す。この時、Snapshotとの比較でタプルの可視不可視チェックも。
  3. タプルを展開し、長大な配列としてデータを並べ替える。元々カラム指向なデータ構造で持っているので、配列への展開は"すごく重い"という訳ではない。
  4. DMA転送でこの配列をGPUデバイスへコピー。
  5. 転送されたデータを使って、GPU側で計算を実行。

現状の実装だと、(1)~(3)までがPostgreSQLバックエンドの仕事、(4)がOpenCL Serverで、(5)がGPUデバイスとなる。

ただ、これだとCPU側での処理がかさんでしまうので、理想としては以下のようにPostgreSQLの共有バッファから直接GPUデバイスにDMA転送、そこでタプルからのデータ取り出しを行えれば、CPU負荷は下がる事になる。
f:id:kaigai:20130825181145p:plain

しかし、問題が2つ。

  1. タプルの可視性判定はIFブロックの鬼なのでGPUで実行したくない。その上、不可視なタプルをGPUに転送するコストは完全に無駄
  2. PostgreSQLの共有バッファのブロック長は8KB(設定により最大32KB)なので、細切れのDMAを大量に発行する必要が出てくる。

後者に関しては、簡単に性能を計測できるのでやってみた。

128MBのブロックを1回のDMAで転送した場合。理論上、これが最も性能の出るパターン。

[kaigai@iwashi gpuinfo]$ ./gpudma -m async
DMA send/recv test result
device:         GeForce GT 640
size:           128MB
chunks:         128MB x 1
ntrials:        100
total_size:     12800MB
time:           4.18s
speed:          3063.97MB/s
mode:           async

一方、128MBのブロックを8KBx16384個のチャンクに分けて転送した場合だと、転送性能は1/3程度に。うーん、これはちょっとヨロシクナイ…。

[kaigai@iwashi gpuinfo]$ ./gpudma -m async -c 8
DMA send/recv test result
device:         GeForce GT 640
size:           128MB
chunks:         8KB x 16384
ntrials:        100
total_size:     12800MB
time:           12.36s
speed:          1035.97MB/s
mode:           async

当然、ブロック長が長くなればなるほどDMA発行の手間が減るので性能的には有利。PostgreSQLのコンフィグで変更可能な32KB相当のDMA単位にすると、本来の性能の85%程度は出てくれる。それでも結構痛いが。

[kaigai@iwashi gpuinfo]$ ./gpudma -m async -c 32
DMA send/recv test result
device:         GeForce GT 640
size:           128MB
chunks:         32KB x 4096
ntrials:        100
total_size:     12800MB
time:           5.06s
speed:          2531.70MB/s
mode:           async

なかなか悩ましいところではあるが、8KBのチャンクを直接GPUにDMA転送するコストの高さを考えれば、CPU側である程度まとまった大きさに集約してからガツンとDMAを発行した方が効率はよさそう。その一方で、シングルスレッドのPostgreSQLバックエンドを酷使するのも考え物なので、ベストミックスとしては、マルチスレッドで動作可能なOpenCL Serverにタプルの取り出しとDMAバッファへの展開を行わせても良さそう。
あと、現状ではDMAバッファへの展開時に圧縮データの展開を行っているけども、CPUの処理負荷を下げるだけでなく、PCI-Eの帯域を節約するという観点からも、GPU側で圧縮データの伸長を行った方が良いのかもしれない。

OpenCLでCPU/GPUを使い分ける?

最近、PG-Stromに興味があるという方からちょくちょく、個別に質問メールを頂く事がある。

その中で頂いたコメントに興味深い洞察が。

GPUによるアクセラレーションは確かに興味深い機能だけれども、PG-Stromの本質は突き詰めていえばパイプライン処理のお化けだよね?だから、計算処理をCPUでやるようにしても良いんじゃない?

確かに。GPUによる並列処理はハマると物凄い費用対効果をもらたすけれども、例えば正規表現マッチみたく、GPU向きじゃない処理もある。

PG-Stromの場合、SQLのWHERE句に与えられた条件から行を評価する関数を自動的に生成、それをJITコンパイルして実行する。たぶん、プランナの時点でCPU実行用、GPU実行用の2種類の関数を自動的に生成して、計算サーバに渡すという処理は実現可能だろう。

NVidiaGPUを前提とするCUDAと異なり、OpenCLはAMDGPUIntelXeon Phiもサポートする。それどころか、OpenCL Cで書かれたコードをCPU用にコンパイルする事も可能。

その辺の事情もあり、今、PG-StromをOpenCLで再実装し直している。(works in progress)

だが、CPUとGPU用にそれぞれ計算サーバ書くのか、実装が複雑になってやだなぁ~と思っていたところ、実は勘違いである事が判明。

以下のgpuinfoコマンドの出力は、NVidiaのCUDA 4.2と、IntelのOpenCL SDKインストールした環境でのもの。

なんと、Platform-1でGPUが、Platform-2でCPUが認識されている。

NVidiaのOpenCLライブラリでも、IntelのOpenCLライブラリでも同様。

という事はですよ、要求された計算の特性に応じて、1個の計算サーバでCPU/GPUを使い分けるという芸当もできるという事になるじゃありませんか。

いやー、面白い。実装意欲を掻き立てられる。

なお、以下のコマンド gpuinfo のURLは↓です。

https://github.com/kaigai/gpuinfo

[kaigai@iwashi gpuinfo]$ ./gpuinfo
platform-index:      1
platform-vendor:     NVIDIA Corporation
platform-name:       NVIDIA CUDA
platform-version:    OpenCL 1.1 CUDA 4.2.1
platform-profile:    FULL_PROFILE
platform-extensions: cl_khr_byte_addressable_store cl_khr_icd cl_khr_gl_sharing
 cl_nv_compiler_options cl_nv_device_attribute_query cl_nv_pragma_unroll
  Device-01
  Device type:                     GPU
  Vendor:                          NVIDIA Corporation (id: 000010de)
  Name:                            GeForce GT 640
  Version:                         OpenCL 1.1 CUDA
  Driver version:                  310.32
  OpenCL C version:                OpenCL C 1.1
  Profile:                         FULL_PROFILE
  Device available:                yes
  Address bits:                    32
  Compiler available:              yes
  Double FP config:                Denorm, INF/NaN, R/nearest, R/zero, R/INF, FMA
  Endian:                          little
  Error correction support:        no
  Execution capability:            kernel, native kernel
  Extensions:                      cl_khr_byte_addressable_store cl_khr_icd \
   cl_khr_gl_sharing cl_nv_compiler_options cl_nv_device_attribute_query \
   cl_nv_pragma_unroll  cl_khr_global_int32_base_atomics \
   cl_khr_global_int32_extended_atomics cl_khr_local_int32_base_atomics \
   cl_khr_local_int32_extended_atomics cl_khr_fp64
  Global memory cache size:        32 KB
  Global memory cache type:        read-write
  Global memory cacheline size:    128
  Global memory size:              2047 MB
  Host unified memory:             no
  Image support:                   yes
  Image 2D max size:               32768 x 32768
  Image 3D max size:               4096 x 4096 x 4096
  Local memory size:               49152
  Local memory type:               SRAM
  Max clock frequency:             901
  Max compute units:               2
  Max constant args:               9
  Max constant buffer size:        65536
  Max memory allocation size:      511 MB
  Max parameter size:              4352
  Max read image args:             256
  Max samplers:                    32
  Max work-group size:             1024
  Max work-item sizes:             {1024,1024,64}
  Max write image args:            16
  Memory base address align:       4096
  Min data type align size:        128
  Native vector width - char:      1
  Native vector width - short:     1
  Native vector width - int:       1
  Native vector width - long:      1
  Native vector width - float:     1
  Native vector width - double:    1
  Preferred vector width - char:   1
  Preferred vector width - short:  1
  Preferred vector width - int:    1
  Preferred vector width - long:   1
  Preferred vector width - float:  1
  Preferred vector width - double: 1
  Profiling timer resolution:      1000
  Queue properties:                out-of-order execution, profiling
  Sindle FP config:                Denorm, INF/NaN, R/nearest, R/zero, R/INF, FMA

platform-index:      2
platform-vendor:     Intel(R) Corporation
platform-name:       Intel(R) OpenCL
platform-version:    OpenCL 1.2 LINUX
platform-profile:    FULL_PROFILE
platform-extensions: cl_khr_fp64 cl_khr_icd cl_khr_global_int32_base_atomics \
  cl_khr_global_int32_extended_atomics cl_khr_local_int32_base_atomics \
  cl_khr_local_int32_extended_atomics cl_khr_byte_addressable_store \
  cl_intel_printf cl_ext_device_fission cl_intel_exec_by_local_thread
  Device-01
  Device type:                     CPU
  Vendor:                          Intel(R) Corporation (id: 00008086)
  Name:                                   Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz
  Version:                         OpenCL 1.2 (Build 56860)
  Driver version:                  1.2
  OpenCL C version:                OpenCL C 1.2
  Profile:                         FULL_PROFILE
  Device available:                yes
  Address bits:                    64
  Compiler available:              yes
  Double FP config:                Denorm, INF/NaN, R/nearest, R/zero, R/INF, FMA
  Endian:                          little
  Error correction support:        no
  Execution capability:            kernel, native kernel
  Extensions:                      cl_khr_fp64 cl_khr_icd \
    cl_khr_global_int32_base_atomics cl_khr_global_int32_extended_atomics \
    cl_khr_local_int32_base_atomics cl_khr_local_int32_extended_atomics \
    cl_khr_byte_addressable_store cl_intel_printf cl_ext_device_fission \
    cl_intel_exec_by_local_thread
  Global memory cache size:        256 KB
  Global memory cache type:        read-write
  Global memory cacheline size:    64
  Global memory size:              386942 MB
  Host unified memory:             yes
  Image support:                   yes
  Image 2D max size:               16384 x 16384
  Image 3D max size:               2048 x 2048 x 2048
  Local memory size:               32768
  Local memory type:               DRAM
  Max clock frequency:             2600
  Max compute units:               32
  Max constant args:               480
  Max constant buffer size:        131072
  Max memory allocation size:      96735 MB
  Max parameter size:              3840
  Max read image args:             480
  Max samplers:                    480
  Max work-group size:             1024
  Max work-item sizes:             {1024,1024,1024}
  Max write image args:            480
  Memory base address align:       1024
  Min data type align size:        128
  Native vector width - char:      16
  Native vector width - short:     8
  Native vector width - int:       4
  Native vector width - long:      2
  Native vector width - float:     4
  Native vector width - double:    2
  Preferred vector width - char:   16
  Preferred vector width - short:  8
  Preferred vector width - int:    4
  Preferred vector width - long:   2
  Preferred vector width - float:  4
  Preferred vector width - double: 2
  Profiling timer resolution:      1
  Queue properties:                out-of-order execution, profiling
  Sindle FP config:                Denorm, INF/NaN, R/nearest

 

SE-PostgreSQLでリモートプロセスのセキュリティラベルを使用する

PostgreSQL Advent Calendar 2012に参加しています。

何を設定する必要があるのか?

何か世のため人のためになりそうなネタは・・・という事で、SE-PostgreSQLでリモートプロセスのセキュリティラベルを使用する方法をまとめてみました。

v9.1からサポートされたSE-PostgreSQLは、OSの機能であるSELinuxと連携して、SQLを介したDBオブジェクトへのアクセスに対して強制アクセス制御を行う機能です。
かいつまんで言えば、○○のテーブルは機密度の高い情報を持っている、□□は機密度が低いと言った形で、PostgreSQL標準のDBアクセス制御機能に加えて、OSのセキュリティポリシーに従ってアクセス制御を行います。そのため、同じ”情報”であるにも関わらず、保存先がファイルかデータベースかといった”格納手段”によって異なるポリシーでアクセス制御が行われる事を避ける事ができます。
SE-PostgreSQLが追加のアクセス制御を行う際には、『誰が(subject)』『何に(object)』『何をする(action)』のかをひとまとめにして、OSの一部であるSELinuxに伺いを立てるのですが、この時に『誰が』『何に』を識別するために、セキュリティコンテキストと呼ばれる特別な識別子が使用されます。
セキュリティコンテキストというのはこんな感じです。ファイルのセキュリティコンテキストを確認するには ls -Z を、プロセスの場合は ps -Z や id -Z を実行して確認できます。

[kaigai@iwashi ~]$ ls -Z
drwxr-xr-x. kaigai users unconfined_u:object_r:user_home_t:s0 patch/
drwxr-xr-x. kaigai users unconfined_u:object_r:user_home_t:s0 repo/
drwxr-xr-x. kaigai users unconfined_u:object_r:user_home_t:s0 rpmbuild/
drwxr-xr-x. kaigai users unconfined_u:object_r:user_home_t:s0 source/
drwxr-xr-x. kaigai users unconfined_u:object_r:user_home_t:s0 tmp/
[kaigai@iwashi ~]$ ps -Z
LABEL                             PID TTY          TIME CMD
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 28791 pts/4 00:00:00 bash
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 29093 pts/4 00:00:00 ps

DBオブジェクトの場合はSECURITY LABEL構文を用いて設定できるほか、システムビューのpg_seclabelsを用いて参照する事ができます。

postgres=# SELECT provider,label FROM pg_seclabels
              WHERE objtype = 'table' AND objname = 't1';
 provider |                  label
----------+------------------------------------------
 selinux  | unconfined_u:object_r:sepgsql_table_t:s0
(1 row)

そしてDB利用者の場合。やっと今回の記事の主題にたどり着けたワケですが、SE-PostgreSQLは『接続元プロセスのセキュリティコンテキスト』をDB利用者のセキュリティコンテキスト(= DB利用者の権限)として扱います。

SELinuxgetpeercon(3)というAPIを持っており、この人はソケットのファイルディスクリプタを引数として与えると、接続相手のセキュリティコンテキストを返します。SE-PostgreSQLはこのAPIを使用して『接続元プロセスのセキュリティコンテキスト』を取得しています。
で、UNIXドメインソケットによるローカル接続の場合は特別な設定は何も必要ないのですが、TCP/IPによるリモート接続の場合、OSは相手側プロセスの情報を持っていないため、ネットワーク接続の確立に先立って双方のセキュリティコンテキストを交換する Labeled Network の設定を行う必要があります。
この機能、Linux kernel 2.6.16 からサポートされた機能で、IPsecの鍵交換デーモンである racoon と連携してネットワーク接続先とセキュリティコンテキストを交換します。つまり、IPsecで通信経路が保護されている(少なくとも、改ざん検知できる)事が前提です。IPsecが前提ですよ。大切な事なので2回書きました。

Linux kernelのバージョンからも分かるようにそれほど新しい機能ではないのですが、意外と設定手順がドキュメント化されていませんので、少しまとめてみました。

テスト環境の構成

IPsecの設定を極める事が目的ではありませんので、今回は、最も単純なHost-to-HostのPSK(Pre-Shared Key)の構成でIPsec通信経路を作成します。その上で、Labeled Networkの設定を追加する事にします。


今回のテスト環境では、2台のホストをそれぞれサーバとクライアントに見立てて設定を行いました。kg1(192.168.1.42)がクライアント、kg2(192.168.1.43)がサーバであると想定し、この2台の間にIPsecの接続を設定します。ディストリビューションFedora 17 を使用し、Development Workstation構成でクリーンインストールしました。

なお、IPsec接続の設定に関してはFedora Projectのドキュメントを参考にしています。
詳しくはIPsec Host-to-Host Configurationを参照してください。

事前の設定

パッケージの追加

インストール直後の状態では ipsec-tools と system-config-network パッケージが導入されていませんので、両ホストでこれらのパッケージをインストールします。

# yum install -y ipsec-tools system-config-network

また、サーバ側には postgresql-server と postgresql-contrib パッケージが、クライアント側には postgresql パッケージが必要です。

# yum install -y postgresql-server postgresql-contrib
# yum install -y postgresql
ファイアウォールの設定

IPsecの鍵交換デーモンの通信とPostgreSQLサーバへの接続にはファイアウォールの設定変更が必要です。
system-config-firewallを実行して関連するポートを解放してください。

IPsecの鍵交換デーモン用ポートは、サーバ側/クライアント側の双方で解放が必要です。


PostgreSQLサーバ用ポートは、サーバ側で解放が必要です。

これらの設定を保存し、有効化してください。

NetworkManagerの無効化

ちょっとダサいのですが、NetworkManager経由でうまくipsec0デバイスを自動起動させる事ができなかったので、レガシーなnetworkスクリプトによってネットワークデバイスをup/downさせる事にします。

# chkconfig NetworkManager off
# chkconfig network on

Host-to-Host IPsec デバイスの作成

今回は、system-config-networkを使ってipsec0デバイスの定義ファイルや、racoonの設定ファイルを自動生成させる方法でHost-to-HostのIPsecデバイスを作成します。
まず、system-config-networkを実行して設定GUIを起動します。

起動画面です。『IPsec』のタブを選択します。


左上の『New』をクリックすると、ウィザード形式でIPsec設定を追加することができます。


デバイス名を入力します。ここでは『ipsec0』としました。


今回は『Host-to-Host encryption』を選択します。


『Automatic encryption mode selection via IKA (racoon)』を選択します。


この設定での接続相手先のIPアドレスを入力します。画面は192.168.1.42ホストのものなので、接続先は『192.168.1.43』となります。


事前共有型の認証キーを入力します。ここでは『sepgsql920』としました。


入力したパラメータを確認して『Apply』をクリックします。


これで『ipsec0』デバイスが作成されているのが分かります。


設定を保存すると、右上の『Activate』『Deactivate』を選択することができるようになりますので、『Activate』をクリックしてください。

もう一方のノードでも同様の設定を行います。

IPsecの動作確認

ひとまず、SELinux関連の設定が絡まない範囲でHost-to-Host設定のIPsecがちゃんと動作しているかを確認します。双方のノードでネットワークを再起動します。

# service network restart

tcpdumpでパケットを観察すると、IPsecのAH/ESPプロトコルが使われている事が分かります。

[root@kg2 ~]# tcpdump -n -i eth1 host 192.168.1.42
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 65535 bytes
16:55:34.979277 IP 192.168.1.42 > 192.168.1.43: \
    AH(spi=0x03850f37,seq=0x10): ESP(spi=0x0f3c888b,seq=0x10), length 76
16:55:34.979505 IP 192.168.1.43 > 192.168.1.42: \
    AH(spi=0x0ebad42b,seq=0x6): ESP(spi=0x0183d073,seq=0x6), length 76
16:55:34.980000 IP 192.168.1.42 > 192.168.1.43: \
    AH(spi=0x03850f37,seq=0x11): ESP(spi=0x0f3c888b,seq=0x11), length 68
    :
    :

Labeled Network の設定

ipsec0デバイスを起動する時に、どの相手との接続にIPsecを使うのか、その際のアルゴリズムは何なのか…という事がOS側に伝えられます。これを行うのがsetkeyコマンドなのですが、通常、これはifcfg-ipsec0の記述に基づいて(ここにスクリプトファイル名)が自動的に実行されます。
実際には以下のような書式をsetkeyコマンドに与えるのですが、Labeled Networkを使用するにあたって重要なのは、2行目の-ctxから始まる行です。これを指定する事で、このIPsec通信経路ではLabeled Networkを使用するという事を宣言できます。

spdadd 192.168.11.6 192.168.11.8 any
-ctx 1 1 "system_u:object_r:ipsec_spd_t:s0"
-P out ipsec esp/transport//require;

が、FedoraのInitスクリプトから実行される/etc/sysconfig/network-scripts/ifup-ipsecは、この辺の設定を行うための処理が入っていません。そこで、パッチを当てる事にしました(うげげ…)。

--- ifup-ipsec.orig	2012-12-03 17:41:38.809689669 +0100
+++ ifup-ipsec	2012-12-04 17:52:43.356150231 +0100
@@ -123,6 +123,8 @@ if [ "$ESP_PROTO" = "none" ]; then
     unset SPI_ESP_IN SPI_ESP_OUT KEY_ESP_IN KEY_ESP_OUT SPD_ESP_IN SPD_ESP_OUT
 fi
 
+test -n "$SELINUX_CONTEXT" && SELINUX_CONTEXT="\"${SELINUX_CONTEXT}\""
+
 /sbin/setkey -c >/dev/null 2>&1 << EOF
 ${SPI_AH_OUT:+delete $SRC $DST ah $SPI_AH_OUT;}
 ${SPI_AH_IN:+delete $DST $SRC ah $SPI_AH_IN;}
@@ -164,33 +166,45 @@ EOF
 # This looks weird but if you use both ESP and AH you need to configure them together, not seperately.
 if [ "$ESP_PROTO" != "none" ] && [ "$AH_PROTO" != "none" ]; then
 /sbin/setkey -c >/dev/null 2>&1 << EOF
-spdadd $SPD_SRC $SPD_DST any -P out ipsec
+spdadd $SPD_SRC $SPD_DST any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P out ipsec
             ${SPD_ESP_OUT:+esp/$MODE/${TUNNEL_MODE:+$SRC-$DST}/require}
             ${SPD_AH_OUT:+ah/$MODE/${TUNNEL_MODE:+$SRC-$DST}/require}
 	    ;
 
-spdadd $SPD_DST $SPD_SRC any -P in ipsec
+spdadd $SPD_DST $SPD_SRC any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P in ipsec
 	    ${SPD_ESP_IN:+esp/$MODE/${TUNNEL_MODE:+$DST-$SRC}/require}
 	    ${SPD_AH_IN:+ah/$MODE/${TUNNEL_MODE:+$DST-$SRC}/require}
 	    ;
 EOF
 elif [ "$AH_PROTO" = "none" ]; then
 /sbin/setkey -c >/dev/null 2>&1 << EOF
-spdadd $SPD_SRC $SPD_DST any -P out ipsec
+spdadd $SPD_SRC $SPD_DST any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P out ipsec
             ${SPD_ESP_OUT:+esp/$MODE/${TUNNEL_MODE:+$SRC-$DST}/require}
 	    ;
 
-spdadd $SPD_DST $SPD_SRC any -P in ipsec
+spdadd $SPD_DST $SPD_SRC any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P in ipsec
 	    ${SPD_ESP_IN:+esp/$MODE/${TUNNEL_MODE:+$DST-$SRC}/require}
 	    ;
 EOF
 elif [ "$ESP_PROTO" = "none" ]; then
 /sbin/setkey -c >/dev/null 2>&1 << EOF
-spdadd $SPD_SRC $SPD_DST any -P out ipsec
+spdadd $SPD_SRC $SPD_DST any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P out ipsec
             ${SPD_AH_OUT:+ah/$MODE/${TUNNEL_MODE:+$SRC-$DST}/require}
 	    ;
 
-spdadd $SPD_DST $SPD_SRC any -P in ipsec
+spdadd $SPD_DST $SPD_SRC any
+            ${SELINUX_CONTEXT:+-ctx 1 1 ${SELINUX_CONTEXT}}
+            -P in ipsec
 	    ${SPD_AH_IN:+ah/$MODE/${TUNNEL_MODE:+$DST-$SRC}/require}
 	    ;
 EOF

この修正により、TYPE=IPSEC として定義されている /etc/sysconfig/network-scripts/ifcfg-* 設定ファイルに「SELINUX_CONTEXT=...」という行が存在すれば、IPsec通信経路を定義する際に、Labeled Networkの定義も同時に行われる事となります。
ipsec_spd_t』というのは、IPsec通信経路に対して定義されているタイプです。現時点ではこれ以外のタイプは定義されていませんので、”お約束”という事にしておきます。

# cat /etc/sysconfig/network-scripts/ifcfg-ipsec0
DST=192.168.1.43
TYPE=IPSEC
ONBOOT=yes
IKE_METHOD=PSK
# KaiGai added them
SELINUX_CONTEXT=system_u:object_r:ipsec_spd_t:s0

うまく設定できていれば、setkey -DPの出力結果の中に『security context: ...』がどーたらという行が出力されているハズです。

# setkey -DP
    :
192.168.1.42[any] 192.168.1.43[any] 255
        out prio def ipsec
        esp/transport//require
        ah/transport//require
        created: Dec  4 16:45:31 2012  lastused: Dec  5 21:26:24 2012
        lifetime: 0(s) validtime: 0(s)
        security context doi: 1
        security context algorithm: 1
        security context length: 33
        security context: system_u:object_r:ipsec_spd_t:s0
        spid=1 seq=0 pid=3500
        refcnt=2

SE-PostgreSQLのインストール

続いて、SE-PostgreSQLのインストールに移ります。
インストール手順は公式ドキュメントにも記載されていますので、Fedoraのパッケージを前提にざっくりと紹介します。

まず、initdbを実行します。

# postgresql-setup initdb
Initializing database ... OK

次に、postgresql.conf を編集して shared_preload_libraries に '$libdir/sepgsql' を追加します。

# vi /var/lib/pgsql/data/postgresql.conf
    :
    :
#max_files_per_process = 1000           # min 25
                                        # (change requires restart)
shared_preload_libraries = '$libdir/sepgsql'

# - Cost-Based Vacuum Delay -

PostgreSQLをシングルユーザモードで起動し、各データベースに初期セキュリティラベルを割り当てます。

# su - postgres
$ for dbname in template0 template1 postgres
  do
    /usr/bin/postgres --single $dbname < \
      /usr/share/pgsql/contrib/sepgsql.sql > /dev/null
  done

これでSE-PostgreSQLの初期設定は完了です。

最後に、TCP/IP経由での接続を受け付けるための設定を行ってサーバを起動します。

# vi /var/lib/pgsql/data/pg_hba.conf
(以下の一行を追加; 設定簡略化のため、とりあえずtrust)
host    all    all    192.168.1.42/32    trust
vi /usr/lib/systemd/system/postgresql.service
(以下の一行を編集し、pg_ctlから-iオプションを渡すようにする)
ExecStart=/usr/bin/pg_ctl start -D ${PGDATA} -s -o "-p ${PGPORT}" -w -t 300
  ↓
ExecStart=/usr/bin/pg_ctl start -D ${PGDATA} -s -o "-i -p ${PGPORT}" -w -t 300

サーバを立ち上げます。

# service postgresql start

試してみる

長かった・・・。

そして、クライアント側からTCP/IP接続でPostgreSQLサーバに接続してみます。

[kaigai@kg1 ~]$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

[kaigai@kg1 ~]$ psql -h 192.168.1.43 -U postgres postgres
psql (9.1.6)
Type "help" for help.

postgres=# SELECT sepgsql_getcon();
                    sepgsql_getcon
-------------------------------------------------------
 unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
(1 row)

ふむふむ、ちゃんとセキュリティコンテキストが切り替わっているように見えます。

それでは、クライアント側で権限を切り替えてからPostgreSQLに接続してみましょう。

[kaigai@kg1 ~]$ runcon -l s0-s0:c0,c2 bash
[kaigai@kg1 ~]$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0-s0:c0,c2
[kaigai@kg1 ~]$ psql -h 192.168.1.43 -U postgres postgres
psql (9.1.6)
Type "help" for help.

postgres=# SELECT sepgsql_getcon();
                   sepgsql_getcon
----------------------------------------------------
 unconfined_u:unconfined_r:unconfined_t:s0-s0:c0,c2
(1 row)

キタコレ!

クライアント側のOS上で変更したプロセスのセキュリティコンテキストが、PostgreSQL側に透過的に伝えられている事が分かります。

なお、以下のようにgetpeercon(3)に失敗している場合は、TCP/IPレベルでの接続には成功したものの、Labeled Networkが機能していません。設定を見直してみてください。

$ psql -h 192.168.0.43 -U postgres postgres
psql: FATAL:  SELinux: unable to get peer label: Protocol not available

まとめ

今回紹介したLabeled Networkの機能を使う事で、SE-PostgreSQLが実現する”OSとDBのアクセス制御の統合”を複数ノードから成る環境にも拡大する事ができるようになりました。
ただ、これは不特定多数からの接続に対して、接続相手のセキュリティコンテキストを受け入れるという設定ではなく、あくまでも、信頼済みのホストが提示するセキュリティコンテキストを受け入れるという形になります。したがって、”誰か分からないけど世界中からアクセスが来る”という場合に使える方法ではなく、自サイトで管理下にあるWebサーバやAPサーバからPostgreSQLサーバに接続する際にセキュリティコンテキストを伝達するための手法という事になります。

現時点では、OS標準のスクリプトがLabeled Networkに対応しておらずスクリプトを修正する必要があったり、初回接続時に(racoonの鍵交換に時間がかかって?)タイムアウトになるといった現象が見られました。
この辺は改良の余地ありなので、引き続き調査してみようと思います。

mod_selinuxのアーキテクチャを考える(後編)

前回の記事では、現在の mod_selinux モジュールの問題点を解決するために複数のタスクキューにワーカースレッドを紐付ける方法を考え、そこから一歩考察を進めて、FastCGIを使ってマルチプロセス実装にすればよりセキュアなWebアプリケーション環境を実現できるのではないか?というアイデアを紹介した。

実際にやってみた。

もちろんSELinuxの権限に紐付けてリクエストの送出先を切り替えるFastCGIモジュールは存在しないので、既存のものに少し手を加える事にする。今回は(たぶん最も広く使われているであろう)mod_fcgidをベースに修正を加える事にする。

mod_fcgidモジュールではFcgidWrapperディレクティブを使って、例えば .php 拡張子を持つURLが指定された場合、FastCGIプロトコルを介して/usr/bin/php-cgiにリクエストを処理させるという指定ができる。
例えばこんな感じ。

AddHandler      fcgid-script     .php
FcgidWrapper    /usr/bin/php-cgi .php

で、mod_fcgidのソースを読んでみて気が付いたのだが、この人はFastCGI常駐プログラムが既に起動しているかどうかをチェックするために、内部テーブルをスキャンして、動作中のプロセスを起動したときのコマンドライン文字列(要はFcgidWrapperで指定した内容)と、これから起動するFastCGI常駐プログラムのコマンドライン文字列を比較するという処理を行っている。
そうすると、仮にラッパープログラムのコマンドライン文字列の一部を、HTTPリクエストを処理するセキュリティコンテキスト(あるいはユーザIDでも可)で置換するような仕組みを作ってあげれば、FcgidWrapperを次のように指定するだけで、後はmod_fcgidが善きに計らってくれる。

FcgidWrapper  "runcon %(SELINUX_CONTEXT) /usr/bin/php-cgi" .php

例えば、HTTPユーザ alice の場合に %(SELINUX_CONTEXT)が "system_u:system_r:httpd_t:s0:c0" に置換されたら、この設定内容は次のコマンドと等価である。おそらく、認証モジュールで設定するようなHTTP環境変数か何かを使うのが汎用的な実装だろう。兎に角、%(SELINUX_CONTEXT)の部分を動的に置換するというのがポイントである。

FcgidWrapper  "runcon system_u:system_r:httpd_t:s0:c0 /usr/bin/php-cgi" .php

もう一点。PHPのような動的コンテンツであれば既存のFastCGIの設定と同様にできるが、静的コンテンツの場合、apache/httpdはhandlerフックの一番最後で他のモジュールが処理できないタイプのHTTPリクエストを処理する…という流れになる。
つまり、ap_hook_handler() に APR_HOOK_LAST を付けて関数ポインタを登録し、静的コンテンツを読むだけのFastCGI常駐プログラムを実行するような拡張が必要になる。

この2点の魔改造を加えた mod_fcgid がこちら。
https://github.com/kaigai/mod_fcgid

KaiGai版 mod_fcgid のインストールと設定

mod_fcgid(魔改造版)のインストールは以下の通り。

$ git clone git://github.com/kaigai/mod_fcgid.git
$ cd mod_fcgid
$ git checkout --track origin/defhnd
$ ./configure.apxs && make
$ sudo make install

Apache/httpdの設定ファイルを編集。ここでは、.php ファイルに対してrunconを介して/usr/bin/php-cgiを実行するのと同時に、静的コンテンツに対してはfcgi-cat(後述)コマンドを実行する。
runconの引数に%(AUTHENTICATE_SELINUX_TYPE)と%(AUTHENTICATE_SELINUX_RANGE)が付いているが、これは実行時にHTTP環境変数と置換される。で、この環境変数を設定するのがauthn_dbd_moduleで、AuthDBDUserPWQueryが2個以上のカラムを返すとき、それらの内容はHTTP環境変数として後続のモジュールで参照する事ができる。

LoadModule fcgid_module      modules/mod_fcgid.so
LoadModule dbd_module        modules/mod_dbd.so
LoadModule authn_dbd_module  modules/mod_authn_dbd.so

DBDriver    pgsql
DBDParams   "host=localhost dbname=web user=apache"

AddHandler      fcgid-script  .php
FcgidWrapper    "/bin/runcon -t %(AUTHENTICATE_SELINUX_TYPE)  \
                             -l %(AUTHENTICATE_SELINUX_RANGE) \
                     /usr/bin/php-cgi" .php
FcgidDocumentWrapper  "/bin/runcon -t %(AUTHENTICATE_SELINUX_TYPE)  \
                                   -l %(AUTHENTICATE_SELINUX_RANGE) \
                           /usr/local/bin/fcgi-cat"

<Directory "/var/www/html">
# BASIC authentication
AuthType    Basic
AuthName    "Secret Zone"
AuthBasicProvider    dbd
AuthDBDUserPWQuery    "SELECT upasswd, selinux_type, selinux_range \
                       FROM uaccount WHERE uname = %s"
Require     valid-user
</Directory>

次に、認証DBのセットアップ。uaccountテーブルにBASIC認証の情報をインプットしておく。$PGDATAはPostgreSQLのDBクラスタのトップディレクトリ。まぁ、COPYコマンドで読めればドコでもいいんですが。

$ htpasswd -c $PGDATA/uaccount.master alice
New password:
Re-type new password:
Adding password for user alice
$ htpasswd $PGDATA/uaccount.master bob
New password:
Re-type new password:
Adding password for user bob
$ createdb web
$ pgsql web
web=# CREATE USER apache;
CREATE ROLE
web=# CREATE TABLE uaccount (
     uname   text primary key,
     upasswd text,
     selinux_type text,
     selinux_range text
 );
CREATE TABLE
web=# GRANT SELECT on uaccount TO public;
GRANT
web=# COPY uaccount(uname,upasswd) FROM '/opt/pgsql/uaccount.master';
web=# COPY uaccount(uname,upasswd) FROM '/opt/pgsql/uaccount.master' DELIMITER ':';
COPY 2
web=# UPDATE uaccount SET selinux_type = 'httpd_t';
UPDATE 2
web=# UPDATE uaccount SET selinux_range = 's0:c0' WHERE uname = 'alice';
UPDATE 1
web=# UPDATE uaccount SET selinux_range = 's0:c1' WHERE uname = 'bob';
UPDATE 1
web=# SELECT * FROM uaccount;
 uname |                upasswd                | selinux_type | selinux_range
-------+---------------------------------------+--------------+---------------
 alice | $apr1$HQtZm8Sz$ZwXm9tx6Kz7g2wiE1LBUZ/ | httpd_t      | s0:c0
 bob   | $apr1$YJWn7hx3$iJyazYClFeFF0n8EgIMSi. | httpd_t      | s0:c1
(2 rows)

次に、静的ドキュメントを参照するFactCGI常駐プログラムのfcgi-catをビルドして /usr/local/bin にインストールする。こやつのビルドには fcgi-devel パッケージも必要です。

$ git clone git://github.com/kaigai/toybox.git
$ cd toybox
$ gcc -g fcgi-cat.c -lfcgi -o fcgi-cat
$ sudo install -m 755 fcgi-cat /usr/local/bin

最後に、SELinuxセキュリティポリシーを追加し、Booleanを調整して設定は終わり。

$ make -f /usr/share/selinux/devel/Makefile webapps.pp
$ sudo semodule -i webapps.pp
$ sudo setsebool httpd_can_network_connect_db on

動かしてみた

お試しとして、以下の内容のPHPスクリプトを実行させてみる。

<?php
echo "<h3>".selinux_getcon()."<h3>\n";
echo "<h3>username = ".$_SERVER["REMOTE_USER"]."</h3>\n";

phpinfo();
?>

ユーザ alice としてアクセスした場合。確かにDBで指定した通り、セキュリティコンテキストの末尾が ":s0:c0" となっている。

一方、ユーザ bob としてアクセスした場合。今度はセキュリティコンテキストの末尾が ":s0:c1" となっている。Server APIの欄が "CGI/FastCGI" となっている点にも注目。

では、静的ドキュメントを参照した場合。2つの画像ファイル test00.jpg と test01.jpg のセキュリティコンテキストはそれぞれ以下のように設定されているため、alice は test00.jpg を参照できるが、test01.jpg は見えないはず。

$ ls -Z /var/www/html
-rw-r--r--. root root unconfined_u:object_r:httpd_sys_content_t:s0 mytest.php
-rwxr--r--. root root unconfined_u:object_r:httpd_sys_content_t:s0:c0 test00.jpg
-rwxr--r--. root root unconfined_u:object_r:httpd_sys_content_t:s0:c1 test01.jpg

これが test00.jpg を参照したケース。正しく読み込めている。

今度は test01.jpg を参照した場合。サーバは 403 Forbidden を返している。ヒャッハー!

画像は省略するが、これをユーザbobで試した場合は逆の結果となる。

もちろん、これらはFastCGIで動作しているので、psコマンドを叩くと常駐プロセスの様子が見える。
確かにユーザ alice と bob それぞれのHTTPリクエストを処理するために、別個のプロセスが起動している。

[root@iwashi ~]# ps -AZ | grep php-cgi
system_u:system_r:httpd_t:s0:c0 23960 ?        00:00:00 php-cgi
system_u:system_r:httpd_t:s0:c1 24062 ?        00:00:00 php-cgi
[root@iwashi ~]# ps -AZ | grep fcgi-cat
system_u:system_r:httpd_t:s0:c0 24098 ?        00:00:00 fcgi-cat
system_u:system_r:httpd_t:s0:c1 24107 ?        00:00:00 fcgi-cat

まとめ

今までの mod_selinux と違い、この方法では全てのDSOモジュールに作用させるという事はできない。
PHPならPHP用の、RubyならRuby用の設定がそれぞれ必要となる。これは確かにデメリット。

その一方で、HTTPユーザ毎にアプリを実行するメモリ空間を分けられるという事は、セキュリティの観点からは非常に大きなアドバンテージである。Web管理者がその気になれば、Apache/httpdでは一切Webアプリの実行を行わず、ある種のアプリケーションサーバとして振る舞うFastCGI常駐プログラムが、HTTPユーザ毎に閉じたコンテナ環境を提供するという事も可能だろう。
セキュリティの強化を目的とするモジュールなので、ここのアドバンテージはでかい、と思う。