0
Help us understand the problem. What are the problem?

posted at

updated at

DockerでRails環境(Nginx+Unicorn+MySQL)を構築してFargateへデプロイするまで〜開発環境編〜

今回、個人的な学習として、ローカル環境で動かすRails環境(Nginx+Unicorn+MySQL)をDockerで構築しましたので、備忘録&学習のアウトプットとしてこの記事を作成しました。

最終的にはECS(Fargate)を本番環境としてデプロイすることをゴールとしており、
そちらの記事については現在作成中です。

参考

初学者で知らない人はいないであろう神記事。アプリ作成全体的に参考にさせていただきました。本番環境編のcredentialsの部分もお世話になっています。
【2021年リライト版】 世界一丁寧なAWS解説。EC2を利用して、RailsアプリをAWSにあげるまで

Nginx&Unicorn設定で参考にさせていただきました。
【Nginx+Unicorn】 サーバ起動手順まとめ

DBの起動について参考にさせていただきました。(後ほど紹介します)
docker-compose でデータベースなどの起動を待つには wait-for-it スクリプトを使おう
dockerizeでコンテナが立ち上がる順番を制御してみた

開発環境

  • M1Mac
  • Visual Studio Code
  • docker (version 20.10.17)

今回の構成

構成は以下のようになっています。
今回は.prdのファイルは出てきませんが、本番環境編にて紹介いたします。

  • Ruby 3.1
  • Rails 7.0.3
  • Nginx
  • Unicorn
  • MySQL 8
.(各Railsファイル)
├── config
|   └── datebase.yml
|   └── unicorn.rb
├── nginx
|    ├── Dockerfile.dev
|    ├── (Dockerfile.prd)
|    └── nginx.conf
├── docker-compose.yml
├── Dockerfile.dev
├── (Dockerfile.prd)
├── Gemfile
└── Gemfile.lock

1. Rails用Dockerfile(Gemfile&Gemfile.lock)の作成

まずはアプリのルートディレクトリを作成します。
こちらはそれぞれのアプリ名に適宜読み替えて入力してください。

terminal
mkdir three-mouth-ago   ←アプリ名(読み替え)
cd three-mouth-ago

Dockerfile、Gemfile、Gemfile.lockの作成
(本番環境ではDockerfile.prdを使用するため、一旦devで作成します。)

terminal
touch Dockerfile.dev
touch Gemfile
touch Gemfile.lock

Dockerfile

Dockerfile
FROM ruby:3.1

#環境変数
ENV APP="/three-mouth-ago"  \
        CONTAINER_ROOT="./" 

#ライブラリのインストール
RUN apt-get update && apt-get install -y \
        nodejs \
        mariadb-client \
        build-essential \
        wget \
        yarn 

# 実行するディレクトリの指定
WORKDIR $APP
COPY Gemfile Gemfile.lock $CONTAINER_ROOT
RUN bundle install

Gemfile

Gemfile
source "https://rubygems.org"
gem "rails", "~> 7.0.3"

2. docker-compose.ymlの作成

docker-compose.ymlファイルを作成&編集します。

terminal
touch docker-compose.yml
docker-compose.yml
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    tty: true
    stdin_open: true
    ports:
      - '3000:3000'
    volumes:
      - '.:/three-mouth-ago' #任意のアプリ名

docker-compose.yml作成後、コマンドでコンテナに入ります。

terminal
docker-compose up -d
docker-compose exec web bash

3. railsのセットアップ

新規Railsアプリケーションの作成

※MySQLを使用するため、DBにMySQLを指定

terminal
rails new . --force --database=mysql --skip=bundle

railsアプリの各ファイルの作成&編集

  • database.yml
  • Nginx
  • Unicorn

database.yml

database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: <%= ENV.fetch("DB_USERNAME", "root") %>
  password: <%= ENV.fetch("DB_PASSWORD", "password") %>
  host: <%= ENV.fetch("DB_HOST", "db") %>

development:
  <<: *default
  database: three_mouth_ago_development

test:
  <<: *default
  database: three_mouth_ago_test

production:
  <<: *default
  database: <%= ENV["DB_DATABASE"] %>
  username: <%= ENV["DB_USERNAME"] %>
  password: <%= ENV["DB_PASSWORD"] %>
  host: <%= ENV['DB_HOST'] %>

Nginx

ホームディレクトリにnginxフォルダを作成し、Nginx用Dockerfileと設定ファイルを作成します。

