ISUCON13の記録

今年も来ましたISUCONの季節。遅くなりましたがブログ記事となります。 いつもの頼れる後輩syusui、abcangとチーム7年目として出場し、全体12位 + 株式会社アークエッジ・スペース様の打ち上げ成功賞をいただきました! 株式会社アークエッジ・スペース様の打ち上げ成功賞はなんと衛星開発現場見学ツアーに参加させていただけるということで、かなり激アツな賞なのではないでしょうか。 開始前のスポンサー賞発表の時にこれ普通に行きてぇ~ってみんなで言ってたので本当にうれしいです。

isucon.net

今年は予選本戦の区分けがなくなりいきなり全員とバトルということで当初の目標はこれまでの予選突破ラインの30位以内としていましたが、これを無事達成しつつ賞までいただいてめでたい結果になったかなと思います。

大体の流れは abcang が書いてくれているのでそちらへのリンクを張っておくのと、僕のやったことを少しだけ書いておこうと思います。 abcang.hatenablog.com

リポジトリはこちら github.com

やったこと

最初の準備

準備されたマシンに置いてあるソースコード、publicファイル(フロントエンド)、SQLファイルなどをリポジトリに詰め込んでdeploy scriptをおきました。

pt-query-digestを見ながらスロークエリに対してインデックスを張る

シンプルですね。変に新しいツールを突然使うとほぼ間違いなく確実にしにます。 来年あるなら事前に準備してなんかモダンなやつを使ってみてもいいかもしれない。 performance_schema を使っている例も見ましたのでそのへんとか勉強して準備しておこうかなと思いました。

github.com github.com github.com

スロークエリをベースにテーブルの修正を行う

アクセスされるたびに、全テーブルなめて数を数えたり毎回ランキング生成をしている部分を事前に計算したり、情報が更新されるタイミングで計算しておいたりすることで毎回全部計算しなくてもいいようにしました。

github.com github.com github.com

SetMaxOpenConns

github.com

abcangくんの記事でも述べられていますが、何故かHTTPリクエストが全然来なくなってスコアが上がらないという現象が発生していました。 特にCPUが詰まっていて、アクセスが受けられていないわけでもなく、CPU idleはむしろ100ぐらいみたいな状況が続きかなり困った状態でした。 あと30分ぐらいしか時間がねえみたいな状況下でもうヤケクソじゃ!!!って値を変えたところめちゃくちゃスコアが上がりひっくり返りました。

SetMaxOpenConnsは初期実装のデフォルトで10が設定されており、DBコネクションが一瞬で埋まり、CPUとしては余裕がある状態にもかかわらず、 接続は受け付けたけどDBコネクションがidleになるまでまっているリクエストばかりになり、止まっているように見えたというオチでした。

自分で最初にこれは最後広げないとね~って言ってたんですが、うっかり忘れていたのでチームメンバーには大変なご迷惑をおかけしました。。。 今回設定した100という値もあと10分しかねぇみたいな状況下で、かなり適当で設定していたので、本当は何回かベンチ回したりして様子見しながら設定すればもっとコネクション開けたはずで、そうなるともっとスコアが上がったはずなので 大変悔しい思いがあります。仕事でも似たような調整したのに全くわかってなかったぜ。

おそらくではありますが、今どれだけのリクエストが来ていて、どれだけ処理しているのかみたいなメトリクスがあればもう少し気がつけたのかなと思います。 なんかさすがに処理できている量が少なくないか、とかアクセス自体は来ているなみたいなのがわかればここまでハマらずにすんだと思います。 次回以降の課題とさせていただきます。

まあこれにはまったおかげで急激に点数が伸び結果的に打ち上げ成功賞をいただけたので、ま、ええか!

終わりに

今年はちゃんと手を動かしていろんな改善を行えていた(例年何もわからんっていって手が止まったりしてた)ので全然点数上がらなかったときは大変大変苦しい時間を過ごしましたが、最終的にそれなりの点数になってよかったですね。 これも諦めずに全員で改修を積み重ねた結果だと思います。

