2020年11月10日火曜日

Ruby でオブジェクトを簡単に JSON シリアライズする方法

概要

Rails を使っている場合には ActiveModelSerializers があります
単体で使用している場合には使いづらいケースもあります
そんな場合にはシンプルに使える Blueprinter が使えます

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • blueprinter 0.25.1

準備

  • bundle init
  • vim Gemfile
gem "blueprinter"
  • bundle install

まずは普通のクラスを定義

テスト用に作成します
これを JSON にシリアライズしてみます

class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def show_profile
    puts @name
    puts @age
  end

  attr_accessor :name, :age
end

u = User.new("hawksnowlog", 10)
u.show_profile

シリアライズできるようにする

blueprinter と json を require します
json を require しないと uninitialized constant Blueprinter::Configuration::JSON (NameError) になります
シリアライズするクラスは Blueprinter::Base を継承します

あとは JSON のフィールドとして表示するインスタンス変数を fileds で定義します
JSON として出力する場合は render メソッドをコールして生成した User クラスのオブジェクトを渡します

require 'blueprinter'
require 'json'

class User < Blueprinter::Base
  fields :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def show_profile
    puts @name
    puts @age
  end

  attr_accessor :name, :age
end

u = User.new("hawksnowlog", 10)
puts User.render(u)

識別子の自動付与

identifier を使います
uuid というメソッドを定義するか identifier の引数にブロックを与えることで uuid として設定する値を決めることができます
今回は SecureRandom.uuid の値を返していますが本来はデータベースなどから識別子を取得して返却するようになると思います

require 'blueprinter'
require 'json'
require 'securerandom'

class User < Blueprinter::Base
  identifier :uuid
  fields :name, :age

  def initialize(name, age)
    @name = name
    @age = age
  end

  def uuid
    SecureRandom.uuid
  end

  def show_profile
    puts @name
    puts @age
  end

  attr_accessor :name, :age
end

u = User.new("hawksnowlog", 10)
puts User.render(u)

フィールドのデフォルト値の設定

nil などでインスタンス変数の値が初期化されていない場合に返却するデフォルトの値を設定することができます
Blueprinter 全体の設定をするには Blueprint.configure を使います
ここで config.field_default にデフォルトの値を設定することができます

require 'blueprinter'
require 'json'
require 'securerandom'

class User < Blueprinter::Base
  identifier :uuid
  fields :name, :age

  Blueprinter.configure do |config|
    config.field_default = "N/A"
    config.association_default = {}
  end

  def initialize(name, age)
    @name = name
    @age = age
  end

  def uuid
    SecureRandom.uuid
  end

  def show_profile
    puts @name
    puts @age
  end

  attr_accessor :name, :age
end

u = User.new("hawksnowlog", nil)
puts User.render(u)

最後に

簡単ですが Blueprinter の使い方を紹介しました
to_json などを実装して自分でシリアライズするメソッドを実装しても問題ないですがデフォルト値やバリデーションなどを考えるとすでにその辺りが実装されている Blueprinter が便利かなと思います

既存のクラスを拡張する必要があるのでそこがネックになる可能性もありますが新規のクラスであれば簡単に拡張できると思います

参考サイト

2020年11月9日月曜日

pyenv でインストールした python で ModuleNotFoundError: No module named 'lsb_release' が発生した場合の対処方法

概要

過去に Ubuntu に pyenv をインストールし最新版の python をインストールする方法を紹介しました
pyenv でインストールした python をメインに使っていると lsb_release というコマンドが 0 以外のステータスを返しエラーになってしまいます
そんな場合の対処方法を紹介します

環境

  • Ubuntu 16.04 (on Vagrant)
  • Python 3.8.5
  • pyenv 1.2.20

対処方法

  • cp /usr/share/pyshared/lsb_release.py /home/vagrant/.pyenv/versions/3.8.4/lib/python3.8/site-packages

pyenv でインストールすると site-packages 配下に lsb_release.py がないためエラーになっていました
lsb_release コマンド自体が動作しなくても問題はないですが他のツールで影響を受ける場合は上記の対応をしてください

