Simple minds think alike

より多くの可能性を

Heroku SSL Endpoint から Automated Certificate Managementへ移行してみた

Herokuの公式ドキュメントを読むとSSLを導入する方法は

  • Automated Certificate Management (ACM)
  • Heroku SSL
  • SSL Endpoint

の3種類がありますが、Automated Certificate Management (ACM)にすると

  • Let's EncryptのSSL証明書を自動更新してくれるので、手動更新が不要になる
  • SSLが無料になる

というメリットから、アドオンで提供されていたHeroku SSL Endpoint から移行してみました。

移行の際、一旦Heroku SSLに移行してからでないとダウンタイムが発生してしまうということで

  • Heroku SSL Endpoint => Heroku SSL 移行
  • Heroku SSL => Automated Certificate Management(ACM) 移行

のように2段階に分けて移行を行いました。

具体的な手順は公式の以下のページを参照しました。
Heroku SSL | Heroku Dev Center

①Heroku SSL Endpoint => Heroku SSL 移行

既存のSSL証明書の用意

まずは、SSL Endpointに登録したものと同じSSL証明書秘密鍵を用意します。

ここでは

とします。

Heroku SSLに証明書と秘密鍵を追加

以下のようにHerokuコマンドを実行して、Heroku SSLに証明書と秘密鍵を追加します。

$ heroku certs:add example.crt example.key --type sni -a 【project-name】

SNIは、Heroku SSLが使っているTLS protcolを拡張したもののようです。

Behind the scenes Heroku SSL uses Server Name Indication (SNI), an extension of the TLS protocol, which is widely supported in modern browsers.

SSL証明書の追加確認

Heroku SSLに証明書が追加されたことを確認します。2つ目のようにTypeがSNIで追加されていればOKです。

$ heroku certs -a 【project-name】


Name                Endpoint                  Common Name(s)            Expires               Trusted  Type
──────────────────  ────────────────────────  ────────────────────────  ──────────
**********          **********.herokussl.com  **********                2020-03-26 01:20 UTC  True     Endpoint
**********          (Not applicable for SNI)  **********                2020-03-26 01:20 UTC  True     SNI

DNS targetの確認

次にDNSに設定するtargetを確認します。***.herokudns.comのようなエンドポイントが表示されます。

$ heroku domains -a 【project-name】

=== pplog Heroku Domain
***.herokuapp.com

=== pplog Custom Domains
Domain Name    DNS Record Type  DNS Target
─────────────  ───────────────  ───────────────────────────
**********     CNAME            ***.herokudns.com

DNSのCANME変更

次にDNSの設定画面を開いてCNAMEを変更します。

現状*****.herokussl.comのようなエンドポイントになっているところを新しいエンドポイントに変更します。

digコマンドで、エンドポイントが****.herokudns.comのように切り替わっていることを確認します。

$ dig CNAME 【domain】

;; ANSWER SECTION:
*********.com.    3408    IN  CNAME   ***.herokudns.com.

DNS設定変更後、24時間後に③のオペレーションを行います。

③Heroku SSL => Automated Certificate Management(ACM) 移行

SSL Endpointアドオンを削除する

$ heroku addons:destroy ssl  -a 【project-name】

Automated Certificate Management有効化

$ heroku certs:auto:enable -a 【project-name】

ACMの設定確認

ACMが有効になることを確認します。

heroku certs:auto -a 【project-name】
=== Automatic Certificate Management is enabled on 【project-name】

再度、証明書も確認し、TypeがACMになっていればOKです。

$heroku certs -a 【project-name】

Name                Common Name(s)            Expires               Trusted  Type
──────────────────  ────────────────────────  ────────────────────────  ──────────
**********          **********                2020-06-29 05:30 UTC  True     ACM

Rails権限管理ライブラリ cancancan gem バージョンアップ(2.x => 3.x)の際の対応と注意点

Ruby on rails の権限管理ライブラリの中で一番ダウンロード数が多いGemであるcancancanのv3.0が2019年4月にリリースされました。

仕事のプロジェクトで使っているcancancanのバージョンは随分前にv3.0に上げていたのですが、結構大きな変更が入っていることを認識できていなかったことに最近気が付き、これからアップグレードをする方や新規にCancancanを導入する方に向けて、どんなところに注意したら良いのかをまとめてみることにしました。

注意点を認識していないと、気がつかない間に

  • N+1(パフォーマンス問題)が発生する
  • SQLクエリ実行時にエラーが発生する