毎年そろそろ作問大変そうだよなぁ~っていいながら毎年あーこうきたかみたいな驚きを与えてくれる運営陣には頭が上がりません。次回もまた期待しています!ありがとうございました。

最後終了30秒前に滑り込みで点数が出た様子

vscodeのruby extensionでsyntax highlightが死ぬ問題について

最近は仕事でVSCodeを使っているんだけど、Rubyの拡張 Ruby - Visual Studio Marketplace のアップデートの際に突然syntax highlightがぶっ壊れて仕事の効率が大幅ダウンしていたので、調べて直してみることにした。

issueを眺めていると問題のバージョン 0.16.0で問題があるらしくとりあえずダウングレードで対応しようぜってあったので対応した。

github.com

  1. 一度Ruby extensionをuninstallしてreload
  2. https://rebornix.gallery.vsassets.io/_apis/public/gallery/publisher/rebornix/extension/Ruby/0.15.0/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage ここから0.15.0をダウンロード (参考: https://code.visualstudio.com/docs/editor/extension-gallery#_common-questions )
  3. 落としてきたファイルの拡張子を VSIX に変更
  4. VSCodeのInstall Extension from VSIXをコマンドパレットから起動
  5. VSIXを選んでインストール

するとダウングレードできてなんとかもとに戻った。

react-resizable使った話

pgmot.hatenablog.com

これの続きです。

研究で色々あってreactを使う判断をしました。

研究ではAndroidのセンサログ情報やら開発者が実装した行動認識を行うクラスを可視化して想定通りに行動認識アルゴリズムが動作しているかの調査や、そもそもどういったセンサデータが用いれるかを調べられるWebベースのデバッグツールを作っていました。 本デバッグツールでは様々なセンサデータを可視化することとなるのですが、その際には、グラフなどを自由にサイズ変更できる必要があるかと思います。

そこで STRML/react-resizable: A simple React component that is resizable with a handle. を使いました。 名前の通りリサイズに対応したReactコンポーネントです。

インストール方法とインポート方法はREADMEを参照。

function onResize(e, {element, size}){
  this.setState({
    viewSize: size
  });
}

<ResizableBox width={this.state.viewSize.width} height={this.state.viewSize.height} onResize={onResize}>
  <div>
     Resizable
   </div>
</ResizableBox>

簡単ですね。

ただリサイズできるようにするためのハンドルも設定しないといけません。

    .react-resizable {
      position: relative;
    }
    .react-resizable-handle {
      position: absolute;
      width: 20px;
      height: 20px;
      bottom: 0;
      right: 0;
      background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/Pg08IS0tIEdlbmVyYXRvcjogQWRvYmUgRmlyZXdvcmtzIENTNiwgRXhwb3J0IFNWRyBFeHRlbnNpb24gYnkgQWFyb24gQmVhbGwgKGh0dHA6Ly9maXJld29ya3MuYWJlYWxsLmNvbSkgLiBWZXJzaW9uOiAwLjYuMSAgLS0+DTwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DTxzdmcgaWQ9IlVudGl0bGVkLVBhZ2UlMjAxIiB2aWV3Qm94PSIwIDAgNiA2IiBzdHlsZT0iYmFja2dyb3VuZC1jb2xvcjojZmZmZmZmMDAiIHZlcnNpb249IjEuMSINCXhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiDQl4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjZweCIgaGVpZ2h0PSI2cHgiDT4NCTxnIG9wYWNpdHk9IjAuMzAyIj4NCQk8cGF0aCBkPSJNIDYgNiBMIDAgNiBMIDAgNC4yIEwgNCA0LjIgTCA0LjIgNC4yIEwgNC4yIDAgTCA2IDAgTCA2IDYgTCA2IDYgWiIgZmlsbD0iIzAwMDAwMCIvPg0JPC9nPg08L3N2Zz4=');
      background-position: bottom right;
      padding: 0 3px 3px 0;
      background-repeat: no-repeat;
      background-origin: content-box;
      box-sizing: border-box;
      cursor: se-resize;
    }

cssにこれを書く必要があります。backgroundには 型の画像が仕込まれていてここをドラッグすることでリサイズできるような仕組みです。

