この話題を取り上げ始めたのは、2月末のまだまだ寒い時期でしたが、あっと言う間に季節は巡り、桜の花も過ぎて、初夏の陽気すら感じる季節になりました。
過去2回では、依存関係情報やそれをSQLiteを使ってデータベースする際の仕組みなどを実際のスクリプトを紹介しながら説明してきました。今回はこの話題のまとめとして、説明してきたスクリプトを実際に使う際に生じた問題点やスクリプトの使用例などを紹介してみます。
依存関係情報収集スクリプトとSQLiteの罠
前回 作成した2つのスクリプトをまとめて、「 依存関係情報を集めるスクリプト」の意味でget_depends.py という名称にしてみました。
さっそく、このスクリプトで手元の環境の依存関係情報のデータベース化を試したところ、処理にずいぶん時間がかかります。依存関係情報の検索範囲をファイルシステムの一部に限ってみても、検索、登録作業に3~4時間はかかるようでした。しかも、その時間のほとんどを費しているのがSQLiteのデータベースに依存関係を登録する処理で、出力されるメッセージを見る限り、前回紹介した1レコード4カラムの単純なデータすら、1秒あたり数件程度 しか登録できないようです。
気軽に使えるのが特徴だとしても、データベースシステムの本質とも言えるデータの登録作業にこんなに時間がかかるようでは、とても実用的なシステムには使えないなぁ…、と失望しかけましたが、Google等で調べてみると、SQLiteのデータ登録(インサート)は、インサートごとにトランザクション処理が行われるので時間がかかる、といった情報が多数見つかりました。
この問題はSQLiteのホームページのFAQ にも言及されていました。
[原文]
(19) INSERT is really slow - I can only do few dozen INSERTs per second
Actually, SQLite will easily do 50,000 or more INSERT statements per second on an average desktop computer. But it will only do a few dozen transactions per second. Transaction speed is limited by the rotational speed of your disk drive. A transaction normally requires two complete rotations of the disk platter, which on a 7200RPM disk drive limits you to about 60 transactions per second.
Transaction speed is limited by disk drive speed because (by default) SQLite actually waits until the data really is safely stored on the disk surface before the transaction is complete. That way, if you suddenly lose power or if your OS crashes, your data is still safe. For details, read about atomic commit in SQLite..
By default, each INSERT statement is its own transaction. But if you surround multiple INSERT statements with BEGIN...COMMIT then all the inserts are grouped into a single transaction. The time needed to commit the transaction is amortized over all the enclosed insert statements and so the time per insert statement is greatly reduced.
[拙訳]
(19)インサートがきわめて遅い - 1秒あたり数十件のインサートしか行えない
実のところ、SQLiteは、平均的なデスクトップPC上で50,000件/秒以上のインサートを容易にこなしますが、トランザクション処理が1秒あたり数十件しかできないのです。トランザクションの速度はHDDの回転速度に依存します。トランザクションには、通常、HDDの記録面が2周する必要があります。そのため7200RPMのHDDでは(1分あたり7200回転=1秒あたり120回転なので)1秒あたりに可能なトランザクションの回数は60回に制限されます。
トランザクションの速度がディスクドライブの速度に制限されるのは、SQLiteは(通常では)データが正しくディスク上に書き込まれたことを確認するまで、処理の完了を待つためです。こうしておけば、突然電源が切れたり、OSがクラッシュしたとしてもデータは安全ですから。詳細については、SQLiteのatomic commitについて調べてください。
デフォルトの設定では、インサートはそれぞれがトランザクション処理になります。しかし、複数のインサートをBEGIN...COMMITで囲めば、その中のインサートは1つのトランザクションにまとめられ、その結果、各インサートにかかる時間を大幅に改善することが可能です。
なるほど、SQLiteの場合、デフォルトでは1つのインサートが1つのトランザクション と扱われるので、大量のデータを1つずつインサートしようとすると長い時間がかかってしまうようです。
「トランザクション処理」とはデータベースの内容の整合性を保つための機能で、一連のデータの書き込みや削除などを一つの操作としてまとめ、もしその操作が途中で異常終了した場合、データベースを操作前の状態に戻す(ロールバック)ことで、データベースの内容に矛盾が生じることを回避する仕組みです。
上記FAQによると、複数のINSERT文をBEGIN...COMMITで囲めばいいそうですが、そのような処理はPythonではどうするのだろう…、と思ってSQLite3モジュールのドキュメントを読み返してみると、executemany() という関数が用意されていました。この関数を使えばタプルのリストに対してまとめてSQL文を実行することができそうです。
そこで、SQLiteのデータベースに登録するinsert_db()の部分を、executemany()を使うように修正したinsert_db2()を用意しました。
リスト1 insert_db2()
def insert_db2(dbname, t):
conn = sqlite3.connect(dbname)
try:
print "inserting ", t
conn.executemany('insert into depends values(?, ?, ?, ?)', t)
conn.commit()
except sqlite3.Error, e:
print "An error occurred:", e.args[0]
conn.rollback()
insert_db2()は、従来のinsert_db()のように1つのタプルごとにインサートを実行するのではなく、複数のタプルを集めたリスト を受け取って一気にインサートするので、この関数を呼び出す部分も登録すべきデータ(タプル)をいったんリストに集めた上で渡すように修正しました。
リスト2 insert_db2()の使い方
list = []
for file in files:
base = os.path.basename(file)
tmp = get_depends(file)
for i in tmp:
(soname, realname) = split_parts(i)
print("{0}, {1}, {2}, {3}".format(base, file, soname, realname))
t = (base, file, soname, realname)
# insert_db(dbname, t)
list.append(t)
insert_db2(dbname, list)
この改造でどれくらい処理速度が向上するかを確認するために、調査範囲を/usr/bin以下に限定してtimeコマンド経由で実行したところ、表1のような結果になりました。ざっと見で、38分かかっていた処理が30秒弱にまで改善 したようです。
表1 insert_db2()の効果
1つずつinsert まとめてinsert
実時間(real) 38m4.179s 0m27.210s
ユーザ時間(user) 0m13.019s 0m5.773s
システム時間(sys) 0m19.659s 0m5.293s
登録処理が終了したデータベースを調べると、22,524件のデータが登録されていましたので、この結果から計算すると、1つずつインサートした場合は1秒あたり9.8件 、まとめてインサートした場合は1秒あたり828件 、登録できたことになります。
この結果を見ると、FAQにもあるように、SQLiteではインサート時のトランザクション処理が律速段階になっていて、データをまとめてインサートすることが処理速度を向上させる鍵になっていることがわかります。このあたりは、同じRDBソフトウェアでもPostgreSQLやMySQLには無かった特徴で、SQLiteを利用する際には注意すべきポイントになるでしょう。
一方、get_depends.pyスクリプトのうち、指定したディレクトリ以下からELF形式のバイナリファイルを探す処理についても、/usr/include/や/usr/share/等、バイナリファイルが無いことがわかっているディレクトリはあらかじめ検索対象から省く ようにして、検索速度の向上を目指しました。
これらの改造の結果、当初は5~6時間以上かかっていた、22万件のデータを収めた依存関係情報データベースの作成が、5分前後で終了するようになりました。これくらいの速度ならば、開発作業の合間に最新の情報に更新することも容易でしょう。
このスクリプトの完成版は筆者の日記のページ に添付ファイルとして置いています。同じようなコードを再掲するのも何なので、興味ある人は上記サイトからご入手ください。
依存関係情報検索スクリプト
依存関係情報のデータベースができたので、次はこのデータベースを検索するツールを書いてみます。
前回紹介したように、作成した依存関係情報のデータベースは4つの欄からなる表形式 になっています。
表2 ライブラリ依存関係DBのテーブル
コマンド名 ファイル名 共有ライブラリ名 ライブラリへのパス
sed /usr/bin/sed linux-vdso.so.1
sed /usr/bin/sed libacl.so.1 /usr/lib64/libacl.so.1
sed /usr/bin/sed libc.so.6 /lib64/lib.so.6
sed /usr/bin/sed libattr.so.1 /usr/lib64/libattr.so.1
sed /usr/bin/sed /lib64/ld-linux-x86-64.so.2
検索ツールでは、これら4つの欄のどれかをキーとして他の欄を検索することになりますので、どの欄をキーにするかはオプションで指定できる ようにしましょう。
検索の方向は、コマンド名やファイル名から共有ライブラリ名を引く場合と、共有ライブラリ名からコマンド名やファイル名を引く場合があります。以下では、説明のために前者の方向を「正引き 」 、後者を「逆引き 」と呼ぶことにします。
前回紹介したように、作成したデータベース中では、各欄は表3のような名称になっているので、それぞれの頭文字をオプション指定に使って、-bがコマンド名からの正引き、-pがファイル名からの正引き、-sが共有ライブラリ名からの逆引き、-rがライブラリへのパスからの逆引き、ということにしましょう。
表3 依存関係DBのキー名称
コマンド名 ファイル名 共有ライブラリ名 ライブラリへのパス
base path soname realname
Pythonには、UNIXのgetopt同様の機能を実現するgetopt というモジュールが用意されているので、そのモジュールをインポートしてやれば、オプションをパースする部分はリスト3のような形で書けるでしょう。
リスト3
1 import getopt
2
3 def get_opts():
4 try:
5 opts, args = getopt.gnu_getopt(sys.argv[1:], "b:p:s:r:", ["base=","path=","soname=", "realname="])
6 except getopt.GetoptError:
7 usage()
8 sys.exit(2)
9
10 for o, a in opts:
11 if o in ("-b", "--base"):
12 cmd = 'base'
13 arg = a
14 elif o in ("-p", "--path"):
15 cmd = 'path'
16 arg = a
17 elif o in ("-s", "--soname"):
18 cmd = 'soname'
19 arg = a
20 elif o in ("-r", "--realname"):
21 cmd = 'realname'
22 arg = a
23 else:
24 assert False, "unhundled option"
25 usage()
26
27 return (cmd, arg)
5行目が引数として指定されたオプションと検索の際のキーワードになるファイル名解析する部分で、getopt.gnu_getopt()を使って、UNIXで伝統的な「ハイフン+1文字 」で指定する-b catといったオプション指定と、長形式 と呼ばれる--base=catといった指定の双方を利用可能にしています。
10行目からは、解析した引数を元に、cmdにデータベースを引く際にキーとする欄を、argにキーワードを収めて返しています。
データベースを検索する部分はリスト4のようにしてみました。このquery()という関数は、データベース名(db)と検索する欄(cmd) 、キーワード(arg)を引数にとって、検索した結果を表示します。
リスト4
1 import sqlite3
2
3 def query(db, cmd, arg):
4 conn = sqlite3.connect(db)
5 cur = conn.cursor()
6 sql = 'SELECT {0} FROM depends WHERE {0} LIKE "%{1}%" GROUP BY {0};'.format(cmd, arg)
7 cur.execute(sql)
8 tgt = []
9 for i in cur:
10 tgt.append(i[0])
11
12 for i in tgt:
13 if cmd == 'base' or cmd == 'path' :
14 print("{0} needs these libraries".format(i))
15 else:
16 print("{0} used by these binaries".format(i))
17
18 sql = 'SELECT * FROM depends WHERE {0}="{1}";'.format(cmd, i)
19 cur.execute(sql)
20 for row in cur:
21 (base, path, soname, realname) = row
22 if cmd == 'base' or cmd == 'path' :
23 print(" {0}({1})".format(soname, realname))
24 else:
25 print(" {0}({1})".format(base, path))
26 print
この関数では6行目と18行目の2箇所でSQL文を生成しています。6行目のSQL文では、任意の文字列にマッチするSQLのワイルドカードキャラクタ である"%"をキーワードの前後に追加して、検索対象とした欄にキーワードに指定した文字列の一部があればマッチするあいまい検索 を行っています。
たとえば、cmdにsoname、argにlibcupsを指定すると、
SELECT soname FROM depends WHERE soname LIKE "%libcups%" GROUP BY soname;
といったSQL文に展開されるわけです。
sqlite3コマンドを使って実際にこのSQL文で検索してみると、以下のようにlibcupsを一部に含む共有ライブラリ名が複数得られます。
$ sqlite3 depends.sql3
SQLite version 3.7.10 2012-01-16 13:28:40
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> SELECT soname FROM depends WHERE soname LIKE "%libcups%" GROUP BY soname;
libcups.so.2
libcupscgi.so.1
libcupsdriver.so.1
libcupsimage.so.2
libcupsmime.so.1
libcupsppdc.so.1
sqlite>
この結果を、いったんtgt[]というリストに収めておいて(10行目) 、結果の各行を用いて18行目で以下のようなSQL文を生成します。
SELECT * FROM depends WHERE soname="libcups.so.2";
このSQL文で再度データベースを検索すれば、libcups.so.2を使っているバイナリファイルの一覧が得られます。
sqlite> SELECT * FROM depends WHERE soname="libcups.so.2";
epdfview|/usr/bin/epdfview|libcups.so.2|/usr/lib64/libcups.so.2
gs|/usr/bin/gs|libcups.so.2|/usr/lib64/libcups.so.2
gsc|/usr/bin/gsc|libcups.so.2|/usr/lib64/libcups.so.2
gsx|/usr/bin/gsx|libcups.so.2|/usr/lib64/libcups.so.2
....
この結果を21行目から25行目で整形して表示しているわけです。
実際のスクリプトではこれら以外にも、main()の処理やusage()の使用法表示等がありますが、煩雑になりそうなので割愛します。このスクリプトの完成版も、get_depends.py同様、筆者の日記のページ に添付ファイルとして置いていますので、興味ある人は上記サイトからご入手ください。
スクリプトの活用例
依存関係情報を収集するスクリプトをget_depends.pyとしたので、前節で紹介した検索するスクリプトはquery_depends.py と呼ぶことにします。
まず正引きの例として、-bオプション を使えば、バイナリファイル名の一部から、そのバイナリファイルが参照している共有ライブラリ を調べることができます。
$ ./query_depends.py -b cat
bdftruncate needs these libraries
linux-vdso.so.1(none)
libc.so.6(/lib64/libc.so.6)
/lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)
cat needs these libraries
linux-vdso.so.1(none)
libc.so.6(/lib64/libc.so.6)
/lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)
catia.so needs these libraries
linux-vdso.so.1(none)
libc.so.6(/lib64/libc.so.6)
/lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)
...
ファイル名の一部からだとマッチする件数が多すぎる場合は、フルパス名で指定 すれば検索対象が一意に定まります。
$ ./query_depends.py -p /bin/cat
/bin/cat needs these libraries
linux-vdso.so.1(none)
libc.so.6(/lib64/libc.so.6)
/lib64/ld-linux-x86-64.so.2(/lib64/ld-linux-x86-64.so.2)
共有ライブラリ名の一部から逆引き するには-s オプション を指定します。
$ ./query_depends.py -s poppler
libpoppler-glib.so.5 used by these binaries
inkscape(/usr/bin/inkscape)
inkview(/usr/bin/inkview)
poppler.so(/usr/lib64/python2.7/site-packages/poppler.so)
libpdfdocument.so(/usr/lib64/evince/3/backends/libpdfdocument.so)
tumbler-poppler-thumbnailer.so(/usr/lib64/tumbler-1/plugins/tumbler-poppler-thumbnailer.so)
libpoppler-glib.so.8 used by these binaries
epdfview(/usr/bin/epdfview)
poppler-glib-demo(/usr/bin/poppler-glib-demo)
file-pdf(/usr/lib64/gimp/2.0/plug-ins/file-pdf)
...
これは当初考えていなかった機能ですが、共有ライブラリのパス名として"found"という文字列を指定すれば、( lddが"not found"というメッセージを出している)見つからなかった共有ライブラリを参照しているバイナリファイルを調べることもできます。
$ ./query_depends.py -r found
not found used by these binaries
m_xt.so(/lib/tc/m_xt.so)
libwx_gtk2u_html-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_html-2.8.so.0.8.0)
libwx_gtk2u_xrc-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_xrc-2.8.so.0.8.0)
libwx_gtk2u_richtext-2.8.so.0.8.0(/usr/lib64/libwx_gtk2u_richtext-2.8.so.0.8.0)
libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
libboost_graph.so.1.45.0(/usr/lib64/libboost_graph.so.1.45.0)
....
どんな共有ライブラリが見つからなかったかを調べるには、-bオプションでそのバイナリファイルが参照している共有ライブラリを調べます。
$ ./query_depends.py -b libboost_graph.so.1.45.0
libboost_graph.so.1.45.0 needs these libraries
linux-vdso.so.1(none)
libboost_regex.so.1.45.0(/usr/lib64/libboost_regex.so.1.45.0)
libicuuc.so.46(not found)
libicui18n.so.46(not found)
...
あれれ、C/C++/Java用のUnicodeライブラリ であるlibicuは入れてるはずなのに見つからない、と言われてしまいました。おかしいな、と思って、libicuuc.so を検索してみると、
./query_depends.py -b libicuuc.so
libicuuc.so.44.2 needs these libraries
linux-vdso.so.1(none)
libicudata.so.44(/opt/libreoffice3.4/basis3.4/program/libicudata.so.44)
libpthread.so.0(/lib64/libpthread.so.0)
...
libicuuc.so.48.1.1 needs these libraries
linux-vdso.so.1(none)
libicudata.so.48(/usr/lib64/libicudata.so.48)
libpthread.so.0(/lib64/libpthread.so.0)
...
libicuuc.soの44.2 と48.1.1 はあるのに、46 というバージョンは存在しないようです。これらはどのパッケージでインストールされたのだろう、と/var/log/packages/以下を調べると、icuパッケージ とlibobasis3.4_core05パッケージ に由来することがわかりました。
$ grep libicuuc /var/log/packages/*
/var/log/packages/icu:usr/lib64/libicuuc.so.48.1.1
/var/log/packages/libobasis3.4_core05:opt/libreoffice3.4/basis3.4/program/libicuuc.so.44.2
このうち、libobasis3.4_core05はLibreOfficeの一部なので、システム全体で使っているのはicuパッケージのはずです。この結果を見ると、どうやらicuパッケージを更新した後で、このパッケージを使っているC++用のライブラリ集であるBoostをビルドし忘れていたようです。
参照が解決できない共有ライブラリ(たとえばlibboost_graph.so.1.45.0)を使っているバイナリファイルは起動できなくなっているはずなので、あわてて、この共有ライブラリを使っているコマンド類を調べてみると、
$ ./query_depends.py -s libboost_graph
$
何のことはない、該当するファイルはありませんでした……。orz
さて、そもそもこのパッケージは必要なのか? という疑問も生じてきますが、Boostライブラリの有無をチェックしているソフトウェアがあったからこのパッケージを作ったはずなので、いずれにしても早めに更新しておくのがよさそうです。
本連載では3回に渡ってSQLiteを用いた依存関係管理システムを紹介してみました。
実のところ、SQLiteを用いたスクリプトを作るのは初めてだったので、あちこちにたどたどしい部分があったかと思いますが、SQLiteの面白さや便利さは多少なりとも伝わったでしょうか?
今回紹介したスクリプトを使えば、22万件を越える依存関係情報の中から、コンマ数秒で指定した関係を検索できるようになり、ライブラリ類を更新する順番を考えるのがずいぶん楽になりました。
テキストベースでは管理しづらい規模だけど、わざわざPostgreSQLやMySQLを入れるのも面倒くさい、といった規模のデータを扱うには、SQLiteは便利な選択肢になりそうです。