といった問題が発生する可能性があります。

Cancancan公式のドキュメントのURLも載せておきます。
GitHub - CanCanCommunity/cancancan: The authorization Gem for Ruby on Rails.

4つの注意点(Breaking changes)

注意点(Breaking changes)は4点ありますが、特に2番目、3番目を意識すると問題の発生を避けられそうです。

①権限を定義するアビリティにSubjectを定義しなくてはいけなくなった

例えば、ダッシュボードに対する権限を指定する場合

can :dashboard

のように書くことができなくなりました。具体的に

can :read, :dashboard

のように、ダッシュボードのリード権限があるという書き方をする必要があります。

  • 以前よりDefining AbilitiesのWikiページ で、後者の書き方が載っていたので、あまり前者の書き方をしているプロジェクトはない
  • 前者の書き方をしているとエラーが発生するようになった

ため、この注意点に関してはあまり意識しておかなくても問題ないと思います。

しかし

  • 権限のテストをあまり書いていないプロジェクト
  • アップグレード後に手動でも動作確認をしていない

場合だと、気がつかずにプロダクションに反映する恐れがあるので注意が必要になりそうです。

②Eager loadingを自動的に行わなくなった。

具体的には、このPRの差分になるのですが、今まではモデルに :has_many などで紐付いているモデルは includes して読み込んでいたのですが、left_joinsに変わったのでN+1が発生する可能性がでてきました。

具体的には、Book(本)モデルの :has_many にAuthor(著者)モデルがあるような場合で、書籍一覧(Booksのindexページ)で、著者名(author.name)を表示するようなケースが該当します。

なので

といった対応を行った方が良さそうです。

③Distinctを使うようになった

②と同じ差分で、クエリでdistinctを行うようになったことがわかります。

例えば、load_resourceを読み込んだ結果(ActiveRecord::Relation)に対してordergroup byを付けて以下のようなクエリになっていた場合は、PostgreSQLの場合クエリ実行時にエラーになります。

SELECT
  DISTINCT users.id
FROM 
  users
order by 
  users.email

=>  ERROR: for SELECT DISTINCT, ORDER BY expressions must appear in select list

このような場合、selectの値を見直し、users.emailを含めるようにするといった対応が必要になります。

④アビリティファイルをマージした際、エイリアスもマージされるようになった

権限が複雑になってくるとアビリティファイルを分けたり、それらのアビリティをマージしたくなってきます。

cancancanのアビリティには、mergeというメソッドが定義されていて以前からマージを行うことができるようになっていましたが、エイリアスはマージされないといった問題があったようです。

cancancan 3.0でエイリアスもマージできるようになったようですが、Railsプロジェクトはそこまで複雑になる案件には向かないと思うので

  • アビリティのマージをしている
  • エイリアスも使っている

というのはあまり多くはなさそう。ということでこの4点目は比較的重要度は低そうです。

Rails bulk insertのパフォーマンス比較 (Railsのinsert_allとactiverecord-import)

Railsでbulk insertする方法は主に

の2つの方法があります。パフォーマンス観点では、どちらが良いのか知りたくて検証してみました。

activerecord-importに関しては、READMEを読む限り、いくつかimportの方法がありますが、Rails6のinsert_allと同様にバリデーションを行わない以下2つの方法で比較しました。

  • Works with raw columns and arrays of values (fastest)
  • Works with model objects (faster)
    • => モデルオブジェクトを使う方法

結論

結論としては、activerecord-importのカラム名と値の配列を使う方法が一番早かったです。

以下は10万件のレコードをbulk insertした結果なのですが、件数を変えたり、何回か実行してみても、大体同じような結果になりました。レコード件数を変えて、複数回実行して平均を取った結果を以下の比較図に記載しています。

[1] pry(main)> Book.bulk_insert
                          user        system    total      real
Rails6 insert_all       13.024230   0.051869  13.076099 ( 14.523158)
import (カラム名と値の配列) 11.924948   0.048174  11.973122 ( 13.471458)
import (モデルオブジェクト) 20.419098   0.155907  20.575005 ( 22.313445)

環境

検証環境のスペックは以下の通りです。

  • OS: Ubuntu 18.04.2 LTS
  • PC: メモリ 15.4 G、CPU Itel Core i7-8650U(1.90GHz [コア/スレッド数 4/8])
  • MySQL: 5.7.29 (InnoDB)