Tips: site-packages 配下を表示する方法

  • python3 -c "import site; print (site.getsitepackages())"

参考サイト

2020年11月6日金曜日

Ansible で Docker Swarm 環境を構築してみた

概要

community.general.docker_swarm なるロールがあったのでこれを使って docker swarm 環境を構築してみました
なお作業するマシンは Swarm の Master 側の Ubuntu 上で行います

環境

  • Ubuntu18.04 x 2
  • docker 19.03.13
  • ansible 2.9.15

docker の 2376 ポートの開放

こちらを参考に開放してください

お互いのマシンから 192.168.100.10:2376/info にアクセスして JSON が返ってくれば OK です

お互い SSH できるようにする

  • ssh-keygen -t rsa

をお互いのマシンで実行し id_rsa.pub を作成します
そしてそれぞれのマシンの authorized_keys に記載しましょう

ロールのインストール

  • ansible-galaxy collection install community.general

playbook の作成

では playbook を作成していきます
完成形は以下のような構成にします

.
├── group_vars
│   └── test
│       └── vars.yml
├── inventory
│   └── test
│       └── hosts.yml
├── roles
│   └── swarm
│       └── tasks
│           └── main.yml
└── site.yml

inventory ファイルの作成

  • mkdir -p inventory/test
  • vim inventory/test/hosts.yml
---

test:
  hosts:
    ubuntu:
      ansible_host: "{{ worker_ip }}"
    ubuntu2:
      ansible_host: "{{ manager_ip }}"

変数ファイルの作成

  • mkdir -p group_vars/test
  • vim group_vars/test/vars.yml
---

ansible_python_interpreter: /usr/bin/python3

cluster_interface: enp0s8

manager_ip: 192.168.100.11
worker_ip: 192.168.100.10
manager_host_name: ubuntu2

タスクファイルの作成

  • mkdir -p roles/swarm/tasks
  • vim roles/swarm/tasks/main.yml
---

- name: Install docker-py
  pip:
    name: docker-py

- name: Init a new swarm with default parameters
  community.general.docker_swarm:
    state: present
    advertise_addr: "{{ manager_ip }}"
  register: rezult
  when: lookup('vars', 'ansible_' + cluster_interface).ipv4.address == manager_ip                                                

- name: Add nodes
  community.general.docker_swarm:
    state: join
    advertise_addr: "{{ worker_ip }}"
    join_token: "{{ hostvars[manager_host_name]['rezult'].swarm_facts.JoinTokens.Worker }}"                                      
    remote_addrs: [ "{{ manager_ip }}:2377" ]
  when: lookup('vars', 'ansible_' + cluster_interface).ipv4.address == worker_ip

ちょっと長いので説明します

まず docker-py をインストールしています
今回使用する community.general.docker_swarm は内部的に docker-py を使って docker の操作をしているためです

次に swarm init しています
初期化するノードは manager ノードのみなので IP で実行するノードを制限しています
ここは IP ではなく inventory や group で制御しても問題ないです

最後に swarm join しています
これは worker ノードで実行する必要があるのでこれも IP で制御しています
更に join するためのトークンは manager ノード側の hostvars に格納されているのでそれを参照するようにしています
remote_addrs は docker swarm 用の管理ポートになるので開放した 2367 ではなく swarm manager が起動した際の 2377 を指定します

実行メインファイルの作成

ロールを指定して実行するだけです

  • vim site.yml
---

- name: Build swarm cluster
  hosts: test
  become: true
  roles:
    - role: "swarm"
      tags: ["swarm"]

動作確認

実行してみましょう

  • ansible-playbook -i inventory/test site.yml

成功すると docker swarm クラスタが構築されています

  • docker node ls

あたりで確認すると良いと思います

最後に

Ansible で community.general.docker_swarm を使って docker swarm を構築してみました
今回は 2 台だったのでイマイチありがたみが出てきませんでしたがこれが 10 台くらいの規模になると楽な運用ができるようになると思います

今回は単純な join だけを紹介しましたが leave や update も実行できるのでコマンドのオペレーションは一通り Ansible にすることはできそうです

