まえがき
2019年のNimアドベントカレンダーで以下の記事を投稿しました。
NimでWebアプリ作って公開した
あれから1年間、更新頻度はかなり落ちましたけれど、なんだかんだ現在も保守運用しています。
1年間運用した所感と変わった箇所を書きます。
サイト
Webの画面はこちら
https://websh.jiro4989.com/
ソースコード
インフラのコードは非公開にしています。
開発環境
- Nim 1.4.0
- Ubuntu 18.04
アプリ
画像ファイルをアップロード可能に
元になってるのはシェル芸botというTwitterボットで、そちらでは画像ファイルをアップロードしてシェル芸botのコンテナ内に取り込む事が可能でした。
それに合わせて画像を取り込めるようにしました。
Twitterへ投稿する時のタグを切替可能に
フロントエンドにはKaraxを採用しています。
以下はフロントエンドのKaraxのコードです。
select要素のブロック内のonchangeでhashTagを更新するようにし、
optionをループで作るようにしたらセレクトボックスの実装は終わりです。
あとはTweetボタンのURLにhashTagをパーセントエンコーディングして埋め込んでタグ切り替えの実装は完了です。
思いの外簡単に実装できた記憶です。
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形式でデータを保存してるだけです。
# 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型を埋め込むことも可能です。
# コンパイル時に値を埋め込む
const tag {.strdefine.} = "v0.0.0"
const revision {.strdefine.} = "develop"
上記プラグマを設定した状態で、ビルド時にオプションを渡すことで埋め込み完了です。
nimble build -d:<変数名>:<値>
という書き方で埋め込みます。
リリースはCIで自動化しているので、以下のGitHubActionsのステップ内で埋め込んでビルドするようにしました。
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の起動設定を変更しました。
これはローカルで開発する時にも必要になるので、ここだけは仕方ない手間と許容しました。
# ここを
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だけ自前で実装しました。
stdout, stderrのログを取得する部分の実装がダルかったです。
普通にstringで読み取っても使えないので、自前でパースしています。
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に変換して標準出力に垂れ流しているだけです。
ロガーとして切り出しても良いだろうな、とは思いつつ面倒なのでやっていないです。
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です。
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使いたいなぁ、とか思ったりしています。
以上です。