検証方法

以下のコードを実行しました。

require 'benchmark'

class Book < ActiveRecord::Base
  class << self
    def bulk_insert
      # Rails6で導入された insert_all で bulk insert するデータ
      insert_data =
        100_000.times.each_with_object([]) do |_, array|
          array << { name: 'book name', author: 'book auther', created_at: Time.current, updated_at: Time.current }
        end

      # activerecord-import の import(カラム名と値の配列) で bulk insert するデータ
      import_data = [['book name', 'book auther', Time.current, Time.current]] * 100_000

      # activerecord-import の import(モデルオブジェクト) で bulk insert するデータ
      import_model_data =
        100_000.times.each_with_object([]) do |_, array|
          array << new(name: 'book name', author: 'book auther', created_at: Time.current, updated_at: Time.current)
        end

      Benchmark.bm 30 do |r|
        # Rails6で導入された insert_all で bulk insert
        transaction do
          r.report 'insert_all' do
            insert_all insert_data
          end
          raise ActiveRecord::Rollback
        end 

        # activerecord-import の import(カラム名と値の配列で bulk insert
        transaction do
          r.report 'import (カラム名と値の配列)' do
            columns = [:name, :author, :created_at, :updated_at]
            import columns, import_data, validate: false
          end
          raise ActiveRecord::Rollback
        end 

        # activerecord-import の import(モデルオブジェクト) で bulk insert
        transaction do
          r.report 'import (モデルオブジェクト)' do
            import import_model_data, validate: false
          end
          raise ActiveRecord::Rollback
        end 
      end
    end
  end
end

比較図

レコード10万件から1桁ずつ減らして、パフォーマンスを計測して図(値はBenchmarkのReal)にしてみました。Rails6 insert_allは、activerecord-import の import (カラム名と値の配列) よりちょっと遅いくらいだということがわかります。

感想

2020年3月時点では、Rails 6のinsert_allはパフォーマンス面ではactiverecord-importほどではなく、バッチサイズを指定できなかったり、モデルのバリデーションやコールバックが実行できないので、もう少し機能が増えてきたら用途が増えそうという感じがしています。

関わっている案件ではactiverecord-import使っているけど、置き換えるのはまだちょっと先かなと思ってます.

Rails6アプリのデプロイ時にヘルスチェックに失敗した時にやったこと

先日、稼働しているRailsのWebアプリケーションをRails5.2から6.0に上げて本番デプロイしようとした際に、ヘルスチェックに失敗していてデプロイできない状態になっていたのですが、そのときに対応した内容を書いてみます。

何が起こったか

まず、AWS EC2インスタンスにデプロイしようとした際にタイムアウトが発生して、デプロイに失敗していました。 このタイムアウトの原因を探していたら、AWSのターゲットグループのヘルスチェックに失敗していることが分かりました。

// デプロイ失敗時のログ
Deploying new task definition.................................................................................................................................................................................................................................................................................................
Deployment failed due to timeout. Please see: https://github.com/fabfuel/ecs-deploy#timeout

なぜ、ヘルスチェックに失敗したか

Rails6で導入されたDNSバインディング対策の機能で、ActionDispatch::HostAuthorization というミドルウェアがブラウザからアクセスされた時のホストをチェックしてくれるようになりました。 github.com

外部からアクセスされるドメインは設定しておいたのですが、それ以外は設定していませんでした。

WebアプリはAWS ECS(EC2インスタンス)で運用、ターゲットグループのヘルスチェックを有効にしていていたのですが、このヘルスチェックはどうやらプライベートIPアドレスを使っているようで、ホストとして登録しておかないとアクセスできない状態になっていました。

ActionDispatch::HostAuthorization でホストを許可するための指定の仕方

デフォルトでは、development環境で以下の3つが登録されていて、staging, production環境では設定がないのでホストを指定しておく必要があります。

Rails.application.config.hosts = [
  IPAddr.new("0.0.0.0/0"), # All IPv4 addresses.
  IPAddr.new("::/0"),      # All IPv6 addresses.
  "localhost"              # The localhost reserved domain.
]

config/application.rbconfig/environments の中で

config.hosts << 'my-web-app.com'

のように記載するとホストを指定するとアクセスが許可されるようになります。

具体的に設定した値

EC2などの仮想マシンVPC内に構築しているので、VPCIPV4 CIDRの設定を入れることでデプロイできるようになりました。

