はじめに
今回、Dockerを勉強するついでに同じRubyのWEBアプリケーションフレームワークであるSinatraを触ってみたいと思い、Docker+Sinatraで簡単なToDoアプリの開発を行いました。
開発環境での動作しか確認していませんが、開発にあたって学んだこと、失敗したことなどを備忘録的に書き記そうと思います。
開発環境
- M2 MacbookAir
- ターミナル:Alacritty
- エディタ:Vim
- Docker:23.0.5, build bc4487a
3.1.4
GEM
remote: https://rubygems.org/
specs:
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
nio4r (2.5.9)
pg (1.5.4)
puma (6.4.0)
nio4r (~> 2.0)
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
ruby2_keywords (0.0.5)
sinatra (3.1.0)
mustermann (~> 3.0)
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.1.0)
tilt (~> 2.0)
tilt (2.3.0)
PLATFORMS
aarch64-linux
DEPENDENCIES
pg
puma
sinatra
BUNDLED WITH
2.4.21
導入
環境構築
-
作業用ディレクトリの作成
$ mkdir sinatra_test && cd $_ $ mkdir app
※
$_
は直前の引数を取得するコマンド。今回でいうsinatra_test
。
-
Gitの監視下に置き、.gitignoreを作成する
$ git init $ curl https://raw.githubusercontent.com/github/gitignore/main/Ruby.gitignore -o .gitignore
curlコマンドで行っている処理の解説
curl
コマンドの-o
オプションはcurlで指定したURLの実行結果をファイルに出力することができる。つまり、今回のコマンドではgithub上のファイルをコピーして.gitignoreに出力する。.gitignoreはテンプレートが用意されており、A collection of .gitignore templatesから対象の言語やフレームワークに合わせてテンプレートをコピーすることができる。
今回はRuby.gitignoreを選択
コード右上の
Raw
をクリックし、URLをコピーしてcurl
コマンドを実行する -
Dockerfileとcompose.ymlを作成する
$ touch Dockerfile compose.yml
-
Gemfileを生成する
$ gem install bundler # 私のフォルダ構成に合わせるためにapp/に移動していますが、移動する必要はありません。 # ただし移動しない場合Dockerfileの記述が変わります。 $ cd app $ bundle init
Dockerの設定
Dockerコンテナ
(作業環境)を作成する為の指示を記載したDockerイメージ
の設計図。
docker build
コマンドでDockerfileを元にDockerイメージを作成できる。
# syntax=docker/dockerfile:1
FROM ruby:3.1.4
RUN apt-get update -qq && \
apt-get install -y build-essential libpq-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY app/Gemfile* ./
RUN gem install bundler && bundle install
COPY app/ .
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Dockerfileについて
CMD
コマンド以外のコマンドはイメージ作成時(build)に実行される
# syntax=docker/dockerfile:1
Dockerのreferenceで**docker/dockerfile:1
** の使用を推奨されているため記述。
FROM ruby:3.1.4
FROM
コマンドはビルドに用いるベースイメージを公開リポジトリから取得するためのコマンド。今回はローカルの環境に合わせてRubyの3.1.4を取得しているが、環境に応じて複数のベースイメージを取得することもできる。
RUN apt-get update -qq && \
apt-get install -y build-essential libpq-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
apt-get update -qq
パッケージのリストをアップデートしてインストール可能な最新パッケージのリストを取得。-qq
オプションをつけることでエラーメッセージ以外のメッセージを抑制できる。
apt-get install
先ほど取得した最新パッケージリストに従ってインストールを行なっており、-y
オプションで全ての問いに自動でyを入力する様にしている。
build-essential
開発に必須のビルドツールを提供しているパッケージで、Ruby自体のコンパイルや、CやC++で書かれた部分を含むgemのコンパイルを行うことができる。
libpq-dev
PostgreSQLクライアントのヘッダやライブラリを含むパッケージで、Linuxベースの開発環境でPostgreSQLを利用する際に必要になる可能性が高いパッケージ。
apt-get clean
,rm -rf /var/lib/apt/lists/*
後処理で、apt-get clean
はダウンロードしたパッケージファイルを削除しており、rm -rf /var/lib/apt/lists/*
はリポジトリのパッケージリストを削除している。
後処理がない場合はビルドが高速化されるが、イメージファイルが大きくなってしまう。
WORKDIR /app
COPY app/Gemfile* ./
RUN gem install bundler && bundle install
COPY app/ .
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
WORKDIR /app
コンテナ内での作業ディレクトリの設定。このコマンドは指定したディレクトリが存在しない場合、新規作成する。今回の場合、appというフォルダをルートフォルダの直下に作成している。
COPY app/Gemfile* ./
ローカルのapp直下にあるGemfileと名のつくファイル(GemfileとGemfile.lock)を作業ディレクトリの直下にコピー。
RUN gem install bundler && bundle install
コンテナ内でbundlerのインストールとgemのインストールを実行。
COPY app/ .
ローカルのappフォルダにあるファイルを全て作業ディレクトリにコピー。
EXPOSE 3000
コンテナの公開ポートを3000に設定。
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
RUN
コマンドと違い、コンテナ開始時(start)あるいはコンテナ作成時(run)に実行され、Gemfile.lockに書かれたバージョンのpumaを起動する。-C
オプションで、設定ファイルにconfig/puma.rbを読み込む。
compose.yamlについて
複数のコンテナを使うDockerアプリケーションを定義・実行するためのツール。以前はPythonで開発されたものがDockerと別ツールとして用意されていたが、Goで再開発されDockerコマンドに統合された。(docker-compose
からdocker compose
になった)
また、以前はdocker-compose.yml
というファイル名だったが、compose.yaml
が推奨になりversion
の記載も非推奨になった。
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
environment:
- DATABASE_URL=postgresql://user:password@db:5432/mydb
- TZ=Asia/Tokyo
volumes:
- ./app:/app
db:
image: postgres:latest
environment:
- POSTGRES_DB=mydb
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
-
services:
アプリケーションを動かすための各要素をService
と呼ぶ。各コンテナの設定について定義。-
web:
(コンテナ名)
アプリケーションを動かすために使っている各要素の名前を書く。dockerのログに表示されるので、app:
,web:
,db:
など分かりやすい名前が望ましい。-
build .
コンテナイメージを作成するための構築情報を指定する。Dockerfileの格納場所を指す。 -
ports
今回はホスト:コンテナ
の形で指定。プロトコルを指定することもできる。 -
depends_on
サービス間の起動順と終了順の依存関係を書く。今回の例ではweb
の前にdb
が作成される(終了は逆順)。 -
environment
コンテナ内の環境変数を定義する。
DATABASE_URL
:(user_name):(password)@db:(DB_port)/(DB_name)
TZ
:Asia/Tokyo -
volumes
サービスコンテナがアクセスできる様にする必要があるホスト上のパスか、名前付きのボリュームを定義する。
-
-
db:
PostgreSQLを動かすためのコンテナ。-
image
コンテナの下になるベースイメージを指定する。
今回はDockerfileのFROMと同様、公式で用意されたpostgreSQLのイメージ
-
-
volumes
コンテナ間で共有されるデータの保存場所を定義する。
今回はdb:のvolumes:で定義したpg-dataがwebとdbで共有するデータの保存場所としている。
-
Sinatra/Puma/PostgreSQLの導入
# frozen_string_literal: true
source "https://rubygems.org"
gem "sinatra"
gem "puma"
gem "pg"
Pumaについて
Pumaはマルチスレッドで動作するコンカレントなWEBサーバを提供するgem。
Railsには標準で導入されておりrails s
で動作するWEBサーバはデフォルトではPuma。
Sinatraはリクエストを1つずつしか処理できないため、DBを操作する様なレスポンスに時間がかかる処理の合間に別のリクエストが来た場合処理できないという弱点がある。
PumaはWEBサーバだが、アプリケーションサーバとしても動作することができ、nginxなど他のWEBサーバを介さなくてもHTMLリクエストを受け取りSinatraの処理をすることができる。
-
Rackのサーバ起動設定・Pumaの設定(config.ru・config/puma.rb)
# config.ru require './app' run Sinatra::Application # config/puma.rb workers 3 preload_app! port ENV.fetch("PORT") { 3000 }
config.ruはPuma公式ドキュメントのFrameworksを参照
config/puma.rbに関して、workers
は複数のプロセスを1つの単位にまとめたもの。
Pumaではthreads
でスレッド数を設定する。
スレッドは1つにつき1つのリクエストを処理し、1つのワーカーは複数のスレッド(デフォルトでは最大で5つ)を持つことができる。
preload_app!
はfork前にアプリをロードすることで、メモリ消費量を減らせる
port
でlocal環境のport番号を3000に変更
Sinatraのコード(タスク管理アプリ)
Sinatraではルーティングテーブルに
Sinatraではルーティングテーブルにビジネスロジックが直接関連づけられる。
つまり、ルーティングテーブルとして作成するファイル(今回はapp.rb)によってControllerなどを介さずにデータの処理が行われ、Viewファイルに受け渡す。
# frozen_string_literal: true
require 'sinatra'
require 'pg'
enable :method_override
get '/' do
conn = db_connection
@tasks = conn.exec('SELECT * FROM tasks')
conn.close
erb :"tasks/index"
end
post '/tasks/create' do
data = JSON.parse(request.body.read)
title = data['title']
conn = db_connection
conn.exec_params('INSERT INTO tasks (title) VALUES ($1)', [title])
conn.close
content_type :json
{ status: 'success', message: 'Task added successfully' }.to_json
end
delete '/tasks/:taskId' do
task_id = params[:taskId]
conn = db_connection
conn.exec_params('DELETE FROM tasks WHERE id = ($1)', [task_id])
conn.close
content_type :json
{ status: 'success', message: 'Task deleted successfully' }.to_json
end
def db_connection
PG.connect(
dbname: 'mydb',
user: 'user',
password: 'password',
host: 'db'
)
end
<!DOCTYPE html>
<html>
<head>
<meta charset='UTF-8'>
<title>Sinatra_test</title>
<script src='/javascripts/app.js'></script>
</head>
<body>
<h1>タスク管理アプリ</h1>
<input type='text' id='input-task-form'>
<button id='add-task-button'>追加</button>
<table>
<thead>
<tr>
<th>タイトル</th>
<th>作成日時</th>
<th></th>
</tr>
</thead>
<tbody>
<% @tasks.each do |task| %>
<tr>
<td><%= task['title'] %></td>
<td><%= Time.parse(task['created_at']).strftime('%Y-%m-%d %H:%M:%S') %></td>
<td><button id=<%= "delete-task-button#{task['id']}" %> data-task-id=<%= task['id'] %>>削除</button></td>
</tr>
<% end %>
</tbody>
</table>
</body>
</html>
document.addEventListener('DOMContentLoaded', () => {
const inputForm = document.getElementById('input-task-form');
const addButton = document.getElementById('add-task-button');
const deleteButtons = document.querySelectorAll('[id^="delete-task-button"]');
addButton.addEventListener('click', async () => {
const taskContent = inputForm.value;
try {
const response = await fetch('/tasks/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({title: taskContent})
});
const data = await response.json();
location.reload();
} catch (error) {
console.error('Error:', error);
}
});
document.body.addEventListener('click', async (event) => {
if (event.target.matches('[id^="delete-task-button"]')) {
const button = event.target;
const taskId = button.getAttribute('data-task-id');
try {
const response = await fetch(`/tasks/${taskId}`, {
method: 'DELETE'
});
const data = await response.json();
location.reload();
} catch (error) {
console.error('Error:', error);
}
}
});
});
困ったこと・直面したエラー
-
docker compose up
した後(=Pumaサーバを起動した後)の変更が、一度buildしなおさないと反映されない
⇒compose.yamlのwebにvolumesを追加していなかったため。
volumesを追加すると、指定したローカルのフォルダとコンテナ上のフォルダがボリュームマウントされる。ボリュームマウントとは、コンテナ内の特定のパスをホストマシン(コンテナ外)のファイルシステムにマッピングする機能で、ホストマシン上でのファイルの変更がリアルタイムでコンテナ内に反映される。
-
Error: SyntaxError: Unexpected token 'a', "application/json" is not valid JSON
⇒
app.rbで戻り値がJSON形式でなかったため
app.jsで、await response.json()としているので、JavaScriptはJSON形式のデータが返ってくるまで待機している。一方でapp.rbに下記コードが無かったため、JSON以外のデータが返ってきているというエラーが出ていた。
content_type :json { status: 'success', message: 'Task added successfully' }.to_json
-
アプリ上でタスクを作成したときにIDが重複しているため作成できないエラーが出た
⇒
ダミーデータを作成する際にPostgreSQLで直接作成し、その際にIDを手打ちで作成したため
IDはSERIALなど自動で一意な番号が振られる型にするので原則手打ちで入力しない。今回はPostgreSQLにアクセスし、ダミーデータを全て削除してからアプリ上でタスクを作成したら問題なく作成できた。
参考にしたサイト
Docker関係
基本的にはDockerドキュメントとSinatra公式を参照
- Dockerの使い方・概念の解説・コマンド
- Dockerfileについての解説
- composeについて
Sinatra関係
- Sinatra + Dockerの開発について
- Sinatra自体について