これもreact-draggableのときと同じようにコンポーネントのサイズを保持したい場合はlocalStorageなどを活用すればよいでしょう。

this.state = {
  viewSize: {
    width: parseInt(localStorage.getItem("width"), 10) || 400,
    height: parseInt(localStorage.getItem("height"), 10) || 400,
  }
};

function onResize(e, {element, size}){
  this.setState({
    viewSize: size
  });

  localStorage.setItem(`width`, size.width);
  localStorage.setItem(`height`, size.height);
}

<ResizableBox width={this.state.viewSize.width} height={this.state.viewSize.height} onResize={onResize}>
  <div>
     Resizable
   </div>
</ResizableBox>

さらにreact-draggableと連携すればドラッグ可能でリサイズ可能なウインドウっぽい何かが完成します。

// constructorなどで実行
this.state = {
  componentPosition: {
    x: parseInt(localStorage.getItem(`position_x`), 10) || 0,
    y: parseInt(localStorage.getItem(`position_y`), 10) || 0
  },
  viewSize: {
    width: parseInt(localStorage.getItem("width"), 10) || 400,
    height: parseInt(localStorage.getItem("height"), 10) || 400,
  }
};

function onDrag(e, position){
  const {x, y} = position;
  this.setState({
    position: {x, y}
  });
}

function onStop(e, position){
  const {x, y} = position;

  localStorage.setItem("position_x", x);
  localStorage.setItem("position_y", y)
}

function onResize(e, {element, size}){
  this.setState({
    viewSize: size
  });

  localStorage.setItem(`width`, size.width);
  localStorage.setItem(`height`, size.height);
}

<Draggable handle="handle" position={this.state.componentPositon} onDrag={onDrag} onStop={onStop}>
  <div className="handle">
    ここをドラッグ!
  </div>
  <ResizableBox width={this.state.viewSize.width} height={this.state.viewSize.height} onResize={onResize}>
    <div>
       Resizable
     </div>
  </ResizableBox>
</Draggable>

こういうことをしてなんとか修論を乗り越えました。裏ではWebsocketやらReduxやら色々使っているので元気が出たらそのあたりも頑張って記事にします。

golangのparseライブラリで別サーバに向ける方法

我らがParse.comさんが本格的にサービス終了されました。それにより前に作ったinstall数集計golangアプリが動作しなくなりました。

pgmot.hatenablog.com

今回の環境ではOSSのparse-serverへの移行を成功させているので、回避策としては単純なもので、向き先サーバを変えるだけだったけどろくなドキュメントがなくて大変だったので書いときます。

結論

まず結論です。最初のParse Clientの作成部分は以下のようになっています。

// Client インストール数問い合わせのクライアント
type Client struct {
    parseClient parse.Client
}

// NewClient Client生成
func NewClient(applicationID string, restAPIKey string) *Client {
    return &Client{
        parseClient: parse.Client{
            Credentials: parse.RestAPIKey{
                ApplicationID: applicationID,
                RestAPIKey:    restAPIKey,
            },
        },
    }
}

client := parseclient.NewClient(defaultApplicationID, defaultRestAPIKey)

それをこうすれば他の実装を変えなくてもいいです。

// Client インストール数問い合わせのクライアント
type Client struct {
    parseClient parse.Client
}

// NewClient Client生成
func NewClient(applicationID string, restAPIKey string) *Client {
    return &Client{
        parseClient: parse.Client{
            Transport: &http.Transport{
                TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
            },
            Credentials: parse.RestAPIKey{
                ApplicationID: applicationID,
                RestAPIKey:    restAPIKey,
            },
            BaseURL: &url.URL{
                Scheme: "https",
                Host:   "parse.server.example.com",
                Path:   "",
            },
        },
    }
}

client := parseclient.NewClient(defaultApplicationID, defaultRestAPIKey)

Transport についてですが、今回建てたparse-sever側のSSL証明書をLet’s encryptにしていましてその証明書に対応しておらず認証失敗となるので、SSLの確認を回避することでとりあえずの回避となっています。これはちゃんとした鍵を置くとかすれば消してもいいでしょう(未検証)。