config.hosts <<IPAddr.new('10.0.0.0/16')

VPCIPV4 CIDRを追加した時忘れそうなので、そのときまたデプロイできなくなりそう。。という懸念は残っています。 あと、IPアドレスをもう少し絞れたら嬉しいのですがそこまでは分からなかった。。。サブネットのCIDR使えば、もうちょっと範囲を絞れるかな。どなたか知っている方は教えてもらえると嬉しいです。

Rails と Firebase Authentication でJWT認証実装してみた

最近スマホアプリの開発は大体firebaseを使うようになっていて、認証もfirebase authenticationを使っておけば、実装がすごく楽になっていると感じます。

既にRuby on railsでwebアプリケーションを開発していて後からJWT認証を付けようとするとちょっと面倒かと思いますが、 firebase-auth-railsというGemを使って比較的簡単にJWT認証を行えるようになったので設定方法を書いてみます。

github.com

基本的にREADME通りで動いたのですが、どんな設定が必要なのかチョット補足します。 スマホアプリのバックエンドにこのGemを使ったので、Rails APIモードで実装したコードを記載しています。

環境

概要図

ユーザ登録時、以下のような仕組みでユーザを作ります。

ユーザ登録後、例えばマイ書籍一覧画面のような特定のユーザにしか見れない画面を開く時は以下のような仕組みで特定のユーザを判別します。

ディレクトリ/ファイル構成

├── app
│     └── controllers
│           └── api
│                ├── v1
│                │   └── auth
│                │        └── registrations_controller.rb
│                ├ application_controller.rb
│                └ books_controller.rb
└── config
      └── initializers
            └── firebase_id_token.rb

セットアップ

Redis

Redisをサーバにインストールしておきます。

$ sudo apt install redis-server

内部でfirebase_id_tokenというgemを使っていて、このgemはRedisが必須のためインストールしておきます。 google x509 証明書とその期限を管理するためにRedisを使っているようです。

Gem追加

Gemfileに以下を追記し、

gem 'firebase-auth-rails'

Gemをインストールしておきます。

$ bundle

実装

イニシャライザーの追加

config/initializers/firebase_id_token.rbにRedisとfirebabaseプロジェクトの設定を記載します。

FirebaseIdToken.configure do |config|
  config.redis = Redis.new
  config.project_ids = ['firebase_project_id']
end

上記概要図④、⑨でRailsサーバ側でfirebaseにJWT認証するので設定しておきます。

ユーザのuidカラムを追加

firebaseのUIDを保存しておくカラムを追加します。(上記、概要図⑤、⑩で使うカラム)

$ rails g migration AddUidToUsers uid:string

できたマイグレーションを実行しておきます。

class AddUidToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :uid, :string
  end
end
$ rails db:migrate

ApplicationControllerへ追記

以下のようにApplicationControllerにinclude Firebase::Auth::Authenticable, before_action :authenticate_userの2行を追加しておきます。

# app/controllers/api/v1/application_controller.rb
module Api
  class V1::ApplicationController < ActionController::API
    include Firebase::Auth::Authenticable
    before_action :authenticate_user
  end
end

こうすることでV1::ApplicationControllerを継承したコントローラのメソッドにアクセス時、HTTPリクエストヘッダに、「Authorization: “Bearer *****************(トークン)”」を入れるだけで、勝手にJWT認証してくれるようになります。(上記、概要図⑨、⑩の箇所)

ユーザ登録用のコントローラを作成

ユーザ登録用のコントローラ(上記概要図④のRailsサーバ側の処理)はこんな感じで作ってみました。

# app/controllers/api/v1/auth/registrations_controller.rb
require_dependency 'api/v1/application_controller'
module Api
  module V1
    module Auth
      class RegistrationsController < V1::ApplicationController
        skip_before_action :authenticate_user

        def create
          FirebaseIdToken::Certificates.request
          raise ArgumentError, 'BadRequest Parameter' if payload.blank?
          @user = User.find_or_initialize_by(uid: payload['sub'])
          if @user.save
            render json: @user, status: :ok
          else
            render json: @user.errors, status: :unprocessable_entity
          end
        end

        private

        def token_from_request_headers
          request.headers['Authorization']&.split&.last
        end

        def token
          params[:token] || token_from_request_headers
        end

        def payload
          @payload ||= FirebaseIdToken::Signature.verify token
        end
      end
    end
  end
