Nimで作ったWebアプリ(個人制作)を1年運用した

# まえがき

2019年のNimアドベントカレンダーで以下の記事を投稿しました。
[NimでWebアプリ作って公開した](https://qiita.com/jiro4989/items/144b15ced0a57426ba5b)

あれから1年間、更新頻度はかなり落ちましたけれど、なんだかんだ現在も保守運用しています。
1年間運用した所感と変わった箇所を書きます。

# サイト

Webの画面はこちら
https://websh.jiro4989.com/

# ソースコード

https://github.com/jiro4989/websh

インフラのコードは非公開にしています。

# 開発環境

- Nim 1.4.0
- Ubuntu 18.04

# アプリ

## 画像ファイルをアップロード可能に

元になってるのはシェル芸botというTwitterボットで、そちらでは画像ファイルをアップロードしてシェル芸botのコンテナ内に取り込む事が可能でした。

それに合わせて画像を取り込めるようにしました。

## Twitterへ投稿する時のタグを切替可能に

フロントエンドにはKaraxを採用しています。
以下はフロントエンドのKaraxのコードです。

select要素のブロック内のonchangeでhashTagを更新するようにし、
optionをループで作るようにしたらセレクトボックスの実装は終わりです。

あとはTweetボタンのURLにhashTagをパーセントエンコーディングして埋め込んでタグ切り替えの実装は完了です。
思いの外簡単に実装できた記憶です。

```nim
tdiv(class = "buttons"):
  button(class="button is-primary", onclick = sendShellButtonOnClick):
    text "Run (Ctrl + Enter)"
  a(href = &"""https://twitter.com/intent/tweet?hashtags={encodeUrl($hashTag, false)}&text={encodeUrl($inputShell, false)}&ref_src=twsrc%5Etfw""",
      class = "button twitter-share-button is-link",
      `data-show-count` = "false"):
    text "Tweet"
  tdiv(class = "select"):
    select:
      proc onchange(ev: Event, n: VNode) =
        hashTag = $n.value
      for elem in @[cstring"シェル芸", cstring"shellgei", cstring"ゆるシェル", cstring"危険シェル芸", ]:
        option(value = $elem):
          text $elem

```

## 実行したシェルの履歴をLocalStorageに保存するように

20件まで過去に実行したシェルの履歴を表示できるようにしました。
履歴はLocalStorageに保持するようにしています。

LocalStorageはKaraxがサポートしていたので、 `import karax/localstorage` でアクセス可能です。

コードとしては以下です。LocalStorageにJSON形式でデータを保存してるだけです。

```nim
# LocalStorageからの取得部分

# localstorageにシェルの履歴が存在するときだけ取得
if localstorage.hasItem("history"):
  let hist = localstorage.getItem("history").`$`.parseJson.to(seq[string])
  shellHistory.add(hist)

# LocalStorageへの保存部分

proc sendShellButtonOnClick(ev: Event, n: VNode) =
  ## Tweetボタンを押した時に呼び出されるプロシージャ

  # 省略

  localStorage.setItem("history", shellHistory.mapIt(cstring(it)).toJson)
```

## アプリビルド時のタグとコミットハッシュをHTMLに埋め込む

画面上のソースのコミットハッシュを見えるようにしたかったのでビルド時に埋め込めるようにしました。
これはJSビルドでも可能です。

Nimでは以下のように`strdefine`プラグマを付けることでビルド時に値を埋め込む事が可能です。
`intdefine`でint型を、`booldefine`でbool型を埋め込むことも可能です。

```nim
# コンパイル時に値を埋め込む
const tag {.strdefine.} = "v0.0.0"
const revision {.strdefine.} = "develop"
```

上記プラグマを設定した状態で、ビルド時にオプションを渡すことで埋め込み完了です。
`nimble build -d:<変数名>:<値>` という書き方で埋め込みます。

リリースはCIで自動化しているので、以下のGitHubActionsのステップ内で埋め込んでビルドするようにしました。

```yaml
build-artifact:
  runs-on: ubuntu-latest
  needs: before
  steps:
    - uses: actions/checkout@v2

    - name: Build assets
      run: |
        docker build --target base -t base .
        docker run --rm -v $PWD/websh_front:/work -t base \
          nimble build -Y \
                       "-d:tag:${GITHUB_REF/refs?heads?}" \
                       "-d:revision:$GITHUB_SHA"

```

## Docker APIを使うように

後述するコンテナ化に伴って、DockerAPIを使ってシェル芸botコンテナを操作するようにしました。

初期はシェル芸botのコンテナを`docker`コマンドを呼び出して操作していたのですが、
コンテナ化をするとコンテナ内からホストのコンテナを操作するためにDocker in Docker構成にする必要がでてきました。

Docker in Docker構成はdindイメージを使う必要があります。
dindイメージをベースにNimコンパイラをインストールするようにしてもよかったのですが、
Nimコンパイラのバージョン管理が面倒になる可能性がありました。

よってDocker APIを使うようにしてAPIリクエストでコンテナを操作できるようにし、ベースはNim公式のDockerイメージのままで動くようにしました。

APIリクエストでDockerを操作できるようにするには、Dockerの設定を変更する必要があります。
以下のようにsystemdで起動するdockerの起動設定を変更しました。

これはローカルで開発する時にも必要になるので、ここだけは仕方ない手間と許容しました。

```/lib/systemd/system/docker.service
# ここを
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

# こう修正
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:2376 -H fd:// --containerd=/run/containerd/containerd.sock
```

また、NimでDockerをAPIで操作するライブラリで現在も保守されているものはありませんでした。
しかなたくDocker APIのAPI仕様書を参考に、必要なAPIだけ自前で実装しました。

https://github.com/jiro4989/websh/blob/master/websh_server/src/dockerclient.nim

stdout, stderrのログを取得する部分の実装がダルかったです。
普通にstringで読み取っても使えないので、自前でパースしています。

```nim
proc parseLog(s: string): string =
  var strm = newStringStream(s)
  defer: strm.close
  var lines: seq[string]
  while not strm.atEnd:
    # 1 = stdout, 2 = stderr
    if strm.readUint8() notin [1'u8, 2]:
      break

    # 3byteは使わないので捨てる
    discard strm.readUint8()
    discard strm.readUint8()
    discard strm.readUint8()

    # Bigendianで読み取る
    var src = strm.readUint32().int
    var n: int
    bigEndian32(addr(n), addr(src))

    lines.add(strm.readStr(n))
  result = lines.join
```

# インフラ

## コンテナ化

もともとはビルドしたバイナリをサーバ上にアップロードしてサーバ上で直接実行していました。
コンテナ化したくなったのでコンテナ化してdocker-composeで動かすようにしています。

もともとコンテナで動作させる作りになってなかったので、かなり大改修になりました。
https://github.com/jiro4989/websh/pull/173

コンテナ化する以前はローカルで環境を起動するのに色々環境構築が必要で、サーバを起動する順序もあって色々面倒だったのですが、コンテナ化が完了してから`docker-compose up`だけで環境が起動するようになったのでだいぶ楽になりました。

## Prometheus + Grafanaでサービス監視

アドベントカレンダーを書いた時点ではサービス監視をほとんどやっていなかったのですが、
Prometheus + Grafana + Grafana Lokiでサービス監視をするようにしました。
今は大した監視入れてないですが、安心感はあります。

## ログのJSON化

ログの監視にはGrafana + Grafana Lokiを使っています。
ログのパースを自分で頑張って作りたくなかったので、標準で提供されているJSONパーサを使えるように、ログの書式をJSON形式に変更しました。
アプリケーション側に改修をいれて対応しました。

普通に`json`モジュールを使ってJSONに変換して標準出力に垂れ流しているだけです。
ロガーとして切り出しても良いだろうな、とは思いつつ面倒なのでやっていないです。

```nim
    const
      # #158 JSONのキーにハイフンを含めるべきでないのでlowerCamelCaseにする
      xForHeader = "xForwardedFor"
    let
      now = now()
      uuid = $genUUID()
      xFor = request.headers().getOrDefault("X-Forwarded-for")
    try:
      var respJson = request.body().parseJson().to(ReqShellgeiJSON)

      # 一連の処理開始のログ
      echo %*{xForHeader: xFor, "time": $now(), "level": "info", "uuid": uuid, "code": respJson.code, "msg": "request begin"}
```

## ansible-galaxyを使ってミドルウェアのインストール設定記述をしないですむように

以前はミドルウェアのインストール設定を自分で書いていたのですが、
ansible-galaxyを使って3rd partyのAnsibleのロールを使うようにしました。

これでPrometheus用のExporterやLoki関連のミドルウェアのインストール設定を全く書かなくてよくなりました。

## サービス管理をsupervisorからsystemdに統一

もともとアプリの管理はsupervisorを使っていたのですが、すべてsystemdに統一しました。
以下の理由からです。

- ansible-galaxyを使いたかった
    - 3rd partyのansible galaxyを利用する場合、大多数がsystemdでサービスを起動するように作られている
- systemd-journalでログを収集したかった
    - Grafana Lokiにログを流すとき手段がいくつかありました
    - 手段
        - fluentdでログを拾ってpromtailに流す
        - systemd-journalからpromtailに流す
    - このときsystemd-journalからログを拾う場合だとfluentdをサーバ上に追加でインストールする必要がなく、管理が楽になると判断しました
        - また、後述するLoki上でデータを分析するためのリラベリングもやりやすかったため

## Grafana Lokiでログを可視化してフィルタリングできるように

Grafana Lokiの画面でログをフィルタリングするには、ログのラベリングが必要でした。

ログをラベリングする方法は色々あるのですが、JSON形式のログでsystemd-journalから転送されたログの場合はPromtailでのラベリング設定の記述がかなり楽になります。

以下がPromtailのscrape_configsです。


```yml

scrape_configs:
  - job_name: journal
    journal:
      max_age: 12h
      labels:
        job: systemd-journal
    relabel_configs:
      - source_labels: ['__journal__systemd_unit']
        target_label: 'unit'
      # session-* というラベルが別で割り振られるのがうざいので
      - source_labels: ['__journal__systemd_unit']
        target_label: 'unit'
        regex: '^(session).*(scope)$'
        action: replace
        replacement: session.scope

    pipeline_stages:
      - match:
          selector: '{unit="websh.service"}'
          stages:
            - regex:
                expression: |-
                  (?P<service>websh_[^\s]+)\s+\|\s+(?P<content>.*)$
            - json:
                expressions:
                  ip: xForwardedFor
                  time: time
                  level: level
                  code: code
                  msg: msg
                  elapsed_time: elapsedTime
                source: content
            - labels:
                service: service
                ip: ''
                time: ''
                level: ''
                code: ''
                msg: ''
                elapsed_time: ''
```

systemd-journalから転送されたログの場合、`source_labels`で`__journal__systemd_unit`という指定で絞り込みが可能です。
また、systemdで起動するサービス名が`unit`ラベルに付与されるので、pipeline_stagesのmatch selectorの指定にも使えて楽でした。

`docker-compose`でサービスを起動して、docker-composeの標準出力を拾う都合上、docker-composeがログの出力に必ずプレフィックスをつけてしまいます。

そこはpromtailのregex expressionでパースして除外するようにしました。
結果的には以下のような記述でログのラベリングができました。だいぶ楽です。

# 感想

ちょこちょこアプリもインフラも手を入れていますが、現状そこまで不便なく改修、運用できています。

Nimについては以前致命的なバグがあって仕方なくdevelブランチの最新ソースを落としてきてCIでコンパイラをビルドしたりしていたのですが、Nim 1.4.0になって不具合も解消し、現在は特に問題もなくアプリが動いています。

今後も保守は続けると思いますが、どうなるかはわからないです。

今やりたいこととしてはwebsh全体をLXDで仮想化してスナップショットを取れるようにしたいなぁ、とか考えています。たまにインフラ側の変更でやらかして前に戻すのにドタバタしたりするので、仮想化してサッと切り戻せるようにしたいなぁ、と。

このあたりAWS使えるならインスタンスのスナップショットを取るとかで対応できるので、AWS使えるならAWS使いたいなぁ、とか思ったりしています。

以上です。