terminal
mkdir nginx
touch nginx/Dockerfile.dev
touch nginx/nginx.conf
Dockerfile.dev
FROM nginx:stable
#デフォルトのnginxファイルを削除して作成したものコンテナないにコピー
RUN rm -f /etc/nginx/conf.d/*
#自分のapp名.confを記述
COPY /nginx.conf /etc/nginx/conf.d/three-mouth-ago.conf
#-c以降の設定ファイルを指定して起動 daemon offでフォアグラウンドで起動
CMD /usr/sbin/nginx -g 'daemon off;' -c /etc/nginx/nginx.conf
nginx.conf
upstream unicorn {
  #ユニコーンソケットの設定
  server unix:/three-mouth-ago/tmp/sockets/.unicorn.sock fail_timeout=0;
}

server {
  #IPとポートの指定
  listen 80 default;
  #サーバーネームの指定
  server_name localhost;

  access_log /var/log/nginx/access.log;
  error_log  /var/log/nginx/error.log;
  #ドキュメントルートの指定
  root /myapp-miguchi/public;

  client_max_body_size 100m;
  error_page 404             /404.html;
  error_page 505 502 503 504 /500.html;
  try_files  $uri/index.html $uri @unicorn;
  keepalive_timeout 5;

  location @unicorn {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_pass http://unicorn;
  }
}

Unicorn

Unicornの設定ファイルである、unicorn.rbをconfig配下に作成します。

terminal
touch config/unicorn.rb
unicorn.rb
# frozen_string_literal: true

# ワーカーの数。後述
$worker = 2
# 何秒経過すればワーカーを削除するのかを決める
$timeout = 30
#自分のアプリケーションまでのpath
$app_dir = '/three-mouth-ago'
# リクエストを受け取るポート番号を指定。後述
$listen = File.expand_path 'tmp/sockets/.unicorn.sock', $app_dir
# PIDの管理ファイルディレクトリ
$pid = File.expand_path 'tmp/pids/unicorn.pid', $app_dir
# エラーログを吐き出すファイルのディレクトリ
$std_log = File.expand_path 'log/unicorn.log', $app_dir

# 上記で設定したものが適応されるよう定義
worker_processes  $worker
working_directory $app_dir
stderr_path $std_log
stdout_path $std_log
timeout $timeout
listen  $listen
pid $pid

# ホットデプロイをするかしないかを設定
preload_app true

# fork前に行うことを定義。後述
before_fork do |server, _worker|
  defined?(ActiveRecord::Base) && ActiveRecord::Base.connection.disconnect!
  old_pid = "#{server.config[:pid]}.oldbin"
  if old_pid != server.pid
    begin
      Process.kill 'QUIT', File.read(old_pid).to_i
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end
end

after_fork do |server, worker|
  defined?(ActiveRecord::Base) and ActiveRecord::Base.establish_connection
end

Gemfileにunicornを追加

Gemfile
gem 'unicorn'

webコンテナを抜けます。

terminal
exit

4. docker-compose.ymlの修正&ローカルホストで確認

先程のdocker-compose.ymlの記述はwebコンテナに入るためだけのものだったため、
今度はDBとNginxのコンテナも動かす記述にしていきます。

docker-compose.yml
version: '3'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: > 
      bash -c "/three-mouth-ago/wait-for-it.sh db:3306 --timeout=30 --strict -- 
      bundle exec rails db:create &&
      bundle exec unicorn -p 3000 -c /three-mouth-ago/config/unicorn.rb"
    tty: true
    stdin_open: true
    ports:
      - '3000:3000'
    volumes:
      - '.:/three-mouth-ago' #任意のアプリ名
      - tmp-data:/three-mouth-ago/tmp/sockets #ソケット通信用ファイルをnginxコンテナと共有
      - public-data:/three-mouth-ago/public #画像データなどをnginxと共有
    depends_on:
      - db
    links:
      - db
    environment:
      DB_USER: root
      DB_PASSWORD: root
      DB_HOST: db

  db:
    platform: linux/x86_64 #M1チップ対応のため追記
    restart: always
    image: mysql:8
    ports:
      - 3306:3306
    volumes:
      - mysql-data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root
      TZ: Asia/Tokyo

  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile.dev
    ports:
      - 80:80
    restart: always #明示的にstopさせるまでリスタートする。(失敗するたび遅延あり)
    volumes:
      - tmp-data:/three-mouth-ago/tmp/sockets
      - public-data:/three-mouth-ago/public
    depends_on:
      - web 

volumes:
  mysql-data:
  tmp-data:
  public-data:

webのcommandにてwait-for-it.shを起動させています。ここではdbが立ち上がる前にunicornコマンドが実行されないよう、コンテナ起動後30秒待ってもらう処理をしています。
dbが立ち上がる前にunicornが起動してしまうと、dbに接続できず、webコンテナがexitしてしまいます。この処理についてはdockerizeで制御するという方法もあり、以下の記事が参考になります。

参考記事:
docker-compose でデータベースなどの起動を待つには wait-for-it スクリプトを使おう
dockerizeでコンテナが立ち上がる順番を制御してみた

それではそのwait-for-it.shを作成していきます。

terminal
touch wait-for-it.sh
chmod +x wait-for-it.sh
wait-for-it.sh
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available

WAITFORIT_cmdname=${0##*/}

echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }

usage()
{
    cat << USAGE >&2
Usage:
    $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
    -h HOST | --host=HOST       Host or IP under test
    -p PORT | --port=PORT       TCP port under test
                                Alternatively, you specify the host and port as host:port
    -s | --strict               Only execute subcommand if the test succeeds
    -q | --quiet                Don't output any status messages
    -t TIMEOUT | --timeout=TIMEOUT
                                Timeout in seconds, zero for no timeout
    -- COMMAND ARGS             Execute command with args after the test finishes
USAGE
    exit 1
}