end

ログインが必要な画面のコントローラを作成

Api::V1::ApplicationControllerを継承しているので、indexアクセス時勝手にJWT認証(上記、概要図⑨、⑩の箇所)してくれます。

# app/controllers/api/v1/books_controller.rb
module Api
  module V1
    class BookssController < Api::V1::ApplicationController
      def index
        render json: current_user.books, status: 200
      end
    end
  end
end

Routes追加

routes.rbには以下のように記載しました。

Rails.application.routes.draw do
  〜〜〜
  namespace 'api' do
    namespace 'v1' do
      resources :books, only: %i(index)
      namespace 'auth' do
        post 'registrations' => 'registrations#create'
      end
    end
  end
end

デプロイ後のオペレーション

サーバにコードデプロイ後は、firebase_id_tokenのREADME に記載してある以下をrails consoleで実行し、Googleのx509証明書をダウンロード・確認することで、JTW認証が動くようになりました。

FirebaseIdToken::Certificates.request
FirebaseIdToken::Certificates.present?
=> true

補足

スマホアプリはReact Nativeで実装したのですが、以下のようなコードになりました。 firebaseパッケージを使っています。

[サインアップ時の処理]

// SignupScreen.js サインアップ画面のコード
export default class SignupScreen extends React.Component {
  〜〜〜
  onSignupPress = () => {
    firebase.auth().createUserWithEmailAndPassword(this.state.email, this.state.password)
        .then(apiClient.authenticate, authError);
    // => サインアップ後、apiClient.authenticate 認証処理を実行
  }
  〜〜〜
}

[ログイン時の処理]

// LoginScreen.js ログイン画面のコード
export default class LoginScreen extends React.Component {
  〜〜〜
  onLoginPress = () => {
    firebase.auth().signInWithEmailAndPassword(this.state.email, this.state.password)
      .then( apiClient.authenticate , authError);
    // => ログイン後、apiClient.authenticate 認証処理を実行
  }
  〜〜〜
}

[ログイン処理(ログイン時と、サインアップ時で共通)のコード]

class ApiClient {
  async authenticate() {
    const token = await firebase.auth().currentUser.getIdToken(true)
    const data = { token }
    postRequest('/api/v1/auth/registrations', data,);
  }
}

Golangとginのリクエストハンドリングについて

最近仕事で使い始めた Golang ( net/http 標準ライブラリ)とWebフレームワーク Gin のリクエストハンドリングについてどのような仕組みで行っているのかをまとめてみます。

前提

動作確認環境は以下の通りです。

  • Ubuntu 18.04.3 LTS
  • go 1.13.6
  • gin v1.5.0

Golang

マルチプレクサとは

GolangではユーザがWebページにアクセスされた際にマルチプレクサというものを使って、どのページを呼び出すかを決めます。 以下のように、内部構造にURLとページ(ハンドラ)を対応づける構造体を持っていてもので、アクセスされた時のURLから呼び出すページ(ハンドラ)が決まります。

goデフォルトのマルチプレクサ

実際に以下のようにコードを書いて、ローカルで http://localhost:8080/cart のようにアクセスすると Cart page と表示されるかと思います。

package main

import (
    "fmt"
    "net/http"
)

func defaultHandler(writer http.ResponseWriter, request *http.Request) {
    fmt.Fprintf(writer, "Hello World! (default Page)")
}

func cartHandler(writer http.ResponseWriter, request *http.Request) {
    fmt.Fprintf(writer, "Cart page")
}

func main() {
    http.HandleFunc("/", defaultHandler)
    http.HandleFunc("/cart", cartHandler)
    http.ListenAndServe(":8080", nil)
}

このように、 http.ListenAndServe()メソッドなどでマルチプレクサが指定されていない(nilの)場合は、マルチプレクサの構造体ServerMuxインスタンスであるDefaultServerMuxがデフォルトで使われ、手前の行のhttp.HandleFunc()で登録されたルーティングが処理されます。

[参照コード] go/server.go at bbbc6589dfbc05be2bfa59f51c20f9eaa8d0c531 · golang/go · GitHub

goカスタムのマルチプレクサ

マルチプレクサは、標準ライブラリであるnet/http以外のものに変更可能です。 標準ライブラリでは、例えば http://localhost:8080/books/100 のように一部がIDになっているような可変のURLの場合に対応できないですが、HttpRouterのような別のマルチプレクサに置き換えることが実現できます。

