さくらのアドベントカレンダー(その2)をご覧の皆様、初めまして。一般のさくらインターネットユーザのPiroといいます。
この記事では自分で自由に使えるLinuxなサーバーかPCがあるという事を前提として1、さくらのレンタルサーバーのライトプランで静的コンテンツだけのWebサイトを公開・運用する際のノウハウをご紹介します。
SSH接続できない!
自分は日経Linux誌でシス管系女子という漫画形式の記事を連載させて頂いているのですが、「せっかく本まで出したんだからプロモーション用のページ作りましょうよ!!絵とかバーンとでっかく貼ってかわいい感じのを!!」と言ってみたものの、「日経BP公式サイトのCMS上ではムリ(大意)」と言われてしまったため、自分で勝手にサイトを作って公開する事にしました。約1年前の事です。
で、そうなるとどこかにサーバを借りる必要があるのですが、自分は個人的に10年来以上のさくらのレンタルサーバーユーザなので、「やっぱ信頼と実績のさくらだよね!」と。
作りたいのはせいぜい1枚ペラの静的コンテンツだったことから、月額にして約129円の一番安いプランで必要十分だろうと判断して、サーバ上の一角をお借りする事にしました。
ところが、ここに大きな誤算がありました。ライトプランではSSH接続は使えないのです……
自分がそれまで個人的に愛用していたスタンダードプランは月額約429円で共用サーバの一角をお借りする物で、こちらはSSHが使えます。そのため、デフォルトのシェルをBashにしたりVimを入れたり、さらにGitを入れたりして、自宅のサーバーに置いたGitリポジトリをgit clone
した物を~/www
に置き、そのままWebサイトとして公開していました。それと同じ要領で行くつもりだったのですが、月額300円をケチったばかりに目論見が総崩れです。
SSHができなきゃrsync
でお手軽ミラーリングというわけにもいきません。かといって今更Windows用のFTPクライアントを探す気にもなれないし。なので、サイト開設から1年くらいはコントロールパネル上で提供されているWeb UIのファイルマネージャを使ってちまちまファイルをアップロードしていたのですが、シス管系女子 Advent Calendar 2016を立ち上げた結果、1日最低1回はサイトを更新する必要が生じてついに我慢の限界に達しました2。そこで運用開始から1年が経過してようやく、サイトの更新手順の自動化に本腰を入れて取り組む事にしました。
ファイルのアップロードはlftp
で一発
静的コンテンツのアップロードは、rsync
を使えればそれが一番楽です。rsync
の--archive
オプションと--delete
オプションを組み合わせれば、ローカル側で更新されたファイルを転送し、リモート側に取り残されたファイルを削除する、という事(いわゆるミラーリング)を効率よく行えます。が、前述の通りライトプランではこの方法を採れません。
でもよくよく考えてみれば、Linuxのコマンド操作とシェルスクリプトをテーマに解説記事を自分で書いてるわけだから、コマンドラインで使えるFTPクライアントがあればなんとかなりそうです。
そう考えて「rsync FTP」みたいなキーワードで検索してみたら、あっさり見つかりました。その名もlftp
。これ自体は対話的に動作するツールとの事ですが、引数で指示を与えれば完全な自動実行も可能で、しかもミラーリングのための便利な内部コマンドを持ってると。rsync
と対でlftp
、と名前も覚えやすいですね!3
これを使って、「実行すれば後はrsync
感覚で更新されたファイルのコピーと取り残されたファイルの削除が完了する」というシェルスクリプトを用意しました。
#!/bin/bash
# 相対パスを気軽に書けるように、
# スクリプトがあるディレクトリにcdすることにする。
root_dir="$(cd "$(dirname "$0")" && pwd)"
cd $root_dir
# ユーザ名が長すぎて「system-admin-gir」で切れてしまってるのはご愛敬……
HOSTNAME=system-admin-gir.sakura.ne.jp
USERNAME=system-admin-gir
PASSWORD=**************
SOURCE=html
DIST=/home/system-admin-gir/www
lftp -d -e "\
set ftp:ssl-auth TLS; \
set ftp:ssl-force true; \
set ftp:ssl-allow yes; \
set ftp:ssl-protect-list yes; \
set ftp:ssl-protect-data yes; \
set ftp:ssl-protect-fxp yes; \
open -u $USERNAME,$PASSWORD $HOSTNAME ; \
mirror \
--reverse \
--delete \
--only-newer \
--dry-run \
--verbose \
$SOURCE \
$DIST; \
exit"
このスクリプトは、以下のような位置関係でファイルが存在している事を前提にしています。
upload.sh
-
html
(ディレクトリ)index.html
.htaccess
- ...
lftp
コマンドは、実行する内部コマンドを-e
オプションで指定すれば非対話的に実行でき、;
で区切って複数コマンドを列挙すれば複雑な操作も自動実行できます。この方法を使って、
- まず
set
でTLSに関わる諸々のオプションを有効化して、セキュアな通信を行うようにする。 - その上で、
open
でログイン。 - ログイン後、
mirror
でミラーリングを実施。 - 終わったら
exit
で切断。
という操作を行うようにしました。パスワードは平文でスクリプトに書く必要がありますが、TLSを有効化していわゆるFTPSでの通信を確立してから認証するので、パスワードが平文のままネットワークを流れる事はありません。
いきなり実行するのは怖かったので、lftp
自体をデバッグモードで動かす-d
オプションと、mirror
をテスト実行し詳細情報を出力させるための--dry-run --verbose
とを併せて指定しています。この状態でスクリプトを実行してみて、問題がない事を確認できてから改めて--dry-run
を取り除くことにします。
共通パーツの埋め込み
前述の通り元々が1枚ペラのページだけに留めるつもりだったため、HTMLファイルには共通のヘッダやフッタ、各SNS向けのシェア用ボタンなどもそのまま静的に記述していました。しかしページ数が増えていくにつれて「これはいつか破綻する……」という危機感が増していき、アドベントカレンダー用に一気に何十ページも増やす事が確定したことでついに破綻が現実化してしまいました。
幸い、ここまでの時点でファイルのアップロードを自動化する事はできました。なのでついでに、各ページのヘッダやフッタといった共通部分を別ファイルに分離しておいて、アップロードの直前にそれらをくっつけてからアップロードするようにしてみます。戦略としては、以下の要領です。
build.sh
upload.sh
-
html
(ディレクトリ、ソース用)-
_parts
(ディレクトリ)metadata.html
-
sharebuttons
(ディレクトリ)
*top.html
- ...
index.html
.htaccess
- ...
-
-
html_resolved
(ディレクトリ、アップロード用)index.html
- ...
このような位置関係でファイルを置いておき、build.sh
を実行したらhtml
を丸ごとhtml_resolved
にコピーして、各HTMLファイルの中に
<!DOCTYPE html>
<html lang="ja" xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml">
<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
<!--EMBED(metadata.html)-->
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
のように書かれた箇所を検出して、
<meta charset="UTF-8">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@piro_or">
<meta name="twitter:creator" content="@piro_or">
...
という感じの共通パーツのファイルの内容を
<!DOCTYPE html>
<html lang="ja" xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml">
<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# article: http://ogp.me/ns/article#">
<meta charset="UTF-8">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@piro_or">
<meta name="twitter:creator" content="@piro_or">
...
<title>シス管系女子って何!? - 【シス管系女子】特設サイト</title>
...
のように埋め込んでいく、という方針です。
……という説明を見て、昔なつかしSSI(Server Side Include)を思い出した方もいると思います。じゃあSSI使えばええやんという話なのですが、SSIはWebサーバの機能なので、アップロード前の表示確認をするにはローカルにApacheか何かを立てないといけません。それは面倒で嫌だったので、SSIを使わずにそれっぽい事をやることにしたのでした。
できたスクリプトはこんな感じです。
#!/bin/bash
# 相対パスを気軽に書けるように、
# スクリプトがあるディレクトリにcdすることにする。
root_dir="$(cd "$(dirname "$0")" && pwd)"
cd $root_dir
# 環境によってsedで拡張正規表現を使うためのオプションが違うので、
# egrepコマンドのように使える「$esed」を定義しておく。
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
# アップロード用のファイルを用意する。
# とりあえず、前回の結果は削除してソースを丸ごとコピーし直すことにする。
rm -rf html_resolved
cp -r ./html/ ./html_resolved/
# 共通ファイルの埋め込み箇所の検出用正規表現。
# ファイル名部分は、後方参照で取り出せるように`()`で囲っておく。
embed_matcher='<!-- *EMBED\( *([^) ]+) *\) *-->'
# 上記正規表現にマッチした箇所にファイルの内容を埋め込むための
# sedのコマンドライン引数を組み立てるための、sedのスクリプト。
# 詳細は後述。
embed_mark_to_resolver="s|($embed_matcher)| -e '\\\\;\\1;r html/_parts/\\2' -e '\\\\;^ *\\1 *$;d' -e 's; *\\1 *;;'|"
# アップロード用ファイルの中で、ファイル埋め込み用のマークが
# 含まれている物を検索する。
egrep -r \
--include='*.html' \
"$embed_matcher" \
html_resolved |
cut -d ':' -f 1 | # ファイルパスだけを取り出す。
uniq | # 1ファイルの中で何カ所も見つかる事があるので、重複を取り除く。
while read path
do
# 見つかった各ファイルに対して処理を行う。
echo "updating $path"
# 埋め込みマークを埋め込み対象のファイルの内容で置き換えるためのsedのコマンド列を組み立てる。
# 1. 見つかったファイルの中にあるファイル埋め込み用のマークを抽出して(cat | egrep)、
# 2. 埋め込みマークをファイルの内容で置き換えるためのsedのコマンド列を組み立てて(sed)、
# 3. 最後に各行を区切っている改行文字を削除して1行に繋げる(tr)。
resolve_embedded_resources="sed $(cat "$path" |
egrep -o "$embed_matcher" |
$esed -e "$embed_mark_to_resolver" |
tr -d '\n')"
# 安全のためファイルを一旦リネームし、
mv "$path"{,.tmp}
# sedで一気に内容を解決して、リダイレクトでアップロード用ファイルの位置に書き出す。
# この時、組み立てたコマンド列にある文字列を括る「'」自体が
# パラメータの一部として渡されては困るので、
# evalを使ってコマンド列をシェルの記法で改めてパースさせる。
cat "$path.tmp" |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
eval "$embed_resolvers" \
> "$path"
# 最後に、テンポラリファイルを削除する。
rm "$path.tmp"
done
sed
のr
コマンドで/マッチングパターン/r ファイルのパス
と指定すると、そのパターンにマッチした行の後に指定ファイルの中身をそのままはめ込むということができる、というのがポイントです。これで指定ファイルの内容を埋め込んだ後、/マッチングパターン/d
で埋め込みマークだけの行を消して、さらにs/マッチングパターン//
で行中の埋め込みマークも消す、という風にして痕跡を消しています4。
この辺の詳しい事は別の記事にまとめたので、そちらも併せてご覧下さい。
また、sed
のr
はマッチ位置ではなくマッチした行と次の行の間に指定ファイルの内容を出力するため、これだけだと行の中にある埋め込みマークが期待通りに処理されません。そこで、$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g'
であらかじめ埋め込みマークの直後で強制的に改行するようにしています。
awk
を使えばもっとスマートにできるのかもしれませんが、awk
使ったら負けかなと思ってる5のでこんな感じになりました。
絶対パスを相対パスに「解決」する
埋め込み用の共通のパーツとして切り出した内容はどの階層のファイルに埋め込まれるか分からないので、共通パーツ内のリンクは相対パスではなく、/
から始まる絶対パスで書かないといけません。
しかし、この絶対パスはHTMLファイルがWebサーバからコンテンツとして返されたときにだけ有効な物なので、アップロード前の表示確認をしようと思ってHTMLファイルをブラウザで開くとリンク切れになってしまいます。
先に書いたようにWebサーバを立てたくなかったので、面倒さに目を瞑って今までは相対パスを書いていたのですが、埋め込み用の共通パーツには上記のような理由から相対パスを書けません。そこでこの際だからと、共通パーツの埋め込みと同時に絶対パスを相対パスに解決(って言っていいのか?)するようにしてみました。
...
# リンク先に絶対パスが書かれている箇所の検出用正規表現。
# そのまま残す部分を後方参照で取り出せるように`()`で囲っておく。
absoute_path_matcher="((href|src)=['\"])(/[^/])"
# 埋め込み用の共通パーツを埋め込む前に絶対パスを
# 解決してしまうとまずいので、共通パーツのあるディレクトリは
# 除外して検索するようにする。
egrep -r \
--include='*.html' \
--exclude-dir=_parts \
"$absoute_path_matcher|$embed_matcher" \
html_resolved |
cut -d ':' -f 1 |
uniq |
while read path
do
echo "updating $path"
# ファイルのパスから、最上位のディレクトリへの相対パスにするための
# 「../../」の部分を作る。
root="$(echo "$path" |
$esed -e 's;/[^/]*$;;' \
-e 's;[^/]+;..;g' \
-e 's;^\.\.;.;' \
-e 's;^\./\.\.;..;')"
# 同じ置換操作を後で2箇所で行うので、sedへ与える指示を
# ここで変数に格納しておく。
# (もし置換操作を修正するときはここだけ編集すれば済む)
updater="s;$absoute_path_matcher;\\1$root\\3;g"
resolve_embedded_resources="sed $(cat "$path" |
egrep -o "$embed_matcher" |
$esed -e "$embed_mark_to_resolver" |
tr -d '\n')"
mv "$path"{,.tmp}
# 最後の書き出し直前に絶対パスを解決する。
cat "$path.tmp" |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
eval "$embed_resolvers" |
$esed -e "$updater" \
> "$path"
rm "$path.tmp"
done
絶対パスを相対パスにするということは、言い換えると、そのファイルのパスからドキュメントルートまで到達するのに必要な数だけ../
を手前に付け足すということです。この../../
の部分を求めるために、grep
の結果から得たそのファイル自身のパスをsed
で以下のように加工しています。
-
-e 's;/[^/]*$;;'
→html_resolved/path/to/dir/file.html
の最後の部分を取り除いて、親ディレクトリまでのパスのpath/to/dir
にする。 -
-e 's;[^/]+;..;g'
→ 各パートを..
に置き換えて、../../../..
という文字列にする。 -
-e 's;^\.\.;.;'
→html_resolved
ディレクトリの分だけ余計に上位を指しているので、最初の..
を.
に置き換えて./../../..
という文字列にする。 -
-e 's;^\./\.\.;..;'
→ 先頭の./..
は冗長なので、../
にする。
3の手順は前述のファイル配置に依存してこうなっているので、違うファイル配置にする場合は適宜読み替えて下さい。
また、絶対パスが書かれた箇所を検出するための正規表現を ((href|src)=['"])(/[^/])
としていますが、これは以下のようなパターンを判別し分ける事を意図しています。
-
href="/path/to/file
(サーバ上に置いた時の絶対パスで参照しているパターン) →相対パスに解決する -
href="path/to/file
(すでに相対パスで参照しているパターン) →何もしない -
href="//hostname/path/to/file
(スキーマ部のhttp:
を省略したパターン) →何もしない
1番目と3番目を見分ける必要があるので、パスの最初の1文字の/
だけでなく、その次の2文字目まで含んだ正規表現としています。
変更が無かったファイルの処理をスキップする
ここまでの手順でアップロード前に共通パーツの埋め込みや絶対パスの解決を行うようにした結果、前処理にそこそこ手間がかかるようになってしまいました。毎回全ファイルに前処理を施しているとキリがありませんので、前処理を施す前のソースファイルに変更があったときだけ前処理をやり直すようにします。
先のスクリプトではアップロード用のファイルを毎回すべて用意し直していましたが、前回用意したファイルに対してrsync
で差分更新を行えば、ソースが更新されたファイルだけ前処理をやり直す事ができるはずです。
...
# rmで前のファイルを消してcpで全コピーする代わりに
# rsyncで必要なファイルだけコピーし直す
mkdir -p html_resolved
rsync --archive --update --delete ./html/ ./html_resolved/
...
rsync
はローカルとリモートの間でのミラーリングに使う事が多いですが、実はこんな風にローカルのファイル同士にも使えるのでした。
--archive
だけだと、ソースと前処理済みのファイルの最終更新日時が「違う」という理由で処理済みファイルが上書きされてしまいます。なので、--update
を明示的に指定して「処理済みファイルの方が新しい場合は何もしない」「処理済みファイルの方が古い場合は、ソースの方が更新されたという事で、前処理をやり直すためにコピーする」という動作になるようにしています。
また、ソースから消えたファイルをアップロード用ファイルから取り除くため--delete
も指定しています。
強制的に前処理をやり直すオプションの追加
先の手順で「変更があったファイルだけを対象に前処理を行う」ようになりました。しかし、前処理の仕方そのものに手を加えたり、埋め込み用のパーツの方に変更を加えた場合には、各ページのファイルの方には変更が無いため前処理がスキップされたままになってしまうという問題があります。一応、html_resolved
を削除すれば強制的に全ファイルに前処理をやり直せますが、画像などのファイルまでアップロードし直す事になってしまうので効率が悪いです。何か良い方法は無いでしょうか?
先程「--archive
だけだと、ソースと前処理済みのファイルの最終更新日時が『違う』という理由で処理済みファイルが上書きされてしまいます(なので--update
も指定する)」と書きました。という事は、--update
をrsync
に指定しなければ、ここで期待される「前処理済みのファイルだけをソースで上書きして、前処理をやり直す」という事が可能になります。
ということで、rm
等のコマンドで一般的な-f
(強制実行)オプションに倣って、この前処理用スクリプトにもgetopts
を使って-f
オプションを実装してみます。
...
# 既定の状態では、`--update`を指定する。
only_update_option='--update'
while getopts f OPT
do
case $OPT in
# `-f`が指定された場合、先の変数の値を空文字にする。
f) only_update_option='';;
esac
done
mkdir -p html_resolved
# `--update`を直接書く代わりに変数の値を使うようにする。
rsync --archive $only_update_option --delete ./html/ ./html_resolved/
...
もっと効率よくしたい場合には「変更があった埋め込み用パーツを参照しているソースファイルを検出する」という事をする必要があります。今の所は上記のやり方で必要十分なので、これ以上の事をするつもりはありませんが、興味のある方は挑戦してみてはいかがでしょうか。
まとめ
最終的に完成した前処理用のスクリプトとアップロード用のスクリプトは、以下の通りです。
#!/bin/bash
root_dir="$(cd "$(dirname "$0")" && pwd)"
cd $root_dir
case $(uname) in
Darwin|*BSD|CYGWIN*)
esed="sed -E"
;;
*)
esed="sed -r"
;;
esac
only_update_option='--update'
while getopts f OPT
do
case $OPT in
f) only_update_option='';;
esac
done
mkdir -p html_resolved
rsync --archive $only_update_option --delete ./html/ ./html_resolved/
embed_matcher='<!-- *EMBED\( *([^) ]+) *\) *-->'
embed_mark_to_resolver="s|($embed_matcher)| -e '\\\\;\\1;r html/_parts/\\2' -e '\\\\;^ *\\1 *$;d' -e 's; *\\1 *;;'|"
absoute_path_matcher="((href|src)=['\"])(/[^/])"
egrep -r \
--include='*.html' \
--exclude-dir=_parts \
"$absoute_path_matcher|$embed_matcher" \
html_resolved |
cut -d ':' -f 1 |
uniq |
while read path
do
echo "updating $path"
root="$(echo "$path" |
$esed -e 's;/[^/]*$;;' \
-e 's;[^/]+;..;g' \
-e 's;^\.\.;.;' \
-e 's;^\./\.\.;..;')"
updater="s;$absoute_path_matcher;\\1$root\\3;g"
resolve_embedded_resources="sed $(cat "$path" |
egrep -o "$embed_matcher" |
$esed -e "$embed_mark_to_resolver" |
tr -d '\n')"
mv "$path"{,.tmp}
cat "$path.tmp" |
$esed "s/($embed_matcher)( *[^$])/\1"\\$'\n''\3/g' |
eval "$embed_resolvers" |
$esed -e "$updater" \
> "$path"
rm "$path.tmp"
done
#!/bin/bash
root_dir="$(cd "$(dirname "$0")" && pwd)"
cd $root_dir
# アップロード前に必ず前処理をやり直す。
./build.sh
HOSTNAME=system-admin-gir.sakura.ne.jp
USERNAME=system-admin-gir
PASSWORD=**************
# ソースの方ではなく、前処理済みのファイルの方をアップロードする。
SOURCE=html_resolved
DIST=/home/system-admin-gir/www
lftp -d -e "\
set ftp:ssl-auth TLS; \
set ftp:ssl-force true; \
set ftp:ssl-allow yes; \
set ftp:ssl-protect-list yes; \
set ftp:ssl-protect-data yes; \
set ftp:ssl-protect-fxp yes; \
open -u $USERNAME,$PASSWORD $HOSTNAME ; \
mirror \
--reverse \
--delete \
--only-newer \
--verbose \
$SOURCE \
$DIST; \
exit"
ちゃんとしたCMSを使えばこういう事をわざわざ自分でやる必要も無いのですが、静的なページで済む物を月額129円でミニマムに公開するだけなら、こんな感じで管理の手間を軽減できますよ……という事例のご紹介なのでした。
「シス管系女子」について
最後にシス管系女子の事も改めてご紹介します。
自分は2011年から日経Linux誌上で「シス管系女子」というケーススタディ形式の解説マンガ記事を連載させて頂いています。
本編では主にSSH接続でLinuxのサーバーにリモートログインして操作する場面を想定して、各種コマンドの使い方を解説しています。またその延長線上として、それらのコマンドの実行手順をシェルスクリプトにして自動処理するという事にも挑戦しています。動作のイメージを絵で表現するように心がけていますので、この記事でやっているような「いろんな手法を組み合わせて、やりたい事を的確に実現する」という事をする上で有用な、丸暗記ではない・より深いレベルでの理解を得られるはずです。もし良かったら、お近くの大きめの書店でシス管系女子の本や掲載誌を手に取ってチラ見して頂けましたら幸いです。
それ以外にも、Twitterのみんとちゃんbotアカウントでイラスト6や本編に入りきらなかった小ネタを流したり、Webサイトの方にも連載や本では扱わなかったもっと基礎的な話の特別編を置いていたりします。あと、「シス管系女子」をテーマにしたAdvent Calendarも公開中です。
ということで、さくらのアドベントカレンダー(その2)の4日目でした。次の方の記事もお楽しみに!
-
自分では調べていませんが、スクリプト内で使用している
lftp
等のコマンドがHomebrew等でインストール可能なのであれば、macOS(OS X)でもこの方法をそのまま使えるかもしれません。 ↩ -
ドラッグ&ドロップでファイルをアップロードできなかったり、フォルダまるごと再帰的にアップロードできなかったり、果てはFirefoxの64bit版ではFlashプラグインとの相性が悪いのかエラーでファイルのアップロードすらできなかったり…… ↩
-
実際にそういう由来で名付けられたのかどうかまでは分かりませんでした。
rsync
の初版が公開された前後の時期にlftp
という名前になったというのは確かなようですが…… ↩ -
ただしこのままだとマッチングパターンの中にパス区切りの
/
があった時に破綻するので、それぞれのマッチングパターンを囲むのに/
以外の文字を使うように気をつけています。r
とd
については\;マッチングパターン;d
のような要領で明示的に;
を指定しています。 ↩ -
そのうち何でも
awk
でやるようになってしまう、というのが怖いのです……あと、シス管系女子の内容の実践という事も意識しているので、なるべく本編で解説した技術だけでやりたいという思いもありました。 ↩