20200226のdockerに関する記事は7件です。

SEしてるけど実はあんまりコード書いたことないんだよねって人に捧ぐ、Rails on Dockerハンズオン vol.9 - Sign in -

はじめに

第9回目です。
前回はサインアップ機能を作ってみましたね。今回はサインインやサインアウト機能を作ってまいります。
今後、サインインしていないと使えない機能や見れないページなども作っていきますので、アプリがユーザーのサインイン状況を知ることができてサインインしているユーザーを特定できるようにしましょう!

セッション

RailsはRESTfulなWebアプリケーションフレームワークなのでステートレスなアプリです。
しかし、サインイン機能を実装するとなるとそのユーザーがサインイン状態であることをアプリが知る必要がありますし、アプリはそのユーザーがどのユーザーなのかを特定する必要があります。
このために使われるのがセッション(Session)です。

Railsではセッションを管理する便利なsessionメソッドが用意されています。
今回はこのsessionメソッドを使ってサインイン状態を管理し、マイページ機能、つまりサインインしているユーザーのユーザー詳細ページに遷移する機能を作っていきましょう。
サインインしていない場合はユーザーを特定できないので、マイページには遷移させずトップページにリダイレクトをかけるようにもしていきます。

このように、セッションでサインイン状態を管理できるようになれば、その状態によるページや動作の出しわけやサインインユーザーに合わせたコンテンツの出しわけが可能になります。

今回はセッションを一つのリソースと捉えて実装していきます。
つまり、サインインしたときにsessions#createでセッションを作成したり、サインアウトしたときにSessions#destroyでセッションを削除したりさせます。

サインインページを作ろう

セッションはSessionsコントローラーで管理してきます。
アクションとしては、

  • new: サインインページに遷移
  • create: セッション作成(サインイン処理)
  • destroy: セッション削除(サインアウト処理)

が必要になってきます。
今回はrails gコマンドに頼らずにディレクトリやファイルを作成していきますね。

ルーティングを作成する

まずはルーティングを作成しましょう。

config/routes.rb
Rails.application.routes.draw do
  ...
  get     '/sign_in',   to: 'sessions#new',     as: :sign_in
  post    '/sign_in',   to: 'sessions#create',  as: :create_session
  delete  '/sign_out',  to: 'sessions#destroy', as: :sign_out
end

この辺りはもう慣れてきましたよね。

コントローラーを作成する

お次はコントローラーを作ります。

# touch app/controllers/sessions_controller.rb
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
    @user = User.new
  end 

  def create
  end

  def destroy
  end
end

とりあえずルーティングと対応したアクションを記述しておきます。
newアクション、つまりサインインページではまたUserモデルを扱います(ユーザーのサインイン情報を入力してもらうので)。
そのため、newアクションはUsersコントローラーと同じように@user = User.newを記述してます。

サインインページを作成する

さて、newアクションがレンダリングするnew.html.erbをコーディングしてきましょう。

まずは、Sessionsコントローラーのビューファイルを格納するディレクトリを作成し、ビューファイルを作りましょう。

# mkdir app/views/sessions
# touch app/views/sessions/new.html.erb

さて、ビューファイルをコーディングしてみます。

app/views/sessions/new.html.erb
<div class="container">
  <h1 class="my-5">Sign in</h1>

  <%= form_with model: @user, url: create_session_path, local: true do |form| %>
    <div class="form-group">
      <%= form.label :email %>
      <%= form.text_field :email, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= form.label :password %>
      <%= form.password_field :password, class: "form-control" %>
    </div>
    <div class="form-check">
      <%= check_box_tag :visible_password, :visible, false, class: "form-check-input" %>
      <%= label_tag :visible_password, "パスワードを表示する" %>
    </div>

    <div class="form-group mt-5">
      <%= form.submit "Sign in", class: "btn btn-primary form-control" %>
    </div>
  <% end %>
  <p class="text-center">登録がまだの方は<%= link_to "こちら", sign_up_path %></p>
</div>

<%= javascript_pack_tag 'visible_password' %>

基本的には前回のサインアップページと同じですね。今回はサインインなのでemailpasswordを入力項目に指定しました。
また、passwordはサインアップページと同様、チェックボックスで表示非表示を変更できるようにしています。

『Sign in』ボタンの下にまだサインアップが終わっていないユーザー向けにサインアップページへのリンクを追加しています。
<%= link_to "こちら", sign_up_path %>だけで適切なaタグを作成してくれるのはやはり便利ですね。

一度http://localhost:3000/sign_inにアクセスしておきましょう。以下のようなページが表示されたら、ここまでのコーディングは成功です!
image.png

サインインページへのリンクを作る

サインインページの形が出来上がってきたので、ここでサインインページへのリンクを以下のページにつけていこうと思います。

  • ヘッダーの「Sign in」リンク
  • サインアップページの下部に「登録済みの方はこちら」リンクを設置

ヘッダーの「Sign in」リンク

ヘッダーの「Sign in」リンクはまだ遷移先を指定できていませんでしたね。
やっとサインインページができあがってきたので遷移先としてsign_in_pathを指定しておきます。

app/views/layouts/application.html.erb
...
<li class="nav-item"><%= link_to "Sign in", sign_in_path, class: "nav-link" %></li>
...

コーディングが終わったら、一度トップページに遷移してヘッダーの「Sign in」リンクを選択してみましょう。
サインインページに遷移できたら成功です!

サインアップページの下部に「登録済みの方はこちら」リンクを設置

サインインページで「登録がまだの方はこちら」リンクを設置したように、サインアップページにも「登録済みの方はこちら」リンクを設置してみましょう。

app/views/users/new.html.erb
...
    <div class="form-group mt-5">
      <%= form.submit "Sign up!", class: "form-control btn btn-primary" %>
    </div>
  <% end %>
  <p class="text-center">登録済みの方は<%= link_to "こちら", sign_in_path %></p>
...

こちらもサインアップページで「こちら」をクリックしてサインインページに遷移できれば成功です!

サインイン処理を作成する

ここまででサインインページの形ができあがりました。
次はサインイン処理(create)をつくっていきます!