package main

import (
    "fmt"
    "github.com/julienschmidt/httprouter"
    "net/http"
)

func defaultHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
    fmt.Fprintf(writer, "Hello World! (default Page)")
}

func cartHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
    fmt.Fprintf(writer, "Cart page")
}

func main() {
    mux := httprouter.New()
    mux.GET("/", defaultHandler)
    mux.GET("/cart", cartHandler)
    http.ListenAndServe(":8080", mux)
}

ListenAndServe() には、HttpRouterインスタンスが引数に入っています。

Gin

Ginのマルチプレクサ

最後にGolangのWebフレームワークの一つであるGinのマルチプレクサに関してですが、前述のHttpRouterを独自に改良したものが使われているようです。changelogにcustom hand optimized HttpRouter for Ginのような記載があったり、コードの各所のHttpRouterのライセンスの記載がありました。

ginのコードを読んで見ると独自にカスタマイズした engine(マルチプレクサ) を ListenAndServe() の引数に渡しているの読み取れます。

[参照コード] gin/gin.go at master · gin-gonic/gin · GitHub

同様に、こちらも実際にコードを書いてみると

package main

import (
    "github.com/gin-gonic/gin"
)

func defaultHandler(ginctx *gin.Context) {
    ginctx.String(200, "Hello World! (default Page)")
}

func cartHandler(ginctx *gin.Context) {
    ginctx.String(200, "Cart page")
}

func main() {
    mux := gin.Default()
    mux.GET("/", defaultHandler)
    mux.GET("/cart", cartHandler)
    mux.Run(":8080")
}

のようになります。

Ginのマルチプレクサの特徴

HttpRouterとは異なり、ルーティングをグループ化できるという特徴があるようです。(こちらは未検証のため公式ドキュメントのコードを引用します)