ソースレベルのお話

https://github.com/facebookgo/parse/blob/master/parse.go

アクセス先のサーバを決めているのは、この https://github.com/facebookgo/parse/blob/master/parse.go#L198 RoundTrip関数

func (c *Client) RoundTrip(req *http.Request) (*http.Response, error) {
    req.Proto = "HTTP/1.1"
    req.ProtoMajor = 1
    req.ProtoMinor = 1

    if req.URL == nil {
        if c.BaseURL == nil {
            req.URL = &defaultBaseURL
        } else {
            req.URL = c.BaseURL
        }
    } else {
        if !req.URL.IsAbs() {
            if c.BaseURL == nil {
                req.URL = defaultBaseURL.ResolveReference(req.URL)
            } else {
                req.URL = c.BaseURL.ResolveReference(req.URL)
            }
        }
    }

    if req.Host == "" {
        req.Host = req.URL.Host
    }

    if req.Header == nil {
        req.Header = make(http.Header)
    }

    var userAgent string
    if c.UserAgent == "" {
        userAgent = defaultUserAgent
    } else {
        userAgent = c.UserAgent
    }

    req.Header.Add(userAgentHeader, userAgent)
    if c.Credentials != nil {
        if err := c.Credentials.Modify(req); err != nil {
            return nil, err
        }
    }

    res, err := c.transport().RoundTrip(req)
    if err != nil {
        return res, err
    }

    if res.StatusCode > 399 || res.StatusCode < 200 {
        body, err := ioutil.ReadAll(res.Body)
        res.Body.Close()
        if err != nil {
            return res, err
        }

        if len(body) > 0 {
            var apiErr Error
            if json.Unmarshal(body, &apiErr) == nil {
                return res, &apiErr
            }
        }
        return res, &RawError{
            StatusCode: res.StatusCode,
            Body:       body,
        }
    }

    return res, nil
}

最初の部分だけ見れば良い

   if req.URL == nil {
        if c.BaseURL == nil {
            req.URL = &defaultBaseURL
        } else {
            req.URL = c.BaseURL
        }
    } else {
        if !req.URL.IsAbs() {
            if c.BaseURL == nil {
                req.URL = defaultBaseURL.ResolveReference(req.URL)
            } else {
                req.URL = c.BaseURL.ResolveReference(req.URL)
            }
        }
    }

req.URLGetPost などを呼び出す際に渡した http.Request なので基本的にはnilになるはず。 でcことclientのBaseURLの存在を見ています。

defaultBaseURL

   defaultBaseURL = url.URL{
        Scheme: "https",
        Host:   "api.parse.com",
        Path:   "/1/",
    }

こうなっているので同じような構造のものをクライアントに渡せば良いことがわかります。

まとめ

自前のparse-serverではlimitに制限がないのでガンガンAPI Callできていいですね。

golangで書いたスクリプトまとめ

Golangの勉強で書いたコードについて挙げます。

pgmot/imagine

これは画像アップローダーです。短いコードなのでガッと載せます。

package main

import (
    "crypto/rand"
    "encoding/base64"
    "io"
    "log"
    "net/http"
    "os"
    "path/filepath"
    "strings"

    "github.com/gin-gonic/gin"
)

func main() {
    port := os.Getenv("PORT")

    if port == "" {
        log.Fatal("$PORT must be set")
    }

    router := gin.Default()

    router.Static("/", "./images")
    router.POST("/upload", func(c *gin.Context) {
        file, header, err := c.Request.FormFile("image")
        fileExt := filepath.Ext(header.Filename)

        b := make([]byte, 40)
        rand.Read(b)
        randomName := base64.URLEncoding.EncodeToString(b)
        out, err := os.Create("./images/" + randomName + strings.ToLower(fileExt))

        if err != nil {
            log.Print(err)
            c.Err()
        }
        defer out.Close()

        _, err = io.Copy(out, file)
        if err != nil {
            log.Print(err)
            c.Err()
        }

        c.String(http.StatusOK, randomName+strings.ToLower(fileExt))
    })

    router.Run(":" + port)
}