参考サイト

2020年11月5日木曜日

ansible で nickjj.docker を使ってみた

概要

nickjj.docker は ansible を使って docker 環境を構築できるロールです
今回は Ubuntu 18.04 上で試してみました

環境

  • Ubuntu 18.04 (on Vagrant)
  • ansible 2.9.15

Vagrantfile

# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/bionic64"
  config.vm.network "private_network", ip: "192.168.100.11"
  config.vm.provider "virtualbox" do |vb|
    vb.memory = 4096
    vb.cpus = 2
  end
end

ansible インストール

Vagrant で構築して Ubuntu 上で作業していきます

  • sudo apt -y update
  • sudo apt -y install software-properties-common
  • sudo apt-add-repository --yes --update ppa:ansible/ansible
  • sudo apt -y install ansible

ノンパスログイン

  • ssh-keygen -t rsa
  • cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
  • chmod og-wx ~/.ssh/authorized_keys

ロールインストール

  • ansible-galaxy install nickjj.docker

試す

  • vim hosts
[localhost]
localhost
  • vim site.yml
---
# site.yml
- name: Example
  hosts: localhost
  become: true
  roles:
    - role: "nickjj.docker"
      tags: ["docker"]
  • ansible-playbook -i hosts site.yml

これで docker の最新版がインストールされています
ログを見ているとわかりますが公式の手順と同様にリポジトリの追加など行っています
dockerd の設定なども playbook から変更できそうです

  • docker -v

=> 19.03.13

  • docker-compose -v

=> 1.26.2

デフォルトで docker-compose もインストールしてくれます
バージョンの指定やレジストリの URL の指定もレシピからできます

最後に

nickjj.docker を使って docker のインストールと docker-compose のインストールをしてみました
ansible 環境がすでにある場合はこれを使うと簡単にインストールできそうです

Swarm の構築までは行ってくれなさそうです

参考サイト

2020年11月4日水曜日

Python3 の urllib を使って multipart/form-data を送信してみた

概要

urllib では multipart/form-data を送信するための便利メソッドなどは用意されていません
なので今回は自力で multipart/form-data のリクエストボディを生成してリクエストしてみました

環境

  • macOS 10.15.7
  • Python 3.8.5

テキストファイルを送信するサンプル

multipart/form-data はすごい簡単に説明すると boundary という境界文字で区切った複数のコンテンツを送信する方式です
まずは適当な文字列のフォーム+テキストファイルのフォームを送信してみました

  • vim text_file_test.py
import urllib
import urllib.request

url = "http://localhost:8080/upload"
encoding = "utf-8"
boundary = "--------python"

def multipart_formdata():
    lines = []

    lines.append('--' + boundary)
    lines.append('Content-Disposition: form-data; name="message"')
    lines.append('')
    lines.append("hello")

    lines.append('--' + boundary)
    lines.append('Content-Disposition: form-data; name="uploadfile"; filename="uploadfile.txt"')
    lines.append('Content-Type: text/plain')
    lines.append('')
    lines.append("hoge")
    lines.append('')
    lines.append("--" + boundary + "--")
    lines.append('')

    value = "\n".join(lines)
    return value.encode(encoding)

def request_with_multipart_formdata():
    req = urllib.request.Request(url)
    req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
    data = multipart_formdata()
    with urllib.request.urlopen(req, data) as response:
        print(response.status)

request_with_multipart_formdata()

バイナリファイルを送信するサンプル

画像などのデータを送信する方法です
受信側が Content-Transfer-Encoding: base64 に対応していれば送信できるはずですが

import urllib
import urllib.request
import base64

url = "http://localhost:8080/upload"
encoding = "utf-8"
boundary = "--------python"

f = open("image.png", "rb", buffering=0)
file_data = f.read()
f.close()