func main() {
    router := gin.Default()

    // v1 のグループ
    v1 := router.Group("/v1")
    {
        v1.POST("/login", loginEndpoint)
        v1.POST("/submit", submitEndpoint)
        v1.POST("/read", readEndpoint)
    }

    // v2 のグループ
    v2 := router.Group("/v2")
    {
        v2.POST("/login", loginEndpoint)
        v2.POST("/submit", submitEndpoint)
        v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
}

細かいルーティングの指定ができて良さそうです。

Kubernetesクラスタを作ってみた with ラズベリーパイ〜上品なファブリックケーブルを添えて〜

一家に一台Kubernetesクラスタの時代が到来したとちらほらと聞こえ始めてきたので、いまさらながらRaspberry PIKubernetesクラスタを作ってみました。

Raspberry PIKubernetesクラスタであれば、EKSのようなクラウドベンダーが提供しているサービスではないので

  • マスターノードに制約がなく自由に設定ができる
  • サーバ費用をかけずに、複数ノード環境でaddonの検証ができる

というのが良いところか思います。

用意したもの

ラズパイとmicroSDカード、microUSBは千石電商、LANケーブルとケースはAmazon、その他はヨドバシAkibaで入手しました

揃えた中でちょっと微妙だったかもと思ったは以下の2つです。

  • LANケーブルは見た目にこだわった結果、長いものしかなく上記のものを選択しましたが短い方が配線は綺麗になるかと思います。
  • 充電機に使ったElecom MPA-ACD03BKは思ったより大きく、横にすると入らなくなったので、もう少し小さいものを購入した方が良さそうです。

環境の構成

SSHで接続するPC環境はSSHを使える環境、Raspbianイメージを作成するPC環境はイメージをSDカードにwriteできる環境であればどんな環境でも大丈夫です。

  • Raspberry PI k8s環境 (マスター, ワーカノード共通)
    • Raspbian Buster Lite (Version:July 2019, Release date:2019-07-10, Kernel version:4.19)

ラズパイクラスタをRaspbian stretch liteで作ったという記事を良くみかけますが、Raspbian Buster Liteに関してもそれらの記事の設定方法とまったく同じで手順で大丈夫でした。

完成したところ

ケーブルはよく見かける感じのものだと味気ないので、白黒のケーブルにしてみました。ケースもグレーにして大人っぽい上品な感じに仕上げてみました。

ビールかなんかと一緒に置くとより一層大人っぽい雰囲気を楽しめるかもしれません。

作り方

組み立て

組み立て方はつまるところがないと思うので省略します。

イメージの準備

以下のSDカードの準備イメージ内のファイル編集は、ラズパイ3台分 (SDカード3枚)行います。

SDカードの準備

Raspbianイメージを作成するPC環境(Windows 10環境を使いました)で、Raspbian Buster Lite (旧raspbian stretch lite)の最新版のイメージをダウンロードします。

イメージのSDカードへの書き込みは、Win32 Disk Imagerというツールを使いました。

イメージ内のファイルを編集

SDカード作成後、Windows OS上で以下のファイルを変更しました。

  • /bootディレクトリ直下にsshという名前のファイル(中身は空)を作成
    • デフォルトではSSHで接続できないので接続できるようにします。

  • /bootディレクトリ直下にある cmdline.txt を編集し、cgroup_enable=cpuset cgroup_enable=memoryを追加
    • cgroupsのcpusetとmemoryを有効化

Raspbian起動後の下準備

microSDカードをラズパイに挿入、充電器からmicroUSBケーブルを指して給電してラズパイを起動します。ラズパイ各環境でIPアドレス、ホスト名設定するまではHDMIケーブルで外部モニタ、USBキーボードを接続して操作しました。

下準備の設定内容は、マスターノード、ワーカーノードで共通です。

パッケージ更新、スワップオフ

デフォルトの

  • ユーザ: pi
  • パスワード: raspberry

でログイン。

aptパッケージを最新化します。

$ sudo apt-get -y update
$ sudo apt-get -y upgrade

Kubernetes 1.8以降スワップが有効のままだとkubeletが起動しなくなったので、スワップをオフにします。

$ sudo dphys-swapfile swapoff
$ sudo dphys-swapfile uninstall
$ sudo update-rc.d dphys-swapfile remove

ホスト名・Pアドレスを固定

次に /etc/hostname, /etc/hosts を編集しホスト名を変更、/etc/dhcpcd.confを編集しIPアドレスを固定します。

ホスト名、IPアドレスは3台それぞれ

  • k8s-master-01 (192.168.13.101)
  • k8s-worker-01 (192.168.13.102)
  • k8s-worker-02 (192.168.13.103)

をホスト名 (IPアドレス)にしてみました。

以下のようにマスターノードのホスト名の設定しました。

$ sudo vi /etc/hostname
- raspberrypi
+ k8s-master-01
$ sudo vi /etc/hosts
- 127.0.1.1  raspberrypi
+ 127.0.1.1  k8s-master-01

同様にマスターノードのIPアドレス設定を設定しました

$ sudo vi /etc/dhcpcd.conf
interface eth0
static ip_address=192.168.13.101/24
static routers=192.168.13.1
static domain_name_servers=192.168.13.1 8.8.8.8

PC環境からSSH接続できるように

SSH秘密鍵と公開鍵を作っておきます。鍵は同じものを使うようにしたため、実行は1回のみです。

$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/home/username/.ssh/id_rsa): 
Enter passphrase (empty for no passphrase): <パスフレーズ>
Enter same passphrase again: <パスフレーズ>
Your identification has been saved in 【秘密鍵のファイル名】
Your public key has been saved in 【秘密鍵のファイル名】.pub.

作った公開鍵をラズパイ側の~/.ssh/authorized_keysにコピーします。以下はマスターノードの設定で実行したコマンドです。

$ ssh-copy-id -i 【秘密鍵のパス】 pi@192.168.13.101
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "秘密鍵のファイル名.pub"
The authenticity of host '192.168.13.101 (192.168.13.101)' can't be established.
ECDSA key fingerprint is SHA256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.
Are you sure you want to continue connecting (yes/no)? yes
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
pi@192.168.13.101's password: 

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh 'pi@192.168.13.1021"
and check to make sure that only the key(s) you wanted were added.

SSH接続しやすいようにPC設定

.ssh/configに以下のように追加します。

Host k8s-master-01
    HostName 192.168.13.101
    User pi
    Port 22
    IdentityFile 【秘密鍵のパス】

ここまででSSH接続の設定は終わりです。以後の作業は、PC環境からSSHで各ラズパイ環境接続して作業を行っていきます。

Dockerをインストール

各ラズパイ環境にDockerをインストールしていきます。

$ curl -sSL https://get.docker.com/ | sh

kubelet kubeadm kubectl kubernetes-cni のインストール

https経由でaptを実行するために必要なパッケージをインストールします。

$ sudo apt-get install -y apt-transport-https

