はじめに
どうも、Shakkuです。
都内某高専情報科の4年生です。(2024/8/30時点)
今回は、スライドを使用するイベントや講演会、ハンズオンなどで使用する便利なサポートツールを作成しました。
また、初めてOSSとしての公開をしてみました。(OSSの定義がちょっと曖昧なため、ちゃんとできているか不安ですが、多分みなさんの環境でも使えるはずです...)
本アプリケーションについて、自由に使用・修正・拡張していただいて問題ありません。使用した際の感想や問題点改善点などがありましたら、お気軽にご連絡ください。(再配布に関しては一度ご連絡いただけると幸いです。)
経緯
私の通っている高専では定期的に「ICT基礎Lab. for Junior」という中学生向けの技術イベントを学生主体で開催しています。私もそのイベントの運営や講師として当日や事前準備に参加しています。
そんな中で運営している際に以下のような問題があり、私も他の運営の学生もなんとかしないとなーという状況でした。
-
正面のスクリーンやディスプレイにスライドを映しているため、作業遅めの参加者の進捗を伺いながら進行する必要がある
-
参加者によって、詳しくてサクサク進められる人もいれば人差し指でキーボードをぽちぽちしている人もいた
-
スライドを見返すことができないため、遅れをとった時や見返したい時にさかのぼれない
-
スライドを参加者の PC に置いておくと、サクサク進める人が先の資料をカンニングしてしまう
-
長いコマンドやプログラムをスライド見ながら入力してもらうのは大変
-
事前にコピペ用のテキストファイルを参加者用のPCに仕込んでおくのも毎回やるのは大変
そこで、自分自身の「作ってみるか〜」という気持ちと他の運営学生からの「作ってよー」という依頼があったため、これらを解決するアプリケーションを作ってみました。
また、どうせやるならちゃんと公開したいなーと思ったので、DockerHub と GitHub で初めての OSS 公開をしてみました。以下のURLで公開しています。
- DockerHub:https://hub.docker.com/r/shakku/kiso-lab-support-tool
- GitHub:https://github.com/Shakkuuu/kiso-lab-support-tool
概要
イベントや講演会、ハンズオンを行う際に便利なアプリケーション。
スライドや PDF の資料を使用する際に、参加者の手元で資料を見ることができ、前ページを見返すことや、スライドの進行状況に応じて先のページのネタバレやカンニングが起きないように資料を共有することができる。
資料を見ながらの手入力が大変な文字列(コマンドやプログラムなど)を参加者に入力させたいとき、メッセージ機能で管理者から送信することで、参加者はコピー&ペーストをすることができる。また、管理者からの連絡や補足、資料訂正などさまざまな用途で使用できる。
管理者が資料の進行状況を更新したりメッセージを送信すると、それぞれ自動的にリアルタイムでクライアント側にも反映される。
OSS として DockerHub と GitHub にて公開している。
使用方法
導入、起動
Docker の場合
-
DockerHub から image を持ってくる場合
DockerHub から持ってくる。
docker pull shakku/kiso-lab-support-tool
持ってきた image でコンテナ起動。環境変数指定で BasicAuth のユーザとパスワードを string で指定し、サーバ起動ポートを int で指定する。
docker run -e USER_ENV="user" -e PASSWORD_ENV="password" -e PORT_ENV=8080 -p 8080:8080 -d shakku/kiso-lab-support-tool
-
build からする場合
Python の実行パスを以下にする。
PythonPath = "/opt/venv/bin/python3"
build する。
docker image build -t kiso-lab-support-tool .
作成した image でコンテナ起動。環境変数指定で BasicAuth のユーザ名とパスワードを string 、サーバ起動ポートを int で指定する。
docker run -e USER_ENV="user" -e PASSWORD_ENV="password" -e PORT_ENV=8080 -p 8080:8080 -d kiso-lab-support-tool
go コマンドで実行する場合
Python の実行パスを以下にする。PyMuPDF や Pillow というパッケージを使用しているため、環境に合わせて Python のバージョンを変更する。
PythonPath = "python3"
引数で BasicAuth のユーザ名とパスワードを string 、サーバ起動ポートを int で指定する。
go run main.go -user user -password password -port 8080
使い方
-
/management
にアクセスし、発表資料の PDF ファイルをアップロードする - 発表の進捗に合わせて、maxPage の数字を変更する
-
/document
ページにアクセスすると、maxPage までのページの資料が表示され、maxPage が更新されると、資料が自動で更新され、新たに指定されたmaxPage までのページを見ることができる - Management からタイトルと本文を入力して全体にメッセージを送信することができる
-
/message
にアクセスすると、管理者から送信されたメッセージが一覧表示され、新たにメッセージが送信されると自動で更新されて表示される - Management からManagementMessage にアクセスし、メッセージを削除できる。(一般ユーザーのメッセージ一覧では Deleteボタンは表示されない。)
index
management
Document
Document(最大ページを3にした場合)
management(メッセージ送信)
message
message(管理者)
注意点
- アップロードできる資料は PDF(.pdf)のみ
- PDF のファイルサイズは100MB まで
- ページ数は10000ページまで
- メッセージのタイトルは50文字まで
- メッセージの本文は10000文字まで
技術選定
使用した技術
- Golang v1.22.5
- Python v3.11.2
- Docker v27.0.3
- HTML
- Javascript
- CSS
- GORM
- sqlite
- Server-Sent Events(SSE)
使用したパッケージ
Go
- go-playground/validator/v10 v10.22.0
- labstack/echo/v4 v4.12.0
- microcosm-cc/bluemonday v1.0.27
- gorm.io/gorm v1.25.11
- gorm.io/driver/sqlite v1.5.6
- fmt
- log
- net/http
- sync
- html/template
- sort
- strconv
- time
- io
- os
- os/exec
- path/filepath
- text/template
- flag
Python
- PyMuPDF(fitz)
- Pillow(Image、PIL)
- sys
- os
選定理由
Golang
goroutine を使用した HTTP サーバは、イベントやハンズオンを開催する上での限られたリソースの中で、効率よくかつ簡単にサーバをたてることができる。OSS として配布する中でも、クロスコンパイルやDocker との相性の良さなどがあるためよいと考えた。(あと、自分の一番得意な言語だから...)
Python
PDF ファイルを分割したり JPG に変換したりする処理が Go だと良さそうなパッケージがなさそうだったので、いい感じのパッケージ(PyMuPDF や Pillow)があった Python を使用した。スクリプト言語なので Go の os/exec パッケージを用いてコマンド感覚で実行することができるのも便利。
echo
Middleware(ログのカスタマイズのしやすさや Basic認証の実装)や Template(テンプレートへのデータ渡し)が使いやすく、お気に入りのフレームワークだから。
GORM
他のプログラムでもお世話になっており、今回使用したsqlite にも対応していたので使用した。
マイグレーションの簡単さや、クエリ発行の手軽さがお気に入り。
sqlite
アプリケーション内で DB を使用したかったが、他の DB を使用するほど重要度が低く、DB のためにコンテナをもう一つ立ち上げて管理するのは大変だと考えたため、sqlite で済んだ。
Server-Sent Events(SSE)
管理者が最大ページを更新したりメッセージを送信したりした際に、リアルタイムでページのリロードをする必要があったため、サーバ側から任意のタイミングで指示できる SSE を採用した。
go-playground/validator
echo のサンプルでこのバリデーションパッケージが使用されており、一般的にも使用されているパッケージであったため選択した。しかし、構造体にタグを追加してバリデーションするのが少したいへんに感じたため、今後は別のパッケージも使ってみたい。
microcosm-cc/bluemonday
雑談の章に書いた経緯でサニタイジングをする必要が出てきたため、脆弱性につながる文字列を削除してくれるこのパッケージを使用した。今回はメッセージ送信時にチェックするよう実装している。
構成
ディレクトリ
kiso-lab-support-tool/
├── controller
│ ├── controller.go
│ ├── message_controller.go
│ └── pdf_controller.go
├── db
│ └── db.go
├── entity
│ └── entity.go
├── model
│ └── message_model.go
├── server
│ └── server.go
├── controller
│ ├── 401.html
│ ├── 401Unauthorized-image.png
│ ├── 404.html
│ ├── 404NotFound-image.png
│ ├── 405.html
│ ├── 405MethodNotAllowed-image.png
│ ├── document-view.html
│ ├── error.html
│ ├── index.html
│ ├── management.html
│ ├── message.html
│ └── style.css
├── upload
│ └── upload.pdf
├── cut
│ ├── 1.jpg
│ ├── 2.jpg
│ ...
├── view-document
│ ├── 1.jpg
│ ├── 2.jpg
│ ...
├── Dockerfile
├── go.mod
├── go.sum
├── gorm.db
├── main.go
└── pdf-cut.py
ルーティング
クライアント
-
Index
GET /
-
資料表示(Document)
GET /document/:currentPage
currentPage にはパスパラメータでページ番号を指定 -
メッセージ表示(Message)
GET /message
-
SSE クライアントに参加
GET /sse
管理(認証あり)
-
管理画面(Management)
GET /management
-
最大ページ更新
POST /management/maxpage
-
資料のアップロード
POST /management/upload
-
メッセージ追加
POST /management/addmessage
-
メッセージ表示(管理版)
GET /management/message
-
メッセージ削除
GET /management/deletemessage/:id
id にはメッセージの ID を指定
静的ファイル
-
クライアントに表示するページ
/view-document
-
HTML テンプレートなどのクライアント用静的ファイルの配信
/views
フラグ・引数
Docker
docker run
時には-e
オプションで以下の環境変数を指定する。
- USER_ENV
Basic認証のユーザ名
string - PASSWORD_ENV
Basic認証のパスワード
string - PORT_ENV
サーバ起動のポート
int
Go
go コマンドで実行する際は以下のフラグを指定する。
- user
Basic認証のユーザ名
string - password
Basic認証のパスワード
string - port
サーバ起動のポート
int
こだわりポイント
資料アップロードから表示、最大ページ更新からの表示までの流れ
Management ページから PDF ファイルをアップロードすると、アップロード用ディレクトリに保存される。
アップロードされたファイルを Python の PyMuPDF(fitz)を使用して1ページごとに分割し、Pillow(Image、PIL)を使用して1ページごとに分割された PDF を JPEG に変換し、分割したファイル保存用ディレクトリに保存される。
アップロード段階では最大ページが1に設定され、公開用ディレクトリに1ページ目の JPEG ファイルがコピーされる。最大ページが更新されるとその値分、公開用ディレクトリに JPEG ファイルがコピーされ、クライアントからそのディレクトリに対して画像を取ってきて、クライアントで表示される。
Python のスクリプトを実行する際は、Go のexec
パッケージの Command関数で実行コマンドを生成し、CombinedOutput関数で実行している。
自動更新
Server-Sent Events(SSE)を使用し、Document と Message ページの自動更新を実装した。
Document の場合は、管理者が実際の進行に合わせて最大ページを更新した際にリアルタイムでクライアントの最大ページも更新される必要がある。また Message の場合は、管理者側からメッセージが送信(または削除)されたら即時にクライアントにも反映させる必要がある。
そこで SSE を利用し、ページ更新やメッセージ追加がされた際にバックエンド側からイベントを送信し、クライアントがそれに反応してリロードし、最新の状態を保つようにした。
具体的には、クライアントが Document ページや Message ページにアクセスした際(ページ読み込み時)に javascript で/sse
へアクセスしSSEクライアントに参加し、バックエンド側からMessageUpdate
というメッセージが送信されたらリロードを実行するように実装している。
<script>
document.addEventListener("DOMContentLoaded", function() {
// SSEクライアントに参加
var eventSource = new EventSource("/sse");
// バックエンドサーバから"MessageUpdate"というSSEが届いたらページをリロードする。
eventSource.onmessage = function(event) {
if (event.data === "MessageUpdate") {
location.reload();
}
};
});
</script>
バックエンド側では/sse
にアクセスされたタイミングでそのクライアントをclients
というチャネルを持った map に追加し、管理を行う。
最大ページ更新時やメッセージ送信/削除 時にSendEvent
関数が実行され、各クライアントにチャネルでメッセージが送信され、それを引き金に各クライアントへメッセージが送信される。
var (
clients = make(map[chan string]struct{}) // SSEのクライアントを管理するmap
clientsMutex sync.Mutex // mapへの同時書き込みを制限する用
)
// SSE接続とクライアント追加
func SSE(c echo.Context) error {
// Fulusherを取得
flusher, ok := c.Response().Writer.(http.Flusher)
if !ok {
log.Println("[error] SSE c.Response")
return c.String(http.StatusInternalServerError, "Streaming unsupported")
}
messageChan := make(chan string)
// 書き込みのためmapをロック
clientsMutex.Lock()
// クライアント追加
clients[messageChan] = struct{}{}
// ロック解除
clientsMutex.Unlock()
// クライアントとの接続終了後mapから削除
defer func() {
// 書き込みのためmapをロック
clientsMutex.Lock()
// クライアント削除
delete(clients, messageChan)
// ロック解除
clientsMutex.Unlock()
// チャネルを閉じる
close(messageChan)
}()
// SSE用のヘッダー設定
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
// イベントの待機
for {
select {
case msg := <-messageChan: // イベントが来た場合
// レスポンスに書き込み
fmt.Fprintf(c.Response(), "data: %s\n\n", msg)
// クライアントに送信
flusher.Flush()
case <-c.Request().Context().Done(): // 接続が終了した場合
// ループから抜ける
return nil
}
}
}
// イベント送信
func SendEvent(message string) {
// 書き込みのためmapをロック
clientsMutex.Lock()
// 関数終了後にロック解除
defer clientsMutex.Unlock()
// 各クライアントにイベント送信
for client := range clients {
client <- message
}
}
後述している雑談のセキュリティ項目にも記載していますが、大量アクセスによる map への書き込み失敗でのサーバダウン対策として、sync
パッケージの Mutex を使用した map の書き込みロックを実装している。
パスワードとポートの指定
このアプリケーションでは、Management にアクセスする際に Basic認証を必要としている。そのため、Basic認証に必要となるユーザ名とパスワードを自由に設定できるようにする必要がある。また、OSS 化にするにあたって、サーバを実行するポート番号も自由に設定できる必要もある。
そこでflag
パッケージを使用し、プログラム実行時にフラグへ値を指定し、自由に指定できるよう実装した。
func main() {
// 実行時のフラグでManagementページへアクセスする際のBasic認証のユーザ名&パスワードと、サーバ起動ポートを指定
userNameFlag := flag.String("user", "user", "BasicAuth user flag")
passwordFlag := flag.String("password", "password", "BasicAuth password flag")
portFlag := flag.Int("port", 8080, "Port flag")
flag.Parse()
...
}
プログラム実行例
go run main.go -user user -password password -port 8080
また、Docker image で配布するにあたって、docker run 時にも指定することができるようにする必要があるため、Dockerfile 内で環境変数を参照してパスワードやポート番号を指定するように記述した。
CMD ["/bin/sh", "-c", "./main -user $USER_ENV -password $PASSWORD_ENV -port $PORT_ENV"]
docker run コマンドの-e
オプションで環境変数を設定したり、その他の方法でコンテナに環境変数を与えてあげれば指定することができる。
docker run -e USER_ENV="user" -e PASSWORD_ENV="password" -e PORT_ENV=8080 -p 8080:8080 -d shakku/kiso-lab-support-tool
メッセージの表示順
今回のメッセージ機能は、資料修正や参加者にコピーして欲しいコマンドやコードを配布するためなどに使用される。そのため、メッセージの記録といった保存/保管というより、リアルタイムかつ最新の情報に重きが置かれるため、最新の情報が目につくように(上に来るように)配置した。
DB からメッセージ一覧(Message構造体の配列)を取得した後、sort
パッケージの Slice 関数を使用してソートを行った。以下のようにメッセージの Date フィールドでtime
パッケージの After を使用して比較をしてあげると、メッセージの新しい順でソートすることができた。
// 最新のメッセージが上に来るように並び替え
sort.Slice(messages, func(i, j int) bool {
return messages[i].Date.After(messages[j].Date)
})
ファイル形式
今回、クライアントに返す/表示する 資料のファイル形式を PDF ではなく JPEG を使用している。
経緯については後述している雑談の旧バージョンの項目に記載しているが、JPEGを採用した理由として、ページが連結された PDF ファイル1つと1ページの JPEG ファイルのファイルサイズを比較すると JPEG ファイルの方が小さかったことや、PDF をHTMLで表示した際のUIの歪さが挙げられる。
そして一番の理由として、最大ページ更新時にリロードされると HTML 内の PDF 表示が1ページ目に戻されてしまうという問題があった。
そのため現在のバージョンの実装では、/document/ページ数
といったURLパスで資料のページごとにURLパスを分けて管理することで、リロードしても現在のページが変更されないようにした。
JPEG にしたことによって、HTML で PDF を表示させた際に利用できた、左側に表示される各ページのプレビューアイコンと、そこをクリックするとそのページに移動する機能が失われてしまうため、独自にプレビュー部分を作成した。その際にプレビューアイコン分の画像が大量に読み込む/アクセスされることになった。
プレビューの読み込み
資料表示ページにて PDF のプレビュー機能(左側に表示される各ページのプレビューアイコンと、そこをクリックするとそのページに移動する機能)をハンバーガーメニューとして独自に実装した。
実装にあたって、Document ページリクエストのたびに各ページのプレビューアイコンの画像を一気に取得する必要があり、バックエンドに画像取得の大量のリクエストとそれに伴う負荷がかかってしまっていた。
そこで、メインのページの画像は Document ページへリクエストされ、HTML が読み込まれるタイミングと同時に img タグで画像を読み込むが、プレビューアイコンに使用される画像はハンバーガーメニューを開いた際に画像を取得してくるようにした。
HTML の img タグに data-src で、プレビューアイコン用の画像を取得するための URL をしておく。(document-img-preview)
<div class="sidebar">
<div id="hamburger-menu" class="hamburger-menu">
☰ preview
</div>
<!-- ページプレビュー -->
<div id="document-preview-container" class="document-preview-container">
{{ range $v := .PagePathList }}
<p><a href="/document/{{$v.Number}}">page {{$v.Number}}</a></p>
<img class="document-img-preview" data-src="{{$v.Path}}" alt="Document Preview Page {{$v.Number}}">
{{ end }}
</div>
</div>
今回は複数の img タグを対象に処理するため、document-img-preview クラスで指定した img タグを querySelectorAll で取得し、javascript でハンバーガーメニュのOpen,Close のアクションを管理する処理の中で、Openのタイミングで img タグの src に HTML 内の data-src にセットしておいた URL を入れてあげることで、このタイミングで画像取得のリクエストがバックエンドへ走るようになっている。
<script>
document.addEventListener("DOMContentLoaded", function() {
...
// ハンバーガーメニューを開いたタイミングでimgを読み込むように管理
const images = document.querySelectorAll('.document-img-preview');
// ハンバーガーメニューのクリックイベント管理
document.getElementById("hamburger-menu").addEventListener("click", function() {
var previewContainer = document.getElementById("document-preview-container");
if (previewContainer.style.display === "none" || previewContainer.style.display === "") {
// 表示
previewContainer.style.display = "flex";
// 各プレビューのimgにsrcを設定して画像の遅延読み込み
images.forEach(image => {
image.src = image.dataset.src;
});
} else {
// 隠す
previewContainer.style.display = "none";
}
});
});
</script>
メッセージのHTML対応
Message ページにて、HTML テンプレート内でメッセージを表示する際に、以下のような実装をしている。
{{ range $v := .Message }}
<div class="section">
<h2>{{$v.Title}}</h2>
<h3>{{$v.Date}}</h3>
<pre>{{$v.Content}}</pre>
</div>
{{ end }}
Go の HTML テンプレートの仕様上、<pre>{{$v.Content}}</pre>
部分(メッセージの本文)に HTML のコードを表示させようとすると、テンプレートだと解釈されて HTML の形式で表示されてしまう。
本来の用途として参加者に HTML のコードをコピペさせたいときに問題となってしまうため、template パッケージの HTMLEscapeString 関数を用いて、そのまま文字列として表示できるように変換した。
// 本文でHTMLのコードを入れるとブラウザが解釈してクライアントに表示されてしまうため、そのまま文字列として表示できるように変換
escapedContent := template.HTMLEscapeString(messageForm.Content)
大量のアクセスログへの対応
Documentページにて、大量の画像ファイルが読み込まれる際にバックエンド側にも大量のリクエストとそのログが流れてしまう。
そこで、echo パッケージの Group 機能で通常のアクセス系のルーティングと静的ファイルを配信するアクセス系のルーティングで分け、それぞれにカスタムしたログのミドルウェアを設定することでログの仕分けを行った。
// 認証無しURLパスグループ作成
access := e.Group("")
// アクセスログ用ログ出力のカスタマイズミドルウェア作成
access.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `[access] ` +
`time: ${time_rfc3339_nano}` + ", " +
`method: ${method}` + ", " +
`remote_ip: ${remote_ip}` + ", " +
`host: ${host}` + ", " +
`uri: ${uri}` + ", " +
`status: ${status}` + ", " +
`error: ${error}` + ", " +
`latency: ${latency}(${latency_human})` + "\n",
}))
// indexページ
access.GET("/", controller.Index)
...
// 静的ファイル配信グループ作成
static := e.Group("/static")
// 静的ファイル用ログ出力のカスタマイズミドルウェア作成
static.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `[static] ` +
`time: ${time_rfc3339_nano}` + ", " +
`method: ${method}` + ", " +
`remote_ip: ${remote_ip}` + ", " +
`host: ${host}` + ", " +
`uri: ${uri}` + ", " +
`status: ${status}` + ", " +
`error: ${error}` + ", " +
`latency: ${latency}(${latency_human})` + "\n",
}))
// クライアントに表示させるページのjpgファイルの配信
static.Static("/"+controller.ViewDocumentDirName, controller.ViewDocumentDirName)
// HTMLテンプレートなどの静的ファイルの配信
static.Static("/views", "views")
// 認証を必要とするURLパスグループ作成
m := access.Group("/management")
// managementグループに対するBasic認証のミドルウェア
m.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
// ユーザ名とパスワードが一致するか確認(ユーザ名とパスワードはフラグで指定しているものをmainからもらっている)
if username == un && password == pw {
return true, nil
}
// 正しくなかった場合は、認証エラーページを返す
return false, c.Render(http.StatusUnauthorized, "401.html", nil)
}))
// 管理画面表示
m.GET("", controller.Management)
...
今後の改善点、追加機能
現在発見されている問題点として、Document ページにて高速でページ送りを行うと、ものすごく長いページ読み込みが発生するというものがある。現在の実装では、DocumentページにアクセスされるたびにSSEクライアントとしてバックエンドに参加しているため、それによる不具合ではないかと考えている。今後何かしら改善案が見つかれば修正予定。
また UI 面に関しては、自身の知識がほとんどないこともあり、ところどこと変なところがあるため、どこかのタイミングで綺麗にしたい。
他に追加する予定であった機能として、参加者からの質問機能や管理者側からのファイル配布機能などがあった。質問機能はSlidoのような機能を実装しようと考えていたが、「ICT基礎Lab. for Junior」はオフラインのイベントでありあまりこの機能の必要性を感じなかったため、実装を見送りにした。ファイル配布機能に関しては、セキュリティ上のリスクが色々発生しそうというのと、それを負ってまでの必要性を感じなかったため、実装を見送りとしている。
まとめ
今回は、スライドを使用するイベントや講演会、ハンズオンなどで使用する便利なサポートツールを作成しました。
初めてのOSS公開のため、ドキュメント不足やどこかしら変な点がありそうですが、細かくコメント書いたり使いやすくしたりしたつもりなので、優しく使っていただければなと思います。
改めて、本アプリケーションについて、自由に使用・修正・拡張していただいて問題ありません。使用した際の感想や問題点改善点などがありましたら、お気軽にご連絡ください。(再配布に関しては一度ご連絡いただけると幸いです。)
不具合や、質問などもございましたら、お気軽にご連絡ください。
雑談
旧バージョンを実際にイベントで使用した話
2024年7月下旬、「ICT基礎Lab. for Junior」が開催されました。その際に旧バージョンの本アプリをお試しで使用しました。
参加者は20人弱の中学生、運営は本校情報科2~4年生の6人で、今回はDocument機能を試してみました。運営用ノートPC内のDockerで本アプリのコンテナを立て、以下の図のような配置のローカル環境で参加者用の用意したノートPCと接続し、Webブラウザ上で本アプリのさまざまな機能を使用することができるようにしています。
旧バージョンの仕様(Document)
現在のバージョンでは/document/ページ番号
という形式で URL が設計されており、1ページごとに分かれているかつ表示形式も JPEG ファイルですが、旧バージョンでは資料表示の/document
の URL パス1つで管理されており、HTML 内の PDF 表示機能でクライアントへ表示されるようになっています。
現在の最大ページに合わせてそこまでのページを結合した PDF ファイルがクライアントに送信されており、参加者は HTML 内の PDF 表示機能をスクロールしてみたいページを開くような UI となっていました。
イベント内で出た問題点
構成図にもある通り、参加者と同じスペックの運営用ノート PC 内の Docker 上で本アプリを立ち上げ、20人弱の参加者から一度にリクエストされる構成となっているため、ページの読み込み速度がとても遅くなってしまっていました。
特に最大ページ更新時には、バックエンドでそのページ分の PDF 結合が行われた直後に各クライアントに更新の SSE が送信され、各クライアントから一斉に PDF 取得のリクエストが送られてくる、というものすごい負荷がかかっていました。
また、仕様上 HTML 内の埋め込みの PDF は、ページリロード時1ページ目に戻されてしまうため、進行に合わせて定期的に最大ページを更新する度1ページ目に戻されていて、参加者目線だと使いにくそうに感じました。
(その時のイベントでは悪意あることをしてくる参加者はいませんでしたが、後述しているセキュリティに関してもこのバージョンではいくつか問題点がありました。)
旧バージョンからの改善
これらの問題点を踏まえて現在のバージョンに改善されました。
主に資料の表示形式に関して、軽量化という面で PDF から JPEG への変更やプレビュー部分の読み込みをハンバーガーを開いたタイミングのみになるよう実装、1ページ目に戻されてしまう問題の面として URL 設計を変更し各ページをそれぞれ表示するように変更しました。
また、SSE に関しては一気にリクエストが来てもサーバが落ちないように内部 map の書き込みロック実装を行い、その他セキュリティ面の修正も行いました。
セキュリティ
7月某日(旧バージョンを実際にイベントで使用した日の前日)このアプリケーションがおおよそ完成したので、同じゼミのセキュリティを履修しているメンバーにお試しで使ってもらう兼、攻撃 脆弱性診断をしてもらいました。
最初は穏やかに使ってくれていたものの徐々にログの流れが速くなり、あれよこれよと複数回のサーバダウンや変な挙動が発生し、いくつかの指摘をしていただきました。
いくつか指摘していただいた点から抜粋して、問題点とその改善を以下に挙げました。
(基本的には Management 画面の Basic認証が突破されなければ起きないものばかりですが、万が一を備えて Management 画面が突破された前提で色々やってもらいました。)
-
バリデーション
バリデーションをある程度していたつもりなのですが、最大ページ更新にありえない数の数値を入れられたり、メッセージ機能のタイトルや本文に変な文字列やものすごい長文を入れられたり、ファイルアップロードに PDF 以外のファイルがあげられたりなどが行われました。
HTML 上のバリデーションで、文字列の max-min やファイルの input で PDF 限定にしていましたが、文字列に関しては Form を無視してリクエストを送信してきたり、ファイルに関してはアップロード部分に直接 PDF 以外のファイルをドラッグ&ドロップすると突破できてしまったりしていました。
そこで今回は、github.com/go-playground/validator/v10 という、バリデーションパッケージを使用しました。バリデーション用に Form から送られてくる値をバインドする構造体を用意し、
validate
という tag でバリデーションを定義してあげます。/entity/entity.go// Formからきた最大ページのバリデーション用 type MaxPageForm struct { MaxPage int `form:"maxpage" validate:"required,min=1,max=10000"` } // Formからきたメッセージのバリデーション用 type MessageForm struct { Title string `form:"title" validate:"required,min=1,max=50"` Content string `form:"content" validate:"required,min=1,max=10000"` }
validator.New()
でバリデーション用のインスタンスを作成し、/controller/pdf_controller.govar ( // バリデータのインスタンス作成 validate = validator.New() )
Form からきたデータを用意していた構造体にバインドして、
validate.Struct
でバリデーションチェックを行いました。/controller/message_controller.go// メッセージインスタンス作成 messageForm := new(entity.MessageForm) // Formから来たメッセージをバインド err := c.Bind(messageForm) if err != nil { log.Printf("[error] AddMessage c.Bind : %v\n", err) data := map[string]interface{}{ "Message": fmt.Sprintf("Formの取得に失敗しました。: %v\n", err), } return c.Render(http.StatusServiceUnavailable, "error.html", data) } // バリデーションチェック err = validate.Struct(messageForm) if err != nil { log.Printf("[error] AddMessage validate.Struct : %v\n", err) data := map[string]interface{}{ "Message": fmt.Sprintf("タイトルは1文字以上50文字以下、コンテンツは1文字以上10000文字以下にしてください。: %v\n", err), "CurrentPage": maxPage, } return c.Render(http.StatusBadRequest, "management.html", data) }
-
XSS 不正な文字列の送信
XSS(クロスサイトスクリプティング)とよばれる攻撃手法があるそうで、実際に
<img src="x" onerror=alert(1)>
という文字列をメッセージ機能のタイトルや本文に入力してみると、定期的に javascript によるアラートが発生し、操作が妨害され、さらには他のクライアントのメッセージ機能にも表示されるため、他のクライアントでもアラートで妨害してくるという現象が派生しました。このような XSS の対策として、入力文字列のサニタイズという行為が存在します。今回は github.com/microcosm-cc/bluemonday というパッケージを用いて以下のように変換を行いました。(本文に関しては、template パッケージによる HTML 文字列の通常文字列変換
HTMLEscapeString
関数の時点で不正な文字列が無効化されているため、タイトルのみサニタイズしている。)/controller/message_controller.go// タイトルに不正な文字列が入れられないように変換 policy := bluemonday.UGCPolicy() safeTitle := policy.Sanitize(messageForm.Title)
実際に変換されると、
<img src="x" onerror=alert(1)>
からアラートの部分が削除され、src=”x” の純粋に画像が見つかっていない img タグになっていました。 -
大量リクエストによる鯖落ち
一気に何百何千リクエストが送信され、サーバがダウンしてしまいました。純粋にサーバが耐えられなかったという話かと思いつつログをよくみてみると、
chan receive
やIO wait
といった文言が見られ、どうやら SSE クライアント参加のタイミングで一斉に map へ書き込みが行われたことによるパニックが原因の1つだと判明しました。ログ抜粋
2024-07-19 15:28:24 [access] time: 2024-07-19T06:28:24.471699923Z, method: GET, remote_ip: 192.168.65.1, host: 172.24.14.24:8080, uri: /sse, status: 200, error: , latency: 85573560289(1m25.573560289s) 2024-07-19 15:28:28 fatal error: concurrent map writes 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1492 [running]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE.func1() 2024-07-19 15:28:28 /app/controller/controller.go:45 +0x3c 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE({0xb06708, 0x40001a01c0}) 2024-07-19 15:28:28 /app/controller/controller.go:59 +0x484 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x40001a01c0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x40001a01c0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x40001a01c0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x400028e8c0}, 0x4000302d80) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x40004848d0?}, {0xaff4b8?, 0x400028e8c0?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40000d9200, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be46b0, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x7?, 0x0?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Accept(0x4000220100) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:611 +0x250 2024-07-19 15:28:28 net.(*netFD).accept(0x4000220100) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_unix.go:172 +0x28 2024-07-19 15:28:28 net.(*TCPListener).accept(0x400021a300) 2024-07-19 15:28:28 /usr/local/go/src/net/tcpsock_posix.go:159 +0x28 2024-07-19 15:28:28 net.(*TCPListener).AcceptTCP(0x400021a300) 2024-07-19 15:28:28 /usr/local/go/src/net/tcpsock.go:314 +0x2c 2024-07-19 15:28:28 github.com/labstack/echo.tcpKeepAliveListener.Accept({0x40002cdac8?}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:767 +0x1c 2024-07-19 15:28:28 net/http.(*Server).Serve(0x40000f6d20, {0xaff5d8, 0x4000216118}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3255 +0x2a8 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).StartServer(0x40001ce1e0, 0x40000f6d20) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:663 +0x430 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Start(...) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:604 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init({0xffffd92d5ebf, 0x4}, {0xffffd92d5ece, 0x8}, 0x1f90) 2024-07-19 15:28:28 /app/server/server.go:83 +0x7e8 2024-07-19 15:28:28 main.main() 2024-07-19 15:28:28 /app/main.go:91 +0x4f8 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 6 [select, 31 minutes]: 2024-07-19 15:28:28 database/sql.(*DB).connectionOpener(0x4000099520, {0xb003a8, 0x4000178820}) 2024-07-19 15:28:28 /usr/local/go/src/database/sql/sql.go:1246 +0x80 2024-07-19 15:28:28 created by database/sql.OpenDB in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/database/sql/sql.go:824 +0x140 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1525 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be3fe8, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x4000220b00?, 0x40004850c1?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x4000220b00, {0x40004850c1, 0x1, 0x1}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x4000220b00, {0x40004850c1?, 0x40002c0701?, 0x69e36c?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x40006044b0, {0x40004850c1?, 0x0?, 0xefa2a0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).backgroundRead(0x40004850b0) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:681 +0x40 2024-07-19 15:28:28 created by net/http.(*connReader).startBackgroundRead in goroutine 1497 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:677 +0xc8 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1516 [select, 2 minutes]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE({0xb06708, 0x40002aa070}) 2024-07-19 15:28:28 /app/controller/controller.go:54 +0x46c 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x40002aa070}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x40002aa070}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x40002aa070}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x4000480b60}, 0x4000268ea0) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x4000143380?}, {0xaff4b8?, 0x4000480b60?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40003a8ea0, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1499 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be41d8, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x4000220b80?, 0x400020c000?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x4000220b80, {0x400020c000, 0x1000, 0x1000}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x4000220b80, {0x400020c000?, 0x0?, 0x0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x40006044c0, {0x400020c000?, 0x72?, 0x4000485268?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).Read(0x4000485260, {0x400020c000, 0x1000, 0x1000}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:789 +0x224 2024-07-19 15:28:28 bufio.(*Reader).fill(0x40002a8f60) 2024-07-19 15:28:28 /usr/local/go/src/bufio/bufio.go:110 +0xf8 2024-07-19 15:28:28 bufio.(*Reader).Peek(0x40002a8f60, 0x4) 2024-07-19 15:28:28 /usr/local/go/src/bufio/bufio.go:148 +0x60 2024-07-19 15:28:28 net/http.(*conn).serve(0x40000d98c0, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2074 +0x63c 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1467 [runnable]: 2024-07-19 15:28:28 github.com/valyala/fasttemplate.(*Template).ExecuteFunc(0x400019ca00, {0xafc500, 0x40003b02d0}, 0x400030f978) 2024-07-19 15:28:28 /go/pkg/mod/github.com/valyala/fasttemplate@v1.2.2/template.go:276 +0x210 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x4000232a10}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:126 +0x1fc 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x4000232a10}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x400028e2a0}, 0x4000302240) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x400020b110?}, {0xaff4b8?, 0x400028e2a0?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40003a0630, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1512 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be43c8, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x40002d0880?, 0x4000484a01?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x40002d0880, {0x4000484a01, 0x1, 0x1}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x40002d0880, {0x4000484a01?, 0x4000439701?, 0x40004396c8?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x400028a088, {0x4000484a01?, 0x400008b680?, 0xefa2a0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).backgroundRead(0x40004849f0) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:681 +0x40 2024-07-19 15:28:28 created by net/http.(*connReader).startBackgroundRead in goroutine 1522 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:677 +0xc8 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1281 [chan receive, 16 minutes]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.PDFController.ChangeMaxPage({}, {0xb06708, 0x40001a00e0}) 2024-07-19 15:28:28 /app/controller/pdf_controller.go:79 +0x4cc 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.BasicAuth.BasicAuthWithConfig.func5.1({0xb06708, 0x40001a00e0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/basic_auth.go:89 +0x29c 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x40001a00e0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x40001a00e0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x40001a00e0}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x400031a000}, 0x4000302000) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0xafe218?}, {0xaff4b8?, 0x400031a000?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x4000318000, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1262 [IO wait]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be44c0, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x40002a8480?, 0x407cdb719f?, 0x1) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x40002a8480, {0x407cdb719f, 0x385ce61, 0x385ce61}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 os.(*File).read(...) 2024-07-19 15:28:28 /usr/local/go/src/os/file_posix.go:29 2024-07-19 15:28:28 os.(*File).Read(0x40002d2018, {0x407cdb719f?, 0x40002c3508?, 0x20000000?}) 2024-07-19 15:28:28 /usr/local/go/src/os/file.go:118 +0x70 2024-07-19 15:28:28 bytes.(*Buffer).ReadFrom(0x40003f6420, {0xafbf00, 0x4000604438}) 2024-07-19 15:28:28 /usr/local/go/src/bytes/buffer.go:211 +0x90 2024-07-19 15:28:28 io.copyBuffer({0xafc500, 0x40003f6420}, {0xafbf00, 0x4000604438}, {0x0, 0x0, 0x0}) 2024-07-19 15:28:28 /usr/local/go/src/io/io.go:415 +0x14c 2024-07-19 15:28:28 io.Copy(...) 2024-07-19 15:28:28 /usr/local/go/src/io/io.go:388 2024-07-19 15:28:28 os.genericWriteTo(0x40002d2018?, {0xafc500, 0x40003f6420}) 2024-07-19 15:28:28 /usr/local/go/src/os/file.go:269 +0x5c 2024-07-19 15:28:28 os.(*File).WriteTo(0x40002d2018, {0xafc500, 0x40003f6420}) 2024-07-19 15:28:28 /usr/local/go/src/os/file.go:247 +0xa0 2024-07-19 15:28:28 io.copyBuffer({0xafc500, 0x40003f6420}, {0xafbde0, 0x40002d2018}, {0x0, 0x0, 0x0}) 2024-07-19 15:28:28 /usr/local/go/src/io/io.go:411 +0x98 2024-07-19 15:28:28 io.Copy(...) 2024-07-19 15:28:28 /usr/local/go/src/io/io.go:388 2024-07-19 15:28:28 os/exec.(*Cmd).writerDescriptor.func1() 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:577 +0x44 2024-07-19 15:28:28 os/exec.(*Cmd).Start.func2(0x400023c390?) 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:724 +0x34 2024-07-19 15:28:28 created by os/exec.(*Cmd).Start in goroutine 1261 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:723 +0x7cc 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1456 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be3ef0, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x40000da380?, 0x40001d8000?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x40000da380, {0x40001d8000, 0x1000, 0x1000}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x40000da380, {0x40001d8000?, 0x0?, 0x0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x40002d2008, {0x40001d8000?, 0x72?, 0x4000097448?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).Read(0x4000097440, {0x40001d8000, 0x1000, 0x1000}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:789 +0x224 2024-07-19 15:28:28 bufio.(*Reader).fill(0x400041c480) 2024-07-19 15:28:28 /usr/local/go/src/bufio/bufio.go:110 +0xf8 2024-07-19 15:28:28 bufio.(*Reader).Peek(0x400041c480, 0x4) 2024-07-19 15:28:28 /usr/local/go/src/bufio/bufio.go:148 +0x60 2024-07-19 15:28:28 net/http.(*conn).serve(0x40003a82d0, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2074 +0x63c 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1517 [IO wait, 2 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff54be3920, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x40000da800?, 0x4000143391?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x40000da800, {0x4000143391, 0x1, 0x1}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x40000da800, {0x4000143391?, 0x40002c5f01?, 0x69e36c?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x40002d2048, {0x4000143391?, 0x0?, 0xefa2a0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).backgroundRead(0x4000143380) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:681 +0x40 2024-07-19 15:28:28 created by net/http.(*connReader).startBackgroundRead in goroutine 1516 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:677 +0xc8 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1113 [IO wait, 19 minutes]: 2024-07-19 15:28:28 internal/poll.runtime_pollWait(0xffff5422f7a8, 0x72) 2024-07-19 15:28:28 /usr/local/go/src/runtime/netpoll.go:345 +0xa0 2024-07-19 15:28:28 internal/poll.(*pollDesc).wait(0x40001ba580?, 0x40004843a1?, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:84 +0x28 2024-07-19 15:28:28 internal/poll.(*pollDesc).waitRead(...) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_poll_runtime.go:89 2024-07-19 15:28:28 internal/poll.(*FD).Read(0x40001ba580, {0x40004843a1, 0x1, 0x1}) 2024-07-19 15:28:28 /usr/local/go/src/internal/poll/fd_unix.go:164 +0x200 2024-07-19 15:28:28 net.(*netFD).Read(0x40001ba580, {0x40004843a1?, 0x92d58efc55272401?, 0x8024d544f41b8721?}) 2024-07-19 15:28:28 /usr/local/go/src/net/fd_posix.go:55 +0x28 2024-07-19 15:28:28 net.(*conn).Read(0x400028a038, {0x40004843a1?, 0xef2ff86420a0874a?, 0xefa2a0?}) 2024-07-19 15:28:28 /usr/local/go/src/net/net.go:179 +0x34 2024-07-19 15:28:28 net/http.(*connReader).backgroundRead(0x4000484390) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:681 +0x40 2024-07-19 15:28:28 created by net/http.(*connReader).startBackgroundRead in goroutine 1109 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:677 +0xc8 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1109 [select, 19 minutes]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE({0xb06708, 0x40001a0000}) 2024-07-19 15:28:28 /app/controller/controller.go:54 +0x46c 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x40001a0000}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x40001a0000}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x40001a0000}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x400028e000}, 0x4000000480) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x4000484390?}, {0xaff4b8?, 0x400028e000?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40001f4000, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1261 [syscall, 16 minutes]: 2024-07-19 15:28:28 syscall.Syscall6(0x5f, 0x1, 0x2c, 0x4000312da8, 0x1000004, 0x0, 0x0) 2024-07-19 15:28:28 /usr/local/go/src/syscall/syscall_linux.go:91 +0x2c 2024-07-19 15:28:28 os.(*Process).blockUntilWaitable(0x40003c6120) 2024-07-19 15:28:28 /usr/local/go/src/os/wait_waitid.go:32 +0x6c 2024-07-19 15:28:28 os.(*Process).wait(0x40003c6120) 2024-07-19 15:28:28 /usr/local/go/src/os/exec_unix.go:22 +0x2c 2024-07-19 15:28:28 os.(*Process).Wait(...) 2024-07-19 15:28:28 /usr/local/go/src/os/exec.go:134 2024-07-19 15:28:28 os/exec.(*Cmd).Wait(0x40004182c0) 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:897 +0x38 2024-07-19 15:28:28 os/exec.(*Cmd).Run(0x40004182c0) 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:607 +0x38 2024-07-19 15:28:28 os/exec.(*Cmd).CombinedOutput(0x40004182c0) 2024-07-19 15:28:28 /usr/local/go/src/os/exec/exec.go:1012 +0x84 2024-07-19 15:28:28 kiso-lab-support-tool/controller.PDFController.ChangeMaxPage.func1(0x400023c390?) 2024-07-19 15:28:28 /app/controller/pdf_controller.go:76 +0x24 2024-07-19 15:28:28 created by kiso-lab-support-tool/controller.PDFController.ChangeMaxPage in goroutine 1281 2024-07-19 15:28:28 /app/controller/pdf_controller.go:75 +0x4b4 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1497 [select, 2 minutes]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE({0xb06708, 0x4000106310}) 2024-07-19 15:28:28 /app/controller/controller.go:54 +0x46c 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x4000106310}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x4000106310}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x4000106310}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x40002d88c0}, 0x4000210360) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x40004850b0?}, {0xaff4b8?, 0x40002d88c0?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40000d9710, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0 2024-07-19 15:28:28 2024-07-19 15:28:28 goroutine 1522 [select, 2 minutes]: 2024-07-19 15:28:28 kiso-lab-support-tool/controller.SSE({0xb06708, 0x4000232b60}) 2024-07-19 15:28:28 /app/controller/controller.go:54 +0x46c 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).Add.func1({0xb06708, 0x4000232b60}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:490 +0x74 2024-07-19 15:28:28 github.com/labstack/echo/middleware.LoggerWithConfig.func2.1({0xb06708, 0x4000232b60}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/logger.go:118 +0xa0 2024-07-19 15:28:28 kiso-lab-support-tool/server.Init.Recover.RecoverWithConfig.func4.1({0xb06708, 0x4000232b60}) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/middleware/recover.go:78 +0xe4 2024-07-19 15:28:28 github.com/labstack/echo.(*Echo).ServeHTTP(0x40001ce1e0, {0xaff4b8, 0x40004807e0}, 0x4000302ea0) 2024-07-19 15:28:28 /go/pkg/mod/github.com/labstack/echo@v3.3.10+incompatible/echo.go:593 +0x268 2024-07-19 15:28:28 net/http.serverHandler.ServeHTTP({0x40004849f0?}, {0xaff4b8?, 0x40004807e0?}, 0x6?) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3137 +0xbc 2024-07-19 15:28:28 net/http.(*conn).serve(0x40003a0b40, {0xb00370, 0x400023c390}) 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:2039 +0x508 2024-07-19 15:28:28 created by net/http.(*Server).Serve in goroutine 1 2024-07-19 15:28:28 /usr/local/go/src/net/http/server.go:3285 +0x3f0
sync
パッケージのMutex
の Lock/Unlock を使用し、mapへの書き込みを安全にし、mapが原因のサーバダウンは対策しました。/controller/controller.govar ( clients = make(map[chan string]struct{}) // SSEのクライアントを管理するmap clientsMutex sync.Mutex // mapへの同時書き込みを制限する用 )
/controller/controller.gomessageChan := make(chan string) // 書き込みのためmapをロック clientsMutex.Lock() // クライアント追加 clients[messageChan] = struct{}{} // ロック解除 clientsMutex.Unlock()
脆弱性診断していただいたその日の夜に次の日のイベントに間に合うようほぼ徹夜でセキュリティパッチを当てたのですが当日うまく動かず… 結局セキュリティパッチ前の旧バージョンの Docker image を使うことになりました…
結果的に後日ちゃんと見直しつつ色々バッチを当てることができたのでよしとします!
DockerfileのCMDと軽量化
CMDの書き方
Dockerfile 内でサーバプログラムを実行するために CMD を使用して以下のように最初は記述していました。
CMD ./main -user $USER_ENV -password $PASSWORD_ENV -port $PORT_ENV
しかし、ビルド時に以下のようなエラーが出力され、どうやら記述方法がよくなかったみたいです。
1 warning found (use --debug to expand):
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 22)
推奨される記述方法で以下のようにすると、なぜか環境変数が読み込まれませんでした。
CMD ["./main", "-user", "$USER_ENV", "-password", "$PASSWORD_ENV", "-port", "$PORT_ENV"]
どうやら CMD の記述方法によって exec 形式と shell 形式に分かれるみたいで、実行形式や環境変数の読み込み方に違いが出てくるらしい。
参考:DockerfileのCMDにおけるshell形式とexec形式の違い
頭に/bin/sh -c
をつけました。
CMD ["/bin/sh", "-c", "./main", "-user", "$USER_ENV", "-password", "$PASSWORD_ENV", "-port", "$PORT_ENV"]
しかしこれでもダメで、以下のようにサーバプログラム実行部分をまとめて1つにする必要あるとのこと。
CMD ["/bin/sh", "-c", "./main -user $USER_ENV -password $PASSWORD_ENV -port $PORT_ENV"]
これにて正常にビルドすることができました。
軽量化
いつも通り Docker image を作っていたら1.38GB とかいうドデカイメージになってしまったので、前々から噂に聞いていた Docker image の軽量化というものをやってみました。
結果的に939.46MBまで減らすことができました!(もっと改善の余地ありそう…)
docker image軽量化前
FROM golang:1.22.5
RUN apt-get update && \
apt-get install -y python3 python3-pip python3-venv
RUN python3 -m venv /opt/venv
RUN /opt/venv/bin/pip install PyMuPDF Pillow
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o main .
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
CMD ["/bin/sh", "-c", "./main -user $USER_ENV -password $PASSWORD_ENV -port $PORT_ENV"]
今回は、マルチステージビルドというものをやってみるのと、必要ない apt パッケージの削除をやってみました。
軽量化後
# ビルドステージ
FROM golang:1.22.5 AS builder
# パッケージインストール
RUN apt-get update && \
apt-get install -y python3 python3-pip python3-venv && \
rm -rf /var/lib/apt/lists/*
# Python仮想環境の作成とパッケージのインストール
RUN python3 -m venv /opt/venv && \
/opt/venv/bin/pip install PyMuPDF Pillow
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Goアプリのビルド
RUN go build -o main .
# 実行ステージ
FROM golang:1.22.5
# Python仮想環境をコピー
COPY --from=builder /opt/venv /opt/venv
# Goアプリをコピー
COPY --from=builder /app /app
# 環境変数を設定
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /app
CMD ["/bin/sh", "-c", "./main -user $USER_ENV -password $PASSWORD_ENV -port $PORT_ENV"]
Pythonの仮想環境をやめてさらに軽量化することもやってみましたが、非公式のPythonパッケージは仮想環境上でやることが推奨されており、うまくいきませんでした。
400番台の一部ページ
404や405、認証エラーの401など、echo で対応できる範囲のハンドリングについて、実際にそのステータスコードの状態が発生すると以下のようなページが表示されます。(完全におふざけです…)
この某5000兆円風の画像生成は、ゆらふかさん制作の5000兆円ジェネレーターsuperを利用させていただきました。感謝。
多分この画像のせいでDocker imageのサイズが少し大きくなってます。ごめんなさい…