def multipart_formdata():
    value = '--' + boundary
    value += 'Content-Disposition: form-data; name="message"'
    value += ''
    value += 'hello'
    value += '--' + boundary
    value += 'Content-Disposition: form-data; name="uploadfile"; filename="image.png"'
    value += 'Content-Type: image/png'
    value += 'Content-Transfer-Encoding: base64'
    value += ''
    value += str(base64.b64encode(file_data))
    value += ''
    value += "--" + boundary + "--"
    value += ''

    return value.encode(encoding)

def request_with_multipart_formdata():
    req = urllib.request.Request(url)
    req.add_header("Content-Type", "multipart/form-data; boundary=%s" % boundary)
    data = multipart_formdata()
    with urllib.request.urlopen(req, data) as response:
        print(response.status)

request_with_multipart_formdata()

最後に

標準の urllib ではかなり辛いので素直に requests を使うのが良いと思います

参考サイト

2020年11月3日火曜日

ansible vault 超入門

概要

触ってみないことにはわからないのでこちらのチュートリアルっぽい記事を参考に自分でも試してみました

環境

  • macOS 10.15.7
  • ansible 2.9.10

とりあえず動作する playbook の作成

まずはパスワードを暗号化していない状態で正常に動作する playbook を作成します

  • vim hosts
localhost ansible_connection=local
  • vim site.yml
---
- hosts: localhost
  gather_facts: no
  vars_files:
    - vault.yml
  tasks:
    - debug: msg="password = {{ password }}"
  • vim vault.yml
---
password: 'hogehoge'

これで実況すると vault.yml のパスワードの内容がそのまま表示されるのが確認できると思います

  • ansible-playbook -i hosts site.yml

ansible vault を使って vault.yml に書かれているパスワードを暗号化する

ansible-vault encrypt コマンドを使って先程の暗号化されていない vault.yml ファイルを指定します
この際に暗号化するためのパスワードが要求されるので適当に入力しましょう

  • ansible-vault encrypt vault.yml
New Vault password: Confirm New Vault password: Encryption successful

暗号化に成功すると上記のようになります
暗号化に使用したパスワードは別途ちゃんと覚えておきましょう

ファイルの内容を確認すると今度は暗号化された内容になっているが確認できると思います

  • cat vault.yml
$ANSIBLE_VAULT;1.1;AES256 63353464303138643235653836613236623637613733626562663234363866393836653064323337 3230346232373261353165616561313831323134633338390a396666303637323336346566343836 30306662343835303261386235313064636162373064626539386539633334396530383766326438 6230386530393864330a643162386461613066653537666431636162353361303934336361626131 32646165653533346639343137356465396362613237643562363030393966663565

この状態で再度 playbook を実行しようとすると今度はエラーになります

  • ansible-playbook -i hosts site.yml
ERROR! Attempting to decrypt but no vault secrets found

暗号化されたファイルを使って playbook を流してみる

暗号化する際に入力したパスワードを使うことで暗号化されたファイルを使って playbook を実行することができます
--ask-vault-pass というオプションを使えば OK です

  • ansible-playbook -i hosts site.yml --ask-vault-pass

これで Vault password の入力が求められるので暗号化したときのパスワードを入力すれば OK です

Vault password を入力しない場合は

毎回 Vault password を入力するのが面倒という場合は ansible.cfg を使うことで解決できます

  • vim ansible.cfg
[defaults]
vault_password_file = ~/.vault_password
  • vim ~/.vaule_password

でファイルを開いて Vault password を書いておくことで --ask-vault-pass が不要になります

  • ansible-playbook -i hosts site.yml

暗号化したファイルを復号化するには

単純に復号化してファイルの内容を確認することができます
その場合は ansible-vault decrypt を使います

  • ansible-vault decrypt vault.yml

ansible.cfg に vault_password_file が記載されていてすでに復号化用のパスワードが記載されている場合は Vault password を入力せずに復号化できます

再度暗号化する場合は ansible-vault encrypt を使います

  • ansible-vault decrypt vault.yml

最後に

ansible-vault を使ってみました
やろうと思えばこれですべての playbook を暗号化できますがそこまでやる必要はないと思います

参考サイト

2020年11月2日月曜日

Sinatra で Rack::Attack を使ってみる

概要