wait_for()
{
    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
        echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
    else
        echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
    fi
    WAITFORIT_start_ts=$(date +%s)
    while :
    do
        if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
            nc -z $WAITFORIT_HOST $WAITFORIT_PORT
            WAITFORIT_result=$?
        else
            (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
            WAITFORIT_result=$?
        fi
        if [[ $WAITFORIT_result -eq 0 ]]; then
            WAITFORIT_end_ts=$(date +%s)
            echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
            break
        fi
        sleep 1
    done
    return $WAITFORIT_result
}

wait_for_wrapper()
{
    # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
    if [[ $WAITFORIT_QUIET -eq 1 ]]; then
        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
    else
        timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
    fi
    WAITFORIT_PID=$!
    trap "kill -INT -$WAITFORIT_PID" INT
    wait $WAITFORIT_PID
    WAITFORIT_RESULT=$?
    if [[ $WAITFORIT_RESULT -ne 0 ]]; then
        echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
    fi
    return $WAITFORIT_RESULT
}

# process arguments
while [[ $# -gt 0 ]]
do
    case "$1" in
        *:* )
        WAITFORIT_hostport=(${1//:/ })
        WAITFORIT_HOST=${WAITFORIT_hostport[0]}
        WAITFORIT_PORT=${WAITFORIT_hostport[1]}
        shift 1
        ;;
        --child)
        WAITFORIT_CHILD=1
        shift 1
        ;;
        -q | --quiet)
        WAITFORIT_QUIET=1
        shift 1
        ;;
        -s | --strict)
        WAITFORIT_STRICT=1
        shift 1
        ;;
        -h)
        WAITFORIT_HOST="$2"
        if [[ $WAITFORIT_HOST == "" ]]; then break; fi
        shift 2
        ;;
        --host=*)
        WAITFORIT_HOST="${1#*=}"
        shift 1
        ;;
        -p)
        WAITFORIT_PORT="$2"
        if [[ $WAITFORIT_PORT == "" ]]; then break; fi
        shift 2
        ;;
        --port=*)
        WAITFORIT_PORT="${1#*=}"
        shift 1
        ;;
        -t)
        WAITFORIT_TIMEOUT="$2"
        if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
        shift 2
        ;;
        --timeout=*)
        WAITFORIT_TIMEOUT="${1#*=}"
        shift 1
        ;;
        --)
        shift
        WAITFORIT_CLI=("$@")
        break
        ;;
        --help)
        usage
        ;;
        *)
        echoerr "Unknown argument: $1"
        usage
        ;;
    esac
done

if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
    echoerr "Error: you need to provide a host and port to test."
    usage
fi

WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}

# Check to see if timeout is from busybox?
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)

WAITFORIT_BUSYTIMEFLAG=""
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
    WAITFORIT_ISBUSY=1
    # Check if busybox timeout uses -t flag
    # (recent Alpine versions don't support -t anymore)
    if timeout &>/dev/stdout | grep -q -e '-t '; then
        WAITFORIT_BUSYTIMEFLAG="-t"
    fi
else
    WAITFORIT_ISBUSY=0
fi

if [[ $WAITFORIT_CHILD -gt 0 ]]; then
    wait_for
    WAITFORIT_RESULT=$?
    exit $WAITFORIT_RESULT
else
    if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
        wait_for_wrapper
        WAITFORIT_RESULT=$?
    else
        wait_for
        WAITFORIT_RESULT=$?
    fi
fi

if [[ $WAITFORIT_CLI != "" ]]; then
    if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
        echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
        exit $WAITFORIT_RESULT
    fi
    exec "${WAITFORIT_CLI[@]}"
else
    exit $WAITFORIT_RESULT
fi

docker-composeでコンテナを立ち上げます。

terminal
docker-compose up --build -d

初回はDBコンテナの立ち上げに時間がかかり、webコンテナがexitしてしまう可能性があります。その場合は、もう一度上記のコマンドで立ち上げてみてください。
その後、ローカルホストにアクセスできれば成功です!

http://localhost:3000
You are on rails.png
ここからは簡単なアプリの作成のため、個人でアプリを作成される方はここまでとなります。

5.アプリの構築

それではもう一度webコンテナに入ります。

terminal
docker-compose exec web bash

productというアプリを作成し、DBにmigrateします。

terminal
rails g scaffold product name:string price:integer vendor:string
rails db:migrate

以下にアクセスし、表示されれば成功です!
http://localhost:3000/products
products.png
終了する際は以下のコマンドで終了しましょう。

terminal
#コンテナから出る
exit

docker-compose down --volumes

この個人的な学習はECSにアプリをデプロイしてみたいなあという思いで始めましたが、
学習を始めた当初、開発環境と本番環境で分ける想定はしていませんでした。
ローカルで使用するDockerfileのimageをECSに乗っければ、
デプロイできると思っていたためです。

しかし、ECSへのデプロイは容易いモノではありませんでした。。
AWS編へ続きます。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
0
Help us understand the problem. What are the problem?