サインイン処理ではサインインページで入力されたemailpasswordからユーザーを検索します。
ユーザーがヒットすればそのユーザーのユーザー詳細ページ(users#show)、ユーザーが存在しない、またはパスワードが誤っているような場合はエラーメッセージが表示されるようにしましょう。

少しおさらいですが、モデルを検索する場合はfind_byメソッドを使うことで指定した属性に対して検索をすることができました。
さらに、has_secure_passwordをもつモデルはauthenticateメソッドを使ってパスワード認証ができることも思い出しましょう。
この2つを組み合わせることでユーザーの検索し認証することができそうですね。

では、createアクションを記述していきます。

app/controllers/sessions_controller.rb
...
def create
  @user = User.new(email: params[:user][:email])
  user = User.find_by(email: @user.email.downcase)
  if user && user.authenticate(params[:user][:password])
    flash[:success] = "サインインしました。"
    redirect_to user
  else
    flash.now[:danger] = "#{User.human_attribute_name(:email)}または#{User.human_attribute_name(:password)}をもう一度確認してください。"
    render :new
  end
end
...

少し複雑に見えるかもしれません。1行ずつ解説していきますね。

@user = User.new(email: params[:user][:email])

まず、@userに新しくUserモデルを代入しています。属性値はemailparams[:user][:email]を持っています。
paramsはフォームからのリクエストの値を取得するメソッドで、フォームのinputタグでname="user[email]"と定義されている値は[:user][:email]から取得することができます。
@userparams[:user][:email]の属性値をもつUserモデルを代入しているのは、この処理がエラー(ユーザーが見つからない or パスワード認証が通らない)の場合に今入力したemailをデフォルトでフォームに入力された状態でサインインページを再度表示するためです。
これがないと入力したemailをキープする方法がなくてユーザーは毎回メールアドレスを再入力する手間になってしまいます。(パスワードは特性上、キープしないように作っています)

user = User.find_by(email: @user.email.downcase)

先ほど@userに設定したemailを使ってUserを検索しています。
Userモデルを作成するときにモデル側でemailを小文字化してからDBに保存するようにコーディングしたことを覚えているでしょうか?
今DBには必ず全て小文字のメールアドレスが保存されているので、find_byemailを検索する時もdowncaseで検索文字列を小文字化して検索しています。
find_byは検索対象が存在していた場合はモデルオブジェクトを返却し、存在しない場合はnilを返却するメソッドであることも改めて意識しましょう。

if user && user.authenticate(params[:user][:password])
  # trueの処理
else
  # falseの処理
end

次に条件分岐を設けています。&&は「アンド条件」、「かつ」を意味していますので、

  • user
  • user.authenticate(params[:user][:password])

の両方を満たした場合はtrue、どちらか一方でも満たさない場合はfalseの処理に分岐します。

まずuserの条件式をみてみます。
これはusernilfalseでないかどうかを検証しています。
先ほどfind_byは検索結果があればモデルオブジェクト、なければnilを返却するといいました。
なのでこのuserの条件はfind_byの結果そのemailをもつユーザーがそもそも存在しない場合falseの処理を実行するようにするための条件ということになります。

次にuser.authenticate(params[:user][:password])の条件式をみてみます。
これは順番的にuserの条件式を満たした場合に検証される条件式です。なのでuserにはUserモデルのモデルオブジェクトが入っています。
authenticateメソッドはhas_secure_passwordの便利機能の一つで、平文のpasswordを与えるとハッシュ化してDBに格納しているpassword_digestと照会し、同一であればモデルオブジェクトを、そうでなければfalseを返却してくれるものでした。

以上より、このif文の条件式の条件分岐は以下の通りになります。

入力したemailのユーザーが パスワードが 実行する処理
いない - falseの処理
いる 誤っている falseの処理
いる 正しい trueの処理

やりたいことに合致してますね。
ではtrueの処理、falseの処理をそれぞれみてみましょう。

flash[:success] = "サインインしました。"
redirect_to user

trueの処理はとても単純です。
検索し認証したユーザーのユーザー詳細ページにリダイレクトさせているだけです。flashでサインインメッセージも定義してますね。

flash.now[:danger] = "#{User.human_attribute_name(:email)}または#{User.human_attribute_name(:password)}をもう一度確認してください。"
render :new

falseの処理も最終的にはrender :newで再度サインインページを表示させていることがわかります。
その前にflash.nowメソッドを使ってエラー文を用意しています。
前回のUsersコントローラーのcreateアクションでもflashメソッドを使ってサインアップ成功時のメッセージをユーザー詳細ページに一時的に表示させることをしました。
flash.nowはそれのrender時に利用する版と思ってください。keydangerにしているのは前回と同じでBootstrapの命名規則に則っています。

flash.nowのメッセージの中身が若干複雑ですね。
まず、Rubyでは文字列の中に変数を入れる場合#{変数}と記述することができます。
"aaa" + 変数 + "bbb"のような書き方もできますが、"aaa#{変数}bbb"の方がよりスマートな気がしますよね。
その変数はUser.human_attribute_name(属性名)となっています。
human_attribute_nameメソッドは引数の属性のi18nで定義した文字列を返してくれます。ちょうどform.labelと同じような感じですね。
なので今回の場合、User.human_attribute_name(:email)は『メールアドレス』、User.human_attribute_name(:password)は『パスワード』が変数の結果として当て込まれます。

ここまででサインイン処理(createアクション)をみてきました。
最後にflash.nowのメッセージを表示するコードが今のsessions/new.html.erbにはないので、前回のusers/new.html.erbに倣って記述します。

app/views/sessions/new.html.erb
...
<h1 class="my-5">Sign in</h1>

<% flash.each do |msg_type, msg| %>
  <div class="alert alert-<%= msg_type %>"><%= msg %></div>
<% end %>

<%= form_with model: @user, url: create_session_path, local: true do |form| %>
...

動作確認

では、サインイン処理を動作確認してみましょう。
前回DBをリセットしてますので、もう一度John SmithさんをDBに登録しておきます。

> User.create(name: "John Smith", email: "john@sample.com", password: "password")

http://localhost:3000/sign_inにアクセスして色々とチェックしてみましょう!

エラー系

emailpasswordを入力していない

image.png
何も入力していない、またはどちらかだけでも入力していない場合はfind_byauthenticateのいずれかが必ず失敗するのでエラーメッセージが表示されていますね。

入力したemailのユーザーがいない

test@test.comみたいな適当なemailを入力して確認。
image.png
エラーメッセージが表示されているし、emailのテキストフィールドに入力してたtest@test.comが残っているのも確認できましたね。

emailpasswordの組み合わせが異なっている

john@sample.comと適当なパスワードjohn1234を入力して確認してみましょう。
image.png
こちらもエラーメッセージが表示されていますね。emailjohn@sample.comも残っています。

正常系

期待動作はjohn@sample.com&passwordでユーザー詳細ページに遷移することです。
やってみましょう!
image.png
ちゃんと期待通りの動作になりましたね。

セッション管理する

ここまででパスワード認証のロジックができあがりましたね。

ただ今のままでは、「認証」自体はできましたが「認証済み」という状態を管理することはできてません。
「認証済み」という状態を管理するためにセッション管理をする機能を作っていきます。

ちなみにRailsのsessionメソッドでは、半永続的で暗号化された一時Cookieが払い出されます。
これは仮にこのCookieが盗まれたとしても、それを使ってサインインを乗っ取ることはできないようになっています。
その代わりと言ってはなんですが、一度ブラウザを閉じると自動的に消える仕組みになっています。

SessionsHelperを作成する

セッション管理するためにsign_inメソッドやsign_outメソッドなどを自前で実装していきます。
これらはコントローラーの元になるapplication_controller.rbにコーディングしていきたいところですが、それではapplication_controller.rbに多くのメソッドを記述する必要がでてくるため可読性が確保できなくなる恐れがあります。

今回は、新たにSessionsHelperを作成し、その中でsign_inメソッド、sign_outメソッドを定義します。
このSessionsHelperをApplicationControllerが読み込んでコントローラー内でメソッドを利用できるようにすることで、可読性を損なうことなく機能を実装していきましょう。

まずSessionsHelperファイルを作成します。

# touch app/helpers/sessions_helper.rb
app/helpers/sessions_helper.rb
module SessionsHelper
end

ヘルパーはmodule [helper_name]で定義します。
最後に、ヘルパーをApplicationControllerから読み取るように定義しましょう。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

SessionsHelperでsign_inメソッドを作成する

次に、SessionsHelperを編集して、sign_inメソッドを作成していきます。
Railsではsessionメソッドが用意されており、簡単にセッションを管理することができます。

app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    session[:user_id] = user.id
  end

end

これだけで、sign_inメソッドを使ってuser.idをセッションに記録することができます。

サインインページでサインインが成功した場合にsign_inメソッドを呼び出してセッションを記録するようにしてみます。

app/controllers/sessions_controller.rb
...
def create
  ...
  if user && user.authenticate(params[:user][:password])
    flash[:success] = "サインインしました。"
    sign_in user
    redirect_to user
  else
  ...
end
...

redirect_toの前にsign_in userを追加しただけです。
SessionsControllerはApplicationControllerを継承しているのでApplicationControllerで読み込んでいるSessionsHelperを利用することができます。

current_userメソッドを作成する

ここまででサインインしたときにセッション情報としてサインインユーザーのidをCookieに格納できるようになりました。
今度はこれを使って、サインイン中のユーザー情報をアプリが特定するためのcurrent_userメソッドを作成しましょう。
このcurrent_userメソッドを使うことで、例えば<%= current_user.name %>でサインイン中のユーザーの名前を表示できるようにしたりします。

今、セッションにuser_idのキーに対してuser.idを格納しています。これはsession[:user_id]で取り出すことができます。
このことからUser.find(session[:user_id])またはUser.find_by(id: session[:user_id])でサインイン中のユーザーを特定できることを思いつきますね。
ただ、findメソッドはNotFoundの場合に例外が発生します。find_byメソッドの場合はNotFoundでもnilを返却するだけなのでif文などで簡単にエラー時の処理を実装できるのですが、例外の場合はちょっとクセがありますね。
今回はfind_byメソッドで実装するようにしましょう。

では、SessionsHelperに定義します。

app/helpers/sessions_helper.rb
module SessionsHelper
  ...
  def current_user
    @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
  end
  ...
end

少しつまずきそうなコードですね。ちょっと紐解きます。

まずわかりやすいところから、一番後ろにif session[:user_id]と記入しています。
これはこれよりも前の文章が実行される条件式になっています。session[:user_id]が存在する、つまりサインイン済みの状態であればこれよりも前の文章が実行されるというわけです。
current_userメソッドではこの1行以外にコードはないので、session[:user_id]が存在しない場合は何も実行されず、nilが返却されるようになります。

次に見慣れない||=をみてみてましょう。
これは見慣れないと思いますが、少しプログラミングをかじったことがある人であれば何かの言語で+=のような表現を見たことがあるのではないでしょうか?
Rubyでも同じような挙動をとりますが、a += ba = a + bと同義になりますね。
||はORを表す演算子ですので、a ||= ba = a || bと同義になります。
current_userは上で少しお話したように、<%= current_user.name %><%= current_user.email %>のように使われるケースを考えています。
これが同じビューファイルで2回呼び出された場合、User.find_by(id: session[:user_id])とだけコーディングしていた場合は2回ともDBから情報を取得しなくてはならなくなります。
そこで@current_user ||= User.find_by(id: session[:user_id])としておくことで、一度目に<%= current_user.name %>時に@current_userがセットされるため二度目の<%= current_user.email %>の場合はDBにアクセスすることなく前回の検索時のモデルオブジェクトを再利用することができるようになります。
DBの負荷軽減やDBアクセスの回数削減によるレスポンス向上を目的として、こういった記述にしてみました。

まとめると、current_userメソッドは呼び出されたときに以下の動作をします。

サインイン ユーザー 処理
- nil
NotFound nil
Found サインイン済みユーザーのモデルオブジェクト

書き方を変えると以下と同じですね。

def current_user
  if session[:user_id]
    if @current_user
      @current_user
    else
      User.find_by(id: session[:user_id])
    end
  end
end

KStep取れそうですね。笑
上の書き方の方がシンプルで可読性高いですよね。

サインイン状態を確認するsigned_in?メソッドを作る

どんどんいきましょう。
次にサインイン状態をtruefalseで返却するsigned_in?メソッドを作ってみます。
このメソッドによってユーザーのサインイン状態に合わせた処理を簡単に実装することができます。

先ほどのcurrent_userメソッドはユーザーがサインインしている場合はそのユーザーのモデルオブジェクト、サインインしていない場合はnilを返却するメソッドでした。
そしてRailsにはnilの場合trueを、そうでない場合falseを返却するnil?メソッドが用意されています。
さらに!truefalseが入れ替わる否定演算子です。

これらを組み合わせればsigned_in?メソッドが作れそうですね。

app/helpers/sessions_helper.rb
module SessionsHelper
  ...
  def signed_in?
    !current_user.nil?
  end
  ...
end

サインアウトするsign_outメソッドを作成する

最後にサインアウトするメソッドとしてsign_outメソッドを作ってみましょう!

app/helpers/sessions_helper.rb
module SessionsHelper
  ...
  def sign_out
    session.delete(:user_id)
    @current_user = nil
  end
  ...
end

なんとなーくわかるとおもいますが、session.delete(:user_id)でCookieに保存していたsession[:user_id]を削除してます。
さらにサインイン中は@current_userにサインインしているユーザーのモデルオブジェクトが格納されているのでこれもnilに初期化しています。

さてさて、ここまででサインインに関して最低限必要な全てのメソッドを作成できました。
サインインの状態に合わせてヘッダーを出しわけしてみましょう!

ヘッダーを更新する

ヘッダーを以下の条件で出しわけしてみます。

  • 未サインイン
    • Home: トップページに遷移する
    • Sign in: サインインページに遷移する
  • サインイン済
    • Profile: サインインしたユーザーのユーザー詳細ページに遷移する
    • Sign out: サインアウトしてトップページに遷移する

まずは、ヘッダーをsigned_in?メソッドを使って出しわけしてみます。

app/views/layouts/application.html.erb
...
<ul class="navbar-nav">
  <% if signed_in? %>
    <%# サインイン済みの場合のリンク %>
    <li class="nav-item"><%= link_to "Profile", current_user, class: "nav-link" %></li>
    <li class="nav-item"><%= link_to "Sign out", sign_out_path, method: :delete, class: "nav-link" %></li>
  <% else %>
    <%# 未サインインの場合のリンク %>
    <li class="nav-item"><%= link_to "Home", root_path, class: "nav-link" %></li>
    <li class="nav-item"><%= link_to "Sign in", sign_in_path, class: "nav-link" %></li>
  <% end %>
</ul>
...

今までのヘッダーは未サインインの場合のリンクになっていたので、signed_in?falseの場合の方に記述してます。

サインイン済みの場合は新たに「Profile」と「Sign out」のリンクを作っています。

<li class="nav-item"><%= link_to "Profile", current_user, class: "nav-link" %></li>

「Profile」リンクの方は今までとほぼほぼ変わらない書き方ですね。少し違う点としてはroot_pathsign_in_pathのように遷移先としてpathを指定するのではなくcurrent_userというモデルオブジェクトを指定している点です。
link_toメソッドでは、遷移先としてモデルオブジェクトが指定された場合、そのモデルオブジェクトの参照パスに読み替えてくれます。つまり、user_path(current_path)と同義になります。

<li class="nav-item"><%= link_to "Sign out", sign_out_path, method: :delete, class: "nav-link" %></li>

「Sign out」リンクの方はmethod: :deleteの箇所が今までとは異なります。
通常link_toメソッドはGETメソッドでリンク先にリクエストしますが、method: [method]で指定することで別のメソッドでリクエストすることができます。
sign_out_pathroutes.rbdeleteメソッドでルーティングするように定義していましたので、この形でlink_toのHTTPメソッドを指定してあげないとルーティングされなくなってしまいます。

そういえば、sign_out_pathでルーティングされるsessions#destroyについて、コーディングをしていませんでした。
セッションを削除するsign_outメソッドを実行し、トップページにリダイレクトするようにコーディングしましょう。

app/controllers/sessions_controller.rb
...
def destroy
  sign_out
  redirect_to root_path
end
...

ここまでで、サインイン機能、サインアウト機能、ヘッダーの出しわけについてコーディングが完了しました。
ここで一度動作確認をしていきましょう。

サインイン機能、サインアウト機能、ヘッダー出しわけの動作確認

サインインしていない場合

まずサインインしていない状態でhttp://localhost:3000にアクセスしてみましょう。
すでにサインインしてしまっている場合は、ブラウザを閉じるか、「Sign out」リンクを押すかしてサインアウトしましょう。

この状態ではヘッダーは「Home」リンクと「Sign in」リンクが表示されています。
image.png

「Home」リンクを選択するとトップページへ、「Sign in」リンクを選択するとサインインページに遷移することも確認できますね。

サインインしている場合

では、サインインページからjohn@sample.comでサインインしてみましょう。
すると、john@sample.comのユーザー詳細ページに遷移し、ヘッダーも「Profile」リンクと「Sign out」リンクに変わっていることが確認できます。
image.png

また、「Profile」リンクを選択することでjohn@sample.comのユーザーのユーザー詳細ページに遷移できることも確認できますね。

サインアウトを試してみる

最後にサインアウトが正しく動作するか確認してみましょう。
サインインしている状態で、「Sign out」リンクをクリックしてみてください。

トップページに遷移して、ヘッダーが未サインイン状態の場合のヘッダーに戻っていることが確認できるはずです。
image.png

これで、想定どおりに動作していることが確認できましたね。

サインアップした時もサインイン状態になるようにする

今のままではサインアップした後にサインインをしないといけなくなり面倒です。
サインアップ時(users#create)もサインイン状態になるように更新します。

app/controllers/users_controller.rb
...
def create
  @user = User.new(user_params)
  if @user.save
    sign_in @user
    flash[:success] = "サインアップありがとう!"
    redirect_to @user
  else
    render :new
  end
end
...

if @user.saveの下にsign_in @userを追加しました。
これでDB保存が成功した場合に、そのユーザーでサインイン状態になるようになりました。

では、新たにユーザーをサインアップページで作成して、サインイン状態でプロフィールページに遷移することを確認してみましょう。
image.png
プロフィールページに遷移してますし、ヘッダーのリンクからサインイン後の状態になっていることがわかりますね。

サインイン状態のときに遷移できないページを作る

また、サインインしたあとにサインアップページやサインインページに遷移することは必要ありませんね。(むしろ禁止したい。)
さらに、サインイン後はトップページにもアクセスする必要はなく、ルートパスにアクセスしようとした場合、プロフィールページ(サインインユーザーのユーザー詳細ページ)に遷移させるようにしたいかもしれません。

これらを実装してみましょう。

ApplicationControllerにサインインしている場合、強制的にプロフィールページに遷移させるメソッドを作成します。

app/controllers/application_controller.rb
...
def redirect_to_profile_if_signed_in
  redirect_to current_user if signed_in?
end
...

これを、トップページ(static_pages#home)、サインアップページ(users#new, users#create)、サインインページ(sessions#new, sessions#create)にルーティングされた直後(アクションが処理される前)に実行されるようにします。
これはbefore_actionメソッドを使えば容易です。

app/controllers/static_pages_controller.rb
class StaticPagesController < ApplicationController
  before_action :redirect_to_profile_if_signed_in

  def home
  end
end
app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :redirect_to_profile_if_signed_in, only: [:new, :create]
  ...
end
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  before_action :redirect_to_profile_if_signed_in, only: [:new, :create]
  ...
end

StaticPagesControllerはhomeアクションしかなかったので、コントローラー全体にbefore_actionを適用しました。
UsersControllerとSessionsControllerはonlyオプションをつけて対象のアクションの実行前にのみredirect_to_profile_if_signed_inメソッドが呼び出されるようにしました。

サインイン状態にして、「トップページ」「サインアップページ」「サインインページ」にそれぞれダイレクトアクセス(URL直打ち)してみてください。
全てプロフィールページにリダイレクトされます。

また、念のためサインアウトした場合は「トップページ」「サインアップページ」「サインインページ」にそれぞれアクセスできることも確認しておきましょう!

確認できましたね?
では、今日はここまでにしておきましょう!

後片付け

いつものように、次回に向けてデータを消します。

$ docker-compose down
$ docker-compose run --rm web rails db:migrate:reset

DBコンテナが立ち上がった状態だと思うのでdownさせます。

$ docker-compose down

まとめ

今回はセッションを利用してユーザーのサインイン機能、サインアウト機能を作ってみました。
さらにヘッダーの出しわけや、特定のページアクセス時にサインインしている状態だとプロフィールページにリダイレクトされる機能を作ってみました。
すでにお気づきとは思いますが、今回作成したredirect_to_profile_if_signed_inメソッドと逆のことをすればサインインしていないと遷移できないページを作り出すことも可能ですし、条件をsigned_in?ではないものにすれば、特定の条件のユーザー(例えばadminフラグを持っているユーザーとか)しかアクセスできないページを作り出すことも可能です。

それにしても、色々と動作確認することも増えてきましたね...
このまま毎回手で目で確認していくのもしんどそうです。

次回はこれを解決するためにTDD/BDD、そしてテスト自動化に取り組んでみます!

では、次回も乞うご期待!ここまでお読みいただきありがとうございました!

Reference

Links

Vol.1 - Introduction -
Vol.2 - Hello, Rails on Docker -
Vol.3 - Scaffold, RESTful, MVC -
Vol.4 - Static pages -
Vol.5 - Model and CRUD -
Vol.6 - Model validation -
Vol.7 - Secure password -
Vol.8 - Sign up -
・ Vol.9 - Sign in - ?この記事

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

阿佐志保『プログラマのためのDocker教科書 第2版』の誤植情報を集める

阿佐志保『プログラマのためのDocker教科書 第2版』(翔泳社, 2018)

出版社サイトの正誤表を見ると2件しか載っていなかったのですが、私が見つけただけでも10件ほどありましたのでまとめてみます。
まだ読み終えていないので読み終えたら問い合わせフォームからお知らせしようと思っています。
他にも見つけた方はコメントか編集リクエストで教えてください。

ページ
27 DBMS(省略)呼ばれることもあります DBMS(省略)呼ばれることもあります
29 需要な業務であるインフラ構成管理 重要な業務であるインフラ構成管理
48 バージョン管理手間がかかる バージョン管理手間がかかる
88 これは、コンテナIDを呼ばれるもので これは、コンテナID 呼ばれるもので
93 「Offical」 「Official」
111 defaultというホスト名のサーバを操作していている defaultというホスト名のサーバを操作している
136 一番下の表がおかしい。false という余計な文字が入っている
140 リスト4.3がおかしい。/tmp/etc にコピーしていない
140 nginx.cong nginx.conf
144 1つのみなります 1つのみなります
156 Docker image ls コマンド docker image ls コマンド
188 リスト5.60がおかしい。host.html は /docker_dir/web の下にあるはず

あと、誤植ではないのですが、6章の GitHub と Docker Hub の連携で使用する Dockerfile のビルドが失敗します。誰か助けて!

Enjoy!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker Compose でホストとクライアントの両方にて Rails を起動したいそこのあなた!

なにこれ?

あんまりいないとは思うけど、Rails で Docker Compose を使用しているときにホスト側とクライアント側で Rails server を起動したいときはありませんか?自分の場合だと、System Test がどうしても Docker 環境のみで動作させる方法が見つけ出せなかったので、自分の場合 Rails を Ubuntu 環境でシステムテストを走らせて、それ以外は Docker 内のコンテナで動作させたいと思ったのでこういう環境が必要になった。そんなことをしようとしていると困るのが、(Rails のルート)/tmp/ に作られるファイルがホスト側とクライアント側で競合して、同時に起動できないという問題だった。そんなときに読んで解決できる糸口となったのが「DockerでVolumeをマウントするとき一部を除外する方法」だったので、自分もココにその軌跡をのこしておくことにする。

なにするの?

要は Rails 同時に起動できない原因は tmp フォルダが存在するからである。こいつさえなければ、ポートの競合とかはともかく、起動できるのだ。要はお互いが疎な関係になればいい。つまり、クライアントとホストの tmp フォルダが同期しなければいい。以下のコードは tmp フォルダだけはホストと動悸しないようにしてある。

version: '3'

volumes:
  rails_tmp_data:

services:
  as:
    build:
      context: ./
    ports:
      - 3333:3000
    volumes:
      - ./:/usr/src/app/
      - rails_tmp_data:/usr/src/app/tmp/
    command: 
      /bin/sh -c "yarn install --check-files && bundle install && rails db:create && rails db:migrate && rm -f /usr/src/app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0' --early-hints"

なお上記のコードは、要点だけに絞っている。そのため、コピペでは動かないのは、予めご了承いただきたい。

これでおしまい

ポートの設定を端折ってしまった。そこらへんは、ココでは記事にしない。途中で記事を書くのがめんどくさくなったからね。ググるかなにかしてください。そんじゃねー。

タイトルについて

スタパ斉藤マジリスペクト、というわけでなく、冗談抜きでこんなタイトルしか思いつかなかった。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

あまり勧められない Docker-in-Docker のススメ

Docker in Docker とは?


Docker in Docker とは?

  • Docker コンテナ内に Docker 処理系そのものを収容
  • 内側の Docker の中にコンテナを立ち上げると… →外から見ると入れ子状になる

Docker in Docker の動作イメージ

docker-in-docker.png


ミニマムな構成

オフィシャルのイメージがあるのでそれを使う (タグの後ろに -dind と付いている)

docker-compose.yaml
---
version: "3.7"
services:
  docker:
    image: docker:19.03-dind
    privileged: true

ヤバいです、主にセキュリティー的な意味で。

    privileged: true

(ヤバすぎるので抜粋再掲)
詳しい説明は省略しますが、 Linux 内に隔離された空間を作るには相当の特権が必要なようです。


おしまい


んなわけあるか!!


色々いじってみる


ミニマム構成の問題点

  • あんまりメリットが見えない
  • 内側の Docker の通信路が TCP

    → docker build の時にコンテキストといって色々ファイルを Docker のデーモンに送りつけているのだが、その通信路と考えるといささか心もとない


メリットを出そう

隣接するコンテナからの指示で、内側の Docker にコンテナを建てられると便利そう。

docker-compose.yaml
---
version: "3.7"
services:
  docker:
    image: docker:19.03-dind
    privileged: true
  frontend:
    image: docker:latest
    depends_on:
      - docker

通信路の改善 ~よし、ならば UNIX ドメインソケットだ~

/var/run/docker.sock を隣接するコンテナと共有できれば… そういう時は volumes を使いましょう。

docker-compose.yaml
---
version: "3.7"
services:
  docker:
    image: docker:19.03-dind
    privileged: true
    volumes:
      - run:/run:rw  # /var/run は /run へのシンボリックリンクなので… 以下同様
    environment:
      - DOCKER_TLS_CERTDIR=
  frontend:
    image: docker:latest
    volumes:
      - run:/run:rw
    depends_on:
      - docker
    environment:
      - DOCKER_HOST=unix:///var/run/docker.sock

volumes:
  run:
    # tmpfs-as-a-volume: https://stackoverflow.com/a/45759927
    driver_opts:
      type: tmpfs
      device: tmpfs
      o: size=1M,mode=0755,uid=0,gid=0
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

dockerのポートフォワードをIPv6にも対応させる

概要

squidでActiveDirectory連携とSSLインターセプトするProxyをdockerで手軽につくる
Docker Composeでネットワークサービス群を5分で作れるようにした(dhcp/radius/proxy/tftp/syslog/dns)
上記のエントリでつくったProxyにULAのIPv6 Onlyな環境からアクセスしようとしたら、できなかった。

以下、Windowsの状況ですが、iOSとAndroidも混在しているので、DHCPv6が使えず(AndroidがDHCPv6つかえない)、ゲートウェイでRAとRDNSS(ドメインサフィックスも付与)を動作させている状態です。

Wireless LAN adapter Wi-Fi:

   接続固有の DNS サフィックス . . . . .: prosper2.net
   説明. . . . . . . . . . . . . . . . .: Intel(R) WiFi Link 5300 AGN
   物理アドレス. . . . . . . . . . . . .: 00-21-6A-CA-4A-A2
   DHCP 有効 . . . . . . . . . . . . . .: はい
   自動構成有効. . . . . . . . . . . . .: はい
   IPv6 アドレス . . . . . . . . . . . .: fd5a:ceb9:ed8d:fe10:70ff:9d38:95b6:9682(優先)
   一時 IPv6 アドレス. . . . . . . . . .: fd5a:ceb9:ed8d:fe10:4ca8:def3:2da5:a3b6(優先)
   リンクローカル IPv6 アドレス. . . . .: fe80::70ff:9d38:95b6:9682%13(優先)
   デフォルト ゲートウェイ . . . . . . .: fe80::b60c:25ff:fe0f:df11%13
   DHCPv6 IAID . . . . . . . . . . . . .: 117449066
   DHCPv6 クライアント DUID. . . . . . .: 00-01-00-01-24-DA-AE-0D-5C-26-0A-2D-95-59
   DNS サーバー. . . . . . . . . . . . .: fd5a:ceb9:ed8d:fe0a:10:254:10:251
   NetBIOS over TCP/IP . . . . . . . . .: 無効
   接続固有の DNS サフィックス検索の一覧:
                                          prosper2.net

なぜ通信できなかったのか

Dockerネットワークは以下のコマンドで作成したものを利用していました。

# docker network create --ipv6  --driver=bridge  --subnet=fd5a:ceb9:ed8d:dead::/64 br_infra_net

IPv4のDNATは設定されていた。(ほかのコンテナのものも入ってます)

# nft list chain ip nat PREROUTING ; nft list chain ip nat DOCKER
table ip nat {
        chain PREROUTING {
                type nat hook prerouting priority -100; policy accept;
                fib daddr type local counter packets 42 bytes 6049 jump DOCKER
        }
}
table ip nat {
        chain DOCKER {
                meta l4proto tcp tcp dport 8081 counter packets 2 bytes 104 dnat to 172.18.0.2:8081
                meta l4proto tcp tcp dport 53 counter packets 0 bytes 0 dnat to 172.18.0.3:53
                meta l4proto udp udp dport 53 counter packets 0 bytes 0 dnat to 172.18.0.3:53
                meta l4proto udp udp dport 514 counter packets 10 bytes 3332 dnat to 172.18.0.4:514
                meta l4proto tcp tcp dport 8080 counter packets 0 bytes 0 dnat to 172.18.0.5:8080
                meta l4proto udp udp dport 1813 counter packets 0 bytes 0 dnat to 172.18.0.6:1813
                meta l4proto udp udp dport 1812 counter packets 0 bytes 0 dnat to 172.18.0.6:1812
        }
}

IPv6はされてなかった。悲しい。

# nft list chain ip6 nat PREROUTING
table ip6 nat {
        chain PREROUTING {
                type nat hook prerouting priority -100; policy accept;
        }
}

どうやらコンテナにIPv6ふってるんだからNDPで直接通信してね。
みたいなニュアンスらしい。(違ってたらごめんなさい)

やだ、めんどくさい。
IPv4とおんなじ考え方で通信させたいんだ。

スクリプトで対応した

ちゃんとdockerのマニュアルとか読めば解決策があるような気もするのですが、面倒なのでnftでDNATすることにしました。
IPv4のnftを参考にして、IPv6もおなじようなDNATするスクリプトを作成。

スクリプトは jq を利用しています。
dnf -y install jq などでインストールしておく必要があります。

ipv6_port_bind.pl
#!/usr/bin/perl
use strict;

my ($buf,$addr,$cnt,$port,$prot,$handle,$cmd);

my $CHAIN = "DOCKER-USER-ipv6-dnat";

if($ARGV[0] !~ /flush|^$/){
  print "usage : $0 [ flush ]\n";
  exit;
}


####################
#  FLUSH RULES
####################


print "---------- FLUSH exist rules and chains ----------\n";
foreach(split(/\n/,`nft list chain ip6 nat PREROUTING -a | grep "jump $CHAIN" 2> /dev/null`)){
  chomp($_);
  $handle = (split(/\ /,$_))[-1];
  $cmd = "nft delete rule ip6 nat PREROUTING handle $handle";
  print $cmd."\n";
  system("$cmd 2> /dev/null");
}

$cmd = "nft delete chain ip6 nat $CHAIN";
print $cmd."\n";
system("$cmd 2> /dev/null");

if($ARGV[0] eq "flush"){
  exit;
}

print "\n";

####################
#  NEW RULES
####################

$buf  = "";
$buf .= "nft create chain ip6 nat $CHAIN\n";

foreach $cnt (split(/\n/,`docker-compose ps --service`)){
  chomp($cnt);

  $addr = `docker inspect $cnt | jq  '.[].NetworkSettings.Networks[].GlobalIPv6Address'`;
  chomp($addr);
  $addr =~ s/\"//g;

  foreach(split(/\n/,`docker inspect $cnt | jq  '.[].HostConfig.PortBindings|keys' | egrep "tcp|udp" `)){
    chomp($_);
    $_ =~ s/\"|\,|\ //g;
    $port = (split(/\//,$_))[0];
    $prot = (split(/\//,$_))[1];

    #print "$addr : $port $prot \n";
    $buf .= "nft add rule ip6 nat $CHAIN meta l4proto $prot $prot dport $port counter dnat to [$addr]:$port\n";
  }

}

$buf .= "nft add rule ip6 nat PREROUTING  fib daddr type local counter jump $CHAIN\n";

print "---------- CREATE chain and ADD rules ----------\n";
system($buf);
print $buf."\n";

print "---------- CHECK rules and chain ----------\n";
print `nft list chain ip6 nat PREROUTING ; nft list chain ip6 nat $CHAIN`;

exit;

内容は、IPv4のDNATの処理を同じものを、 docker-compose.yml で生成したコンテナに対して実施しています。

docker-compose.yml と同じディレクトリにおいて、スクリプトを実行すると、起動しているコンテナに対して docker inspect コンテナ を実行します。
得られた出力からポートバインドの設定を抜き出して、 nft の処理を生成しています。

実際に動作させると、以下のようになります。

# ./ipv6_port_bind.pl

---------- FLUSH exist rules and chains ----------
nft delete rule ip6 nat PREROUTING handle 250
nft delete chain ip6 nat DOCKER-USER-ipv6-dnat

---------- CREATE chain and ADD rules ----------
nft create chain ip6 nat DOCKER-USER-ipv6-dnat
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto udp udp dport 514 counter dnat to [fd5a:ceb9:ed8d:dead::4]:514
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto tcp tcp dport 8081 counter dnat to [fd5a:ceb9:ed8d:dead::2]:8081
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto tcp tcp dport 8080 counter dnat to [fd5a:ceb9:ed8d:dead::5]:8080
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto udp udp dport 1812 counter dnat to [fd5a:ceb9:ed8d:dead::6]:1812
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto udp udp dport 1813 counter dnat to [fd5a:ceb9:ed8d:dead::6]:1813
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto tcp tcp dport 53 counter dnat to [fd5a:ceb9:ed8d:dead::3]:53
nft add rule ip6 nat DOCKER-USER-ipv6-dnat meta l4proto udp udp dport 53 counter dnat to [fd5a:ceb9:ed8d:dead::3]:53
nft add rule ip6 nat PREROUTING  fib daddr type local counter jump DOCKER-USER-ipv6-dnat

---------- CHECK rules and chain ----------
table ip6 nat {
        chain PREROUTING {
                type nat hook prerouting priority -100; policy accept;
                fib daddr type local counter packets 0 bytes 0 jump DOCKER-USER-ipv6-dnat
        }
}
table ip6 nat {
        chain DOCKER-USER-ipv6-dnat {
                udp dport shell counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::4]:shell
                tcp dport tproxy counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::2]:tproxy
                tcp dport http-alt counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::5]:http-alt
                udp dport radius counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::6]:radius
                udp dport radius-acct counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::6]:radius-acct
                tcp dport domain counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::3]:domain
                udp dport domain counter packets 0 bytes 0 dnat to [fd5a:ceb9:ed8d:dead::3]:domain
        }
}

複数回起動しても問題ないように、処理の際には、既存ルールをフラッシュしてから、再作成しています。
nft ~ で始まる行が、実際に発行されるnftコマンドです。
最後に新たに作成したルールとCHAINを表示しています。

これで、ULAなIPv6 Only環境からもDockerコンテナにアクセスできるようになりました。
よかった。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerのマウント3種類についてわかったことをまとめる

はじめに

Dockerのマウント3種類(Volumesbind mountstmpfs mounts)について、
公式リファレンスを読んで、以下の項目をまとめたものになります。

  • マウントされたデータの保存先について
  • Volumesbind mountstmpfs mountsの概要
  • Volumesbind mountstmpfs mountsの使用例

マウントの種類によるデータの保存先

マウントの種類によらず、コンテナ内からは同じようにデータが見えます。
コンテナのファイルシステム内のディレクトリまたは個別のファイルとして公開されます。
3種類のマウントの違いについて考える上で、ポイントとなるのはデータがDockerホストのどこにあるかを考えることです。

  • Volumesは、Docker(Linuxでは/ var / lib / docker / volumes /)によって管理されるホストファイルシステムの一部に保存されます。Dockerを使用したプロセス以外の場合では、Dockerによって管理されるファイルシステムを変更しないでください。Volumesは、Dockerでデータを保持するための最良の方法です。

  • bind mountsは、ホストシステムのどこにでも保存できます。DockerホストまたはDockerコンテナ上の非Dockerプロセスは、いつでも変更できます。

  • tmpfs mountsはホストシステムのメモリにのみ保存され、ホストシステムのファイルシステムには書き込まれません。

それぞれマウントタイプについて

Volumesについて

  • Volumesが作成されると、Dockerホストのディレクトリに保存されます。

  • Volumesがコンテナにマウントされると、このディレクトリはコンテナにマウントされます。

  • bind Mountと違って、VolumesはDockerによって管理され、ホストマシンのコア機能から分離されます。

  • 特定のVolumesは複数のコンテナに同時にマウントできます。

  • 実行中のコンテナがボリュームを使用していない場合でも、ボリュームは引き続き使用でき、自動的に削除されません。

    • docker system pruneで削除することができます。

Bind mountsについて

Bind mountsVolumesと比較して機能が限られています。

  • Bind mountsを使用すると、ホストマシン上のファイルやディレクトリはコンテナにマウントされます。
  • マウント対象のファイルやディレクトリはホストマシン上の絶対パス(full path)によって参照されます。

tmpfs mountsについて

tmpfs mountはDockerホストあるいはコンテナ内のディスクのどちらにも保持されません。
コンテナが作成され削除されるまでの間に、非永続的な状態または機密情報を保存することができます。

マウントの使用例

Volumesの使用例

  • 複数の実行中のコンテナ間でデータを共有したい場合。
    • 複数のコンテナが読み取り専用か読み書きのいずれかで、同時にマウントできる
  • Dockerホストが特定のディレクトリまたはファイル構造を持つことが保証されていない場合。
    • ホストのファイルやディレクトリ等の構成を実行中のコンテナから分離することがVolumesにはできます。
  • ローカルではなく、リモートホストやクラウドプロバイダーに保存する場合

  • あるDockerホストから別のDockerホストにデータを移行、復元、バックアップしたい場合。

Bind mountsの使用例

一般にできるだけVolumesを使用すべきと公式リファレンスでは述べられています。

以下のユースケースにbind mountsが適していると記述されています。

  • ホストマシンからコンテナへの構成(ファイルやディレクトリ構成)を共有したい場合。

    • ホストマシンから各コンテナに/etc/resolv.confをマウントすることにより、DockerがデフォルトでコンテナにDNS解決を提供することができます。
  • ソースコードや作成したアーティファクトをDockerホストとコンテナ間で共有したい場合。

    • 例えば、Mavenのtarget/ディレクトリをマウントした場合に、DockerホストでMavenプロジェクトをビルドするたびにコンテナは再構築されたアーティファクトにアクセスします。
  • Dockerホストのファイルやディレクトリ構成が、コンテナが必要とするバインドマウントに一致することが保証されている場合。

tmpfsマウントの使用例

  • tmpfsマウントはデータをホストマシンかコンテナ内でも保持したくない場合。
    • これはセキュリティ上の理由か、大量の非永続化データを書き込む必要がある場合にコンテナのパフォーマンスを保護するためです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Dockerコンテナ内からホストへ`localhost` でアクセスしてみる

のっぴきならぬ事情でDockerコンテナ内からホストへlocalhost でアクセスする必要なときに役立ちそうなのでメモ。

Dockerコンテナ内からホストへアクセスするには

こちらの記事が参考になりました。
--add-host オプションを利用すればなんとかなりそうです。

Dockerのコンテナの中からホストOS上のプロセスと通信する方法 - Qiita
https://qiita.com/Iju/items/badde64d530e6bade382

localhost じゃなくて良いのなら

host.docker.internal というDNS名が用意されているので、それを利用すればよさそうです。
ドキュメントによるとMac/Windowsで利用できそうです。

Networking features in Docker Desktop for Windows | Docker Documentation
https://docs.docker.com/docker-for-windows/networking/

The host has a changing IP address (or none if you have no network access). From 18.03 onwards our recommendation is to connect to the special DNS name host.docker.internal, which resolves to the internal IP address used by the host. This is for development purpose and will not work in a production environment outside of Docker Desktop for Windows.

(Google翻訳)ホストのIPアドレスは変更されています(ネットワークアクセスがない場合はなし)。18.03以降では、ホストが使用する内部IPアドレスに解決される特別なDNS名host.docker.internalに接続することをお勧めします。 開発目的のためであり、Docker Desktop for Windows以外の実稼働環境では機能しません。

Networking features in Docker Desktop for Mac | Docker Documentation
https://docs.docker.com/docker-for-mac/networking/

The host has a changing IP address (or none if you have no network access). From 18.03 onwards our recommendation is to connect to the special DNS name host.docker.internal, which resolves to the internal IP address used by the host. This is for development purpose and will not work in a production environment outside of Docker Desktop for Mac.

(Google翻訳)ホストのIPアドレスは変更されています(ネットワークアクセスがない場合はなし)。 18.03以降では、ホストが使用する内部IPアドレスに解決される特別なDNS名host.docker.internalに接続することをお勧めします。 これは開発用であり、Docker Desktop for Mac以外の運用環境では動作しません。

試してみる

準備

まずホストでHTTPサービスを80ポートで立ち上げます。

> python -m http.server 80

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

# Dockerコンテナを起動するのでもOK
> docker run -it --rm -p 80:80 python \
  python -m http.server 80

確認

立ち上げたサービスに対してDockerコンテナ内からcurl でアクセスしてみます。
利用するDockerイメージはなんでもよいです。curl がインストールされていなかったら先にインストールします。

> docker run -it --rm alpine \
  sh -c 'apk add curl;curl http://localhost:80'

(略)
OK: 7 MiB in 18 packages
curl: (7) Failed to connect to localhost port 80: Connection refused

はい。
コンテナ内でlocalhost に対してアクセスすると当然ながらコンテナ内へのアクセスとなり、エラーとなります。

--add-host を足してみる

--add-host を利用するとコンテナ内の/etc/hosts ファイルに設定を追加できるそうです。

Dockerで/etc/hostsファイルが操作出来ない対策 - Qiita
https://qiita.com/jagaximo/items/6b71a03518bbd53d4de6

> docker run -it --rm alpine \
  cat /etc/hosts

127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.3      9f215da7195e

なので、localhost に対してホストのIPアドレスをみにいくように設定を追加してみます。

# ホストのプライベートIPアドレスを確認
> ipconfig getifaddr en0


> docker run -it --rm \
  --add-host=localhost:<ホストのプライベートIPアドレス> \
  alpine \
  cat /etc/hosts

127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
<ホストのプライベートIPアドレス>  localhost
172.17.0.3      6aff7879fc0a

追加できることが確認できたらcurl でアクセスしてみます。

> docker run -it --rm \
  --add-host=localhost:<ホストのプライベートIPアドレス> \
  alpine \
  sh -c 'apk add curl;curl -D - -s -o /dev/null http://localhost:80'

(略)
OK: 7 MiB in 18 packages
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.8.1
Date: Wed, 12 Feb 2020 02:43:26 GMT
Content-type: text/html; charset=utf-8
Content-Length: 987

やったぜ。
使う機会があるかはわかりませんが、できることは確認できました。

参考

Dockerのコンテナの中からホストOS上のプロセスと通信する方法 - Qiita
https://qiita.com/Iju/items/badde64d530e6bade382

Networking features in Docker Desktop for Windows | Docker Documentation
https://docs.docker.com/docker-for-windows/networking/

Networking features in Docker Desktop for Mac | Docker Documentation
https://docs.docker.com/docker-for-mac/networking/

Dockerで/etc/hostsファイルが操作出来ない対策 - Qiita
https://qiita.com/jagaximo/items/6b71a03518bbd53d4de6

Pythonの標準ライブラリでさくっとAPIサーバとWebサーバを立ち上げる - Qiita
https://qiita.com/kai_kou/items/6cf5930330b85fa583b0

cURLコマンドでレスポンスヘッダのみを取得する - Qiita
https://qiita.com/yousan/items/fcc15e1046939c465ab7

macOS で自分のプライベート IP アドレスを見つける方法 - yu8mada
https://yu8mada.com/2018/07/14/how-to-find-my-private-ip-address-in-macos/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む