まず、 router には2つのエンドポイントが設定されます。一つは /upload です。これは名前の通りPOSTで画像を受けて実行しているサーバの ./images/ 以下に適当なハッシュをつけて保存するエンドポイントです。 画像を見たいときはおもむろに /<hash>でアクセスすると見れます。ここはStaticとして設定をして実現をしていますが、サーバが画像を返すのは現実的によろしくないので、今後の課題とさせていただきます。

pgmot/hanasu

これはwebsocketでいい感じにチャットできるgolang webアプリです。 実装は、

Go言語によるWebアプリケーション開発

Go言語によるWebアプリケーション開発

これをパクって参考にしてやりました。

pgmot/dokokara

これはアクセス元IPを出すgolang webアプリです。 VPNなどを構築した後に本当にVPN越しに行けているのかの確認用に作りましたが、本当は 診断くん でなんとかしています。

package main

import (
    "fmt"
    "log"
    "net"
    "net/http"
    "os"
)

func doko(w http.ResponseWriter, r *http.Request) {
    var ip string

    ip = r.Header.Get("X-Forwarded-For")
    if ip == "" {
        var err error
        ip, _, err = net.SplitHostPort(r.RemoteAddr)
        if err != nil {
            log.Println("SplitHostPort error: ", err)
            fmt.Fprint(w, "IP: ???\nHost: ???")
            return
        }
    }
    log.Println("IP: ", ip)

    host, err := net.LookupAddr(ip)
    if err != nil {
        log.Println("LookupAddr error: ", err)
        fmt.Fprintf(w, "IP: %s\nHost: ???", ip)
        return
    }
    log.Println("Host: ", host)

    fmt.Fprintf(w, "IP: %s\nHost: %s", ip, host[0])
}