rack-attack は Rack ミドルウェアを使ってアクセス制御を実現できるミドルウェアです
Sinatra は Rack を実装しているので今回は Sinatra を使って Rack::Attack を使ってみました
ただ使ってみた感じだと blocklist と throttle しか動作していないように見えました
ローカルネットワークからのアクセスだったのでローカルネットワークからのアクセスは基本的には許可しているのかもしれません

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • sinatra 2.0.7
    • rack-attack 6.3.1

インストール

  • vim Gemfile
gem "sinatra"
gem "rack-attack"
gem "activesupport"
  • bundle install

activesupport はスロットリングの機能に使います

有効にする

基本的には config.ru のみを編集していきます

  • vim config.ru
require './app'
require "rack/attack"

use Rack::Attack
Rack::Attack.enabled = true

run MyApp

アプリは何でも OK です

  • vim app.rb
require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/admin' do
    'ok'
  end
end

起動して問題なくアクセスできるか確認します

  • bundle exec rackup config.ru -o 0.0.0.0
  • curl localhost:9292/admin

アクセス可能な IP で制御する

アクセス可能な IP で制御してみます
blocklist というメソッドにブロックでアクセス拒否する条件を記載できます
request オブジェクトが受け取れるのでリクエストの情報を元に制御するのが基本になります

# coding: utf-8
require './app'
require "rack/attack"

use Rack::Attack
Rack::Attack.enabled = true

Rack::Attack.blocklist("特定の IP のみ admin パスにアクセスできる") do |request|
  !(request.ip == "127.0.0.1" && request.path == "/admin")
end

run MyApp

本来であれば safelist を使って否定構文を削除したほうが良いです
なぜか safelist がうまく動作せず blocklist を使っているためこのような書き方になっています

起動して動作確認してみましょう
ローカルからのアクセスは拒否されないことを確認しましょう

  • bundle exec rackup config.ru -o 0.0.0.0
  • curl localhost:9292/admin

=> ok

アクセス不可なパスを制御する

例えば特定のヘッダが付与されていないとアクセスできないパスを作ってみます
blocklist を使ってパスとヘッダ情報をチェックしましょう

  • vim config.ru
# coding: utf-8
require './app'
require "rack/attack"

use Rack::Attack
Rack::Attack.enabled = true

Rack::Attack.blocklist("API キーが設定されているユーザのみ admin パスにアクセスできる") do |request|
  !(request.env["HTTP_APIKEY"] == "secret-string" && request.path == "/admin")
end

run MyApp

起動して動作確認してみましょう
ちゃんとヘッダがないと 403 になることが確認できます

  • bundle exec rackup config.ru -o 0.0.0.0
  • curl localhost:9292/admin

=> Forbidden

  • curl -H "APIKEY: secret-string" localhost:9292/admin

=> ok

スロットリングしてみる

スロットリングとは時間と回数によるアクセス制御のことです
例えば 2 秒間に 5 回しかアクセスできないようなスロットリングをしてみます

# coding: utf-8
require './app'
require "rack/attack"
require "active_support"

use Rack::Attack
Rack::Attack.enabled = true
Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

Rack::Attack.throttle("2 秒間に 5 回しかアクセスできない", limit: 5, period: 2) do |request|
  request.ip
end

run MyApp

起動して動作確認してみましょう
連続でアクセスすると ok が 5 回続いた後はすべて「Retry later (429)」になることが確認できます
そして 2 秒後に再度 5 回アクセスできることが確認できると思います

  • bundle exec rackup config.ru -o 0.0.0.0
  • for i inseq 1 60; do curl localhost:9292/admin; done

=> okokokokok => Retry later

最後に

Sinatra で Rack::Attack を使ってみました
Rails で使う例はたくさん見かけたのですが Sinatra で使う方法はあまり紹介されていなかったので自分で試してみました

safelist がうまく動作していないように見えましたがもしかするとローカルネットワークからのアクセス意外であれば動作するかもしれません
スロットリングなどはパスやユーザ名などいろいろなパラメータでスロットリングを掛けることもできるので便利な機能かなと思います

参考サイト