Google cloudの公式キー登録します。

$ curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key add -

ソースリストにkubernetesを追加します。

$ echo "deb http://apt.kubernetes.io/ kubernetes-xenial main" | sudo tee /etc/apt/sources.list.d/kubernetes.list

apt更新

$ sudo apt-get update

kubelet kubeadm kubectl kubernetes-cniをインストールします。

$ sudo apt-get install kubelet kubeadm kubectl kubernetes-cni

各ノード間でバージョンが異なると予期せぬ動きに繋がるので、バージョンが変わらないようにしておきます。

$ sudo apt-mark hold kubelet kubeadm kubectl kubernetes-cni

マスターノードのセットアップ

以下のコマンドを実行し、kubernetesクラスタのコントロールプレーンを初期化します。

$ sudo kubeadm init --pod-network-cidr=10.244.0.0/16

後にネットワークaddonとしてインストールするflannelのドキュメントを読むとkubeadmの場合、pod-network-cidrに制約があるようなのでドキュメント通りにしています。

他の有名どころのネットワークaddonとして

  • Calicoは、AMD64プロセッサが動作要件にある。Raspbianは32bitなので候補から除外
  • Weaveに関しては、ARMをサポートしているが32bitに対応しているか明示されていないので候補から除外

消去法で残ったflannelを使うことにしています。

kubeadm initすると実行結果の一番最後の他のワーカーノードがジョインするためのコマンドが表示されるので控えておきます。

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 192.168.13.101:6443 --token XXXXXXXXXXXXXXXXX \
    --discovery-token-ca-cert-hash sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

続いて、kubeadm initの実行結果に記載されているコマンドを実行します。

$ mkdir -p $HOME/.kube
$ sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config

現時点でどのPodが動いているか確認してみます。

$ kubectl get pods --all-namespaces

NAMESPACE     NAME                                    READY   STATUS    RESTARTS   AGE
kube-system   coredns-5c98db65d4-4bhgq                0/1     Pending   0          14m
kube-system   coredns-5c98db65d4-p5s4t                0/1     Pending   0          14m
kube-system   etcd-k8s-master-01                      1/1     Running   0          14m
kube-system   kube-apiserver-k8s-master-01            1/1     Running   0          13m
kube-system   kube-controller-manager-k8s-master-01   1/1     Running   0          14m
kube-system   kube-proxy-dz99s                        1/1     Running   0          14m
kube-system   kube-scheduler-k8s-master-01            1/1     Running   0  

Pod network addonがなく、corednsのpodがREADYの状態になっています。 Network addonとしてflannelを入れてみます。

# コマンドは https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/#pod-network から取得
$ kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/62e44c867a2846fefb68bd5f178daf4da3095ccb/Documentation/kube-flannel.yml

数分待ってから再度Podの状態を確認してみると

$ kubectl get pods --all-namespaces

NAMESPACE     NAME                                    READY   STATUS    RESTARTS   AGE
kube-system   coredns-5c98db65d4-4bhgq                1/1     Running   0          37m
kube-system   coredns-5c98db65d4-p5s4t                1/1     Running   0          37m
kube-system   etcd-k8s-master-01                      1/1     Running   0          37m
kube-system   kube-apiserver-k8s-master-01            1/1     Running   0          36m
kube-system   kube-controller-manager-k8s-master-01   1/1     Running   1          37m
kube-system   kube-flannel-ds-arm-szj4t               1/1     Running   0          4m50s
kube-system   kube-proxy-dz99s                        1/1     Running   0          37m
kube-system   kube-scheduler-k8s-master-01            1/1     Running   1

全Pod Runningになっていることを確認できました。

ワーカーノードをKubernetesクラスに追加

残りのワーカーノードにSSHログインして、先程控えておいたクラスタ参加のためのコマンド実行するだけです。

$ sudo kubeadm join 192.168.13.101:6443 --token XXXXXXXXXXXXXXXXX \
    --discovery-token-ca-cert-hash sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

再度マスターノードにSSHログインして、以下のコマンドを実行すると

$ kubectl get nodes

NAME            STATUS   ROLES    AGE   VERSION
k8s-master-01   Ready    master   58m   v1.15.3
k8s-worker-01   Ready    <none>   17m   v1.15.3
k8s-worker-02   Ready    <none>   19s   v1.15.3

無事3台のノードでkubernetesクラスタができていることが確認できました。

参考資料