func main() {
    port := os.Getenv("PORT")

    if port == "" {
        log.Fatal("$PORT must be set")
    }

    http.HandleFunc("/", doko)
    err := http.ListenAndServe(":"+port, nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

func doko 内でアクセス元の情報をもとに返してあげる実装になっています。簡単でした。

まとめと感想

Webアプリをガッツリ書くよりもAPIのためとかコマンドとして書くみたいな方が書きやすかった。一時期RailsみたいなフルスタックWAFとして使おうとしたけどなんか違う気がする。

ssh設定

sshはすごい便利ですね。中でも気に入っているのが .ssh/config です。一番好きな設定ファイルです。

知らない人に便利さを伝えるときにここを見ろとするために書きます。

普通のログイン

host1.example.com にアクセスしたいときを想定します。

$ ssh <ユーザ名>@host1.example.com

初回の接続時には、以下のような確認メッセージが出ます。

The authenticity of host 'host1.example.com' can't be established.
RSA key fingerprint is xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx.
Are you sure you want to continue connecting (yes/no)?

yes とすれば良いです。この確認メッセージはサーバの秘密鍵フィンガープリントになるのですが、もしDNSキャッシュポイズニングなどで接続先などが書き換えられて悪意のあるサーバにアクセスさせられかけたときに、あれ?フィンガープリント変わってるけど大丈夫???って聞いてくれる仕組みです。

次にパスワードが問い合わされるので答えてあげましょう。 認証が通れば晴れてログインです。

鍵認証

毎回パスワードを入力するのはセキュリティリスクが高くあまり推奨されません、それに面倒ですね。 そこで一般的には公開鍵認証というのが用いられます。公開鍵暗号の仕組みそのものはwikiに説明を譲ります。

まずは自分の秘密鍵と公開鍵を生成する必要があります。

$ ssh-keygen

すると

Generating public/private rsa key pair.
Enter file in which to save the key (/home/you/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/you/.ssh/id_rsa.
Your public key has been saved in /home/you/.ssh/id_rsa.pub.
The key fingerprint is:
xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx you@your_host

このように出ます。最初の Enter file in which to save the keyでは鍵の位置が聞かれていますが、デフォルトでいいでしょう。 次の Enter passphrase (empty for no passphrase)Enter same passphrase again では秘密鍵に対するパスワードを問われています。 秘密鍵は名の通り秘密にしなくては行けない鍵で、万が一漏れた際には登録しているサーバに問答無用で入られてしまうので鍵をかけたほうがセキュリティは安全です。 が、面倒な場合はかけなくても良いです。そうした場合は鍵の取扱には十分注意してください。

このコマンドのあと ~/.ssh/id_rsa~/.ssh/id_rsa.pub というファイルが生成されています。 ~/.ssh/id_rsa秘密鍵~/.ssh/id_rsa.pub が公開鍵となります。

サーバに公開鍵を配置すると、自動的に公開鍵認証が用いられます。 サーバサイドの ~/.ssh/authorized_keys に公開鍵を追加します。

$ mv id_rsa.pub .ssh # id_rsa.pubはコピーしてきた公開鍵
$ cd .ssh
$ cat id_rsa.pub >> authorized_keys

.ssh/config

~/.ssh/config というファイルに設定を書くことで接続先に名前をつけたり、接続先固有の設定などができたりします。

例えば上の例をもとにすると、

 Host host1
     HostName host1.example.com
     User <ユーザ名>

こうすると、 ssh host1 だけで、User名とHostNameが設定されます。便利。

以下追記していく

react-draggable使った話

研究で色々あってreactを使う判断をしました。

研究ではAndroidのセンサログ情報やら開発者が実装した行動認識を行うクラスを可視化して想定通りに行動認識アルゴリズムが動作しているかの調査や、そもそもどういったセンサデータが用いれるかを調べられるWebベースのデバッグツールを作っていました。 本デバッグツールでは様々なセンサデータを可視化することとなるのですが、その際には、ツール利用者が自由にグラフ配置できるような仕組みが求められるかと思います。 なにぶん、昨今Androidのセンサは増えていますし、行動認識と一口に言っても様々なパラメータや計算過程の値が跋扈するわけです、大量のグラフを並べたいように並べる仕組みが必要かと思います。

そこで mzabriskie/react-draggable: React draggable component というライブラリを使いました。 名前の通りドラッグ可能にする便利コンポーネントです。

インストール方法と設定については公式READMEをご覧ください。

ドラッグ可能にする

ドラッグ可能にしたい要素を Draggable コンポーネントで囲います。

<Draggable>
  <div>
    ドラッグできるよ
  </div>
</Draggable>

これで div 要素はドラッグ可能になりました。簡単ですね。Reactのコンポーネントでも同様にできます。

ウインドウっぽくしたい

現状の実装だと要素内のどこをドラッグしてもドラッグされます。できればウインドウのUIっぽく上の方だけクリックしたら移動みたいな実装にしたいですね。 それには handle 属性を利用すれば良いです。 handle で指定したclassの要素をドラッグすることで移動ができるようになります。

<Draggable handle="handle">
  <div className="handle">
    ここをドラッグ!
  </div>
  こっちではドラッグできないよ
</Draggable>

位置を保存したい

今回のツールはブラウザベースで開発しているのですがリロードのたびに配置が変わるのは好ましくありません。 そこで位置を保存して再読込の際にはロードされるようにしましょう。

// constructorなどで実行
this.state = {
  componentPosition: {
    x: parseInt(localStorage.getItem(`position_x`), 10) || 0,
    y: parseInt(localStorage.getItem(`position_y`), 10) || 0
  }
};

function onDrag(e, position){
  const {x, y} = position;
  this.setState({
    position: {x, y}
  });
}

function onStop(e, position){
  const {x, y} = position;

  localStorage.setItem("position_x", x);
  localStorage.setItem("position_y", y)
}

<Draggable handle="handle" position={this.state.positon} onDrag={onDrag} onStop={onStop}>
  <div className="handle">
    ここをドラッグ!
  </div>
  こっちではドラッグできないよ
</Draggable>

こうすると、初回は position 属性によって位置が決められる。ドラッグ時には onDrag が呼び出され、 state.position が更新される。 ドラッグ停止時には onStop が呼び出され localStorage への保存が行われる。