新着記事

実務経験0 入社一年目のエンジニアが任されたセキュリティの話

2021年春に入社しました、情報システム部の清瀬です。

TORICOの情報システム部では、セキュリティの向上を上半期の目標に掲げておりました。上半期も終わりに近づいてきたということで、新卒で入社した私がどんなセキュリティ向上のために携わってきた業務を記事にしようとおもいます。多くのシステムでは Django を使用しているためコード部分は Django 前提の話になります。

1. インフラレベルでの対応

AWS WAF の導入

  • SQLインジェクション対策
  • クロスサイトスクリプティング
  • その他、包括的な対策

TORICOでは一部の社内用アプリを除き、全てのサービスをAWSでデプロイしています。それら全てのサーバを悪意のあるリクエストから守るために、AWS WAF を導入しました。

AWS WAF は2019年に大幅アップデートされ、今までのものは WAF Classic と名称を変更しました。新しいWAFの最大のメリットは、るAWSによって作られたルールを簡単に導入できる点です。例えば AWSManagedRlesCommonRuleSet では、OWASPに記載された主要な脆弱性・リスク10個をカバーしてくれています。クロスサイトスクリプティングや悪意のあるボットを排除してくれるルールをボタン一つで導入できるため、包括的なセキュリティ対策をすることができます。もちろん、今までのようにオリジナルのルールを作ることができます。そのため、社内からのアクセスには一部のルールを緩和させたりなどもできるようになっています。

余談ですが、WAFの導入の検証を担当したのは、まだ私がインターン1週間くらいの時でした。検証のために簡易的な Django アプリを Ec2 にデプロイしてWAFの導入でクロスサイトスクリプティングやSQLインジェクションがきちんとブロックされるのかを検証しました。インターンなのにこんな重要そうな仕事をさせていただけるのかと驚いたのを今でも覚えています。

過去に使用していた IP アドレスのルーティングを解除

これはセキュリティとは少し違う話かもしれませんが、ドメインの整理などもしました。一部の未使用のドメインが、既に手放してある IP アドレスと結びついたまま放置されていました。もし IP アドレスが悪意のあるサイトに使われていた場合、最悪ユーザーを悪意のあるサイトに誘導してしまうということも起こり得ます。そうならないためにも、不要なドメインを整理しました。

2. アプリレベルでの対応

サービスのAdmin ・社内アプリ を社内からのみ許可する

  • 不正なアクセス対策
  • 個人情報の保護

こちらは Django の Middleware を使って対応しました。サブネットマスクの表現方法などが複数あるため IP を取り扱うのは面倒くさそうだなと思っていましたが、python には ipaddress という便利なモジュールが標準で搭載されています。このモジュールを使うことで簡単に実装することができました。このタスクは上記の WAF でも実装できますが、1つのALBで複数の社内アプリを制御していたためアプリレベルで対応することにしました。アプリレベルのため、各アプリに適した制限を導入することも簡単にできます。

ログインセッションキーを適切な設定にする

  • セッションハイジャック対策
  • CSRF対策

大事なセッションを管理してくれるクッキーにはセキュリティ対策は欠かせません。TORICOの全てのサイトで、その大切なCookieの設定が適切になるように対策をしました。具体的には HttpOnly Secure SameSite=Lax  の3つの設定です。これらの設定を簡単に説明すると、

HttpOnly: JavaScript からセッションにアクセスできないようにする。設定されていなかった場合、JS 経由で セッション情報が漏洩する危険がある。

Secure: https 通信でのみ セッションを送るようにする。設定されていなかった場合、暗号化されない Http 通信でセッションが送られてしまう危険がある。

SameSite: Strict, Lax, None の3つの設定がある。Lax の場合、別オリジンからの POST の時はセッションを送らない。None の場合、例えばECサイトの届け先を変更するフォームのある偽サイトからユーザーのセッションを使ってデータが送信されてしまう。

Django では 2系以上であれば settings で設定することができます。1系の場合は SameSite を設定することができません。クッキーセッションの注意点として、あまりにも厳しい設定にした場合、サービスがうまく動かなくなる可能性があります。特に SameSite は決済部分でエラーがでることがあるそうです。

個人情報出力時に追跡IDとログを残す

  • 個人情報漏洩時の漏洩元の究明

TORICOでは 商品の配送などの過程で、お客様の住所をCSVに出力することがあります。もしそういったデータが社外に流出した時、どのデータが流出したのかを追いやすいように追跡IDをそれぞれのファイルに追記するようにしました。ログにはリクエストの情報を全てを保存しているため、いつ・誰が・どの検索ワードで CSV を出力したのかを追うことができます。 Django には DB へのログ出力機能はないため、DBに出力する場合には自作する必要があります。

3. データベースの対応

重要なフィールドの暗号化

  • 個人情報の保護
  • データ漏洩時の影響を最小限にする

重要なフィールドを可逆暗号化することで、万が一情報漏洩した時でもその影響を最小限に留められるようにしました。可逆暗号は Django のオリジナルのフィールドを作ることで簡単に対応することができました。具体的な実装方法についてはこちらの記事で書きました。また、一部の古い社内アプリではアカウントのパスワードがハッシュ化されていなかったため、 bcrypt という方法で不可逆暗号化もしました。

ソルトとペッパーの役割の違いなどが曖昧だったため、とても勉強になったタスクだったなと思っています。

最後に

上半期に行った主要なセキュリティ対策を紹介していきました。セキュリティは深刻な障害の原因になるため、普段のコーディングから気をつけていきたいと思います。

PMS&ISMS後編 Pマーク認定、ISO27001規格認証でのコンサルを選ぶポイントは、これだ・・・。


株式会社TORICO情報システム部の四斗邊です。

PMS、ISMS関連の後編です。
今回はP
マーク認証、ISO27001認証を取得する際のコンサルを選ぶポイントについて話したいと思います。

まずTORICOのブログをご覧になられている人ならご存知だと思いますが、TORICOは漫画のECサイトをメインに、デジタルコンテンツ配信、イベント事業行っている会社です。

従業員は150(20216月現在) を超え、事業規模の拡大に伴いPマーク認証、ISO27001認証を取得する流れになりました。

そんな私は入社早々にして取得するためのPJに参加したわけですが、今まで経験した会社に存在した規定などは、実はPマーク認証、ISO27001認証に基づいて行われていたんだなということがわかり、今まで関わった仕事を振り返り立ち返り興味深く関わることができたと実感しています。

ちなみにPMS
Pマーク認証とISMSISO27001認証の違いや説明については、前編をご覧ください。

作業内容は、今まで無法地帯だった箇所にメスを入れ、規律やルールを作成することが多かったと思います。具体的には、管理ルールの定まっていなかったPCの扱いについて、退勤時に施錠を徹底することなどです。

作成時は、これで良いのかと自問する不安の中での取得となっていました。

最終的には、「あ、これで良かったんだ」とある程度の経験と知識は蓄えることができたかなと思います。

さて前置きが長くなりました。

実はPマーク認証、ISO27001認証取得を経験した人が会社内に数名いると独力で取れるぐらいの取得しやすい認証なのですが、これを未経験者のみだけで挑戦すると、「何から手をつけていいのか」、「どうやったらいいのかわからない」ということが多すぎます。そんなときは、コンサルに頼んだほうが近道です。

弊社はもちろんコンサルに依頼することになりました。

ただコンサルを選ぶ際には以下の注意点を考慮されることを強くおすすめします。



1 Google Driveをつかってくれ!


弊社が担当していただいたコンサルは、情報保護の観点からかコンサル独自のアプリケーション、クラウドサービスを提供する会社でした。

その結果、保存する形式としてはofficeフォーマットでやり取りすることがほとんどでした。弊社はどちらかというとofficeなどを使わず共同で同時に編集作業できる点からGoogleのグループウェアを業務に多く取り入れている会社でしたので心底やりづらいです。

一旦編集したファイルをofficeファイルに変換しなければいけないことや、最新版であることが分かりづらいこと、共同編集できないなどGoogleのグループウェアの恩恵を受けられず効率が悪いと何度思ったか。

またクラウドストレージもコンサル独自のものがあったりと、Google Driveで共有しても対応してくれなかったというのがあるので、できれば事前にGoogle Driveなどグループウェアを共同で作業できるなどの事前確認は必要です。



2 リスクマネジメントは、一回マクロなしでやりませんか?


ISMS構築で一番大事なポイントといってもいいリスクマネジメント。

この作業を抜いではないといっても過言ではありません。

この作業を簡単に噛み砕くと「情報資産を特定して、リスクを把握して、驚異を数値化する」業務である。

この「作業の流れ」については、コンサルから説明を受けたので言葉では理解できるのですが、コンサルを交えて実践となった際に、事前に弊社の情報資産を予めピックアップした情報を渡していたので、書式に基づいて処理の流れを見せてくれるのかと思いきや、その部分は割愛されマクロでの処理の流れを説明されるという。

その場では、特に不明な点はないと思われたのですが、後々実際リスクマネジメントを回す際に見返すと「何やってんだ、この作業」となってしまいました。

そのため、できる限りマクロを交えず、実際の業務として落とし込めるまでコンサル立会のもと行うことを強くおすすめします。

特に書式やルールについては、コンサル提供の書式でもいいですが、コンサルと相談して独自の物を作って作業や管理のしやすいように改良することを強くおすすめします。

その場で頑なに拒んでくるコンサルは、実際のISMS構築やPMS構築の実務よりも審査を通ることのみを考えているコンサルではないかなと思われますので、注意が必要です。

 


3 審査期間の情報を豊富に持っているか?


弊社のコンサルでよかった点として、審査期間の情報を豊富に持っていた点があげられます。

実はPマーク認証、ISO27001認証の審査機関は1つではありません。

その審査機関の特徴といった情報を持っていることもコンサルの選ぶポイントと言えます。

大まかに以下の点があげられる。
① 審査の難易度
② 審査において指摘される傾向のあるポイントや過去や例
③ 審査にかかる費用

これらのデータや経験を持っているコンサルはなかなか頼りになるのではないかなと思います。

以上3つのポイントをご説明しました。

これらを踏まえてPマーク認証、ISO27001認証関連のコンサルを選ぶ際の参考にしてください。

前編と後編とかなり文字数多めで書きました。なかなかのボリュームとなっておりますのでお時間あるときにみていただけると大変嬉しく思います。
また、ネタがあればブログを書きたいと思います!

PMS&ISMS前編 PMSとISMSの違いについて


はじめまして株式会社TORICO情報システム部の四斗邊です。

いきなりですが、PMSとISMSについてお話したいと思います。

なぜこの話をしようと思ったかといいますと、株式会社TORICOでは、2021年1月にPマーク認証を取得、2021年2月にISMS認証(別名ISO/IEC 27001)を取得したからです!

この機会に、TORICOを知っていただこうと思い技術ブログとして若干逸れる内容ですが情報系のブログ記事としてPMS及びPマークとISMSに関する内容を前編と後編2回に分けてお話ししたいと思います。

まず、前編としてPMSおよびPマークとISMSの違いについて、後編はこれら認証取得時のコンサルの選ぶポイントをお伝えできればと思います。

それでは、前編スタート


1 PMS
Pマークについて


さっそくですがPMSとは「Personal information protection Management Systems」の略で個人情報保護マネジメントとも呼ばれています。

PMSは、個人情報保護を目的とした継続的な組織の設立や運営管理方法の制定と言った仕組みのことを指し「システム」と書かれていますが機械やプログラミングで構築したソフトウェアなのではありません。

具体的な仕組みとして継続的な個人情報管理台帳の作成と更新、漏洩リスクへの対策、漏洩時の対応など、実際に運用したPMSを評価する会議体を実施するなどがあげられます。

PMSによる個人情報の適切な取り扱いができるか審査を経て、JIS Q 15001規格に定義された個人情報保護マネジメントを実施する適合事業者と認められるとPマーク認証を取得できます。

ただ、Pマーク認証を取得していないからと言って企業には個人情報保護マネジメントが無いということでなく、審査を得ていない場合や、JIS Q 15001規格に沿っていない独自の個人情報保護マネジメントの構築している場合が企業には考えられます。


2 ISMSISO27001について


ISMSとは「information security management system」の略で情報セキュリティマネジメントシステムとも呼ばれています。

ISMSは企業が保有する情報資産を保護する目的のため継続的な組織の設立や運営、管理方法の制定など仕組みを意味します。PMS同様、「システム」と書かれていますが機械やプログラミングで構築したソフトウェアなのではありません。

具体的な仕組みとして、継続的な情報資産の作成と更新、情報を「機密性」「完全性」「可用性」ごとにリスクを特定(リスクアセスメント)し、リスクに合わせて適切に管理改善していくことです。

ちなみにISO27001は、ISMSの国際規格を指します。

別名ISO/IEC 27001、JISQ27001とも呼ばれています。

Pマーク同様、ISMS構築後、審査を得て適合事業者と認められるとISO27001認証を取得できます。

ちなみに、PMSとISMSは個人情報の部分において重複します。

ISMSは全ての企業情報を保護対象としているため上位互換のように表現されますが、あくまで企業の情報資産の一部として個人情報を扱うISMSに対して、個人情報保護を重要視している点で差があります。具体的としては、PMS(JIS Q 15001)では個人情報の目的や取得方法の特定や個人情報の開示、個人情報を第三者へ提供する場合のルールなど規格内に盛り込まれています(問い合わせページとかでやたら同意を求めてくるこれです。)

また、Pマーク認証は法人単位、ISO27001認証は部門やセクション単位で取得できるようです。


3 メリットについて


TORICOではPマーク認証とISO27001認証を取得する過程で既にメリットはありました。

取得に伴い実施したリスクアセスメントや個人情報の洗い出しによって日常の業務において到底意識しない部分に目を向ける機会になったからです。また、それまで関わりのなかった部署に対しても協力していただく機会もあったので全社的に危機意識を培うチャンスとなったと思います。

またリスクを未然に防ぐためにPMSとISMS構築が一役買いました。

これまで個人情報や企業の情報など保管期間を明確に決めずに保存していたり、バックアップを取らなかった部分については改めるきっかけになったと思います。

本来のメリットとしては、公的機関への入札時などPマーク認証とISO27001認証を取得が条件になっているようで、入札時のメリットがあるようです。

またPマークやISO27001認証を取得している企業から取引の際、信用の目安になり、比較的に取引しやすいなどもメリットに挙げられます。

デメリットは、業務フローが増えたことと、書式などのフォーマットが増える点でしょうか。

以上、PMSとISMSの違いについてお話ししました。

後編に続きます。

参考文献


システム管理者の会 

第4回 プライバシーマーク(Pマーク)と他の認証制度の統合


帝国データバンクネットコミュニケーション

ismsとiso27001は何が違うのですか?

WORKーPJ

ISMSとプライバシーマーク(PMS)とは?概要と相違点、どちらを取得するべきか

エンジニアの知らないISBNの話

本には商品判別のためのバーコードがついています。
いわゆるISBNコード(JANコード)です。
ECショップでは本を取り扱うために本の書誌を記録するテーブルを作成しますが、その際にint型でいいだろう? ユニーク制約でいいか?とおもってはいけないません。

先に結論を書いてしまうと

  • 文字が含まれる可能性がある
  • 先頭に0が来る可能性がある
  • ユニークではない

char型にして、ユニーク制約をつけないほうがよい理由

1. 文字「X」が含まれる可能性がある

ISBNコードには10桁と13桁があります。
10桁ISBNコードは9桁+チェックデジット1桁で構成されます。
古い体系ですが、一部で使うことがあり、チェックデジットに文字「X」がはいる可能性があります。
これはチェックデジットがモジュラス11、ウェイト10-2という方法で算出されるためです。
計算方法をphpで書くと

/**
 * ISBN10桁のチェックディジットを返す
 */
function getCheckDigit10($jan9)
{
    $sum = 0;
    $cnt = 0;
    for ($i = 10; ; $i--) {
        if ($cnt > 10) {
            break;
        }
        $sum += substr($jan9, $cnt, 1) * $i;
        $cnt++;
    }
    $remainder = $sum % 11;
    if ($remainder == 0) {
        return 0;
    }
    $check_digit = 11 - $remainder;
    if ($check_digit == 10) {
        $check_digit = 'X';
    }
    return $check_digit;
}

2. 先頭に0がつく可能性がある

ISBNコードと書きましたが、実際にバーコードになっているのはJANコードです。
そしてJANコードは先頭が「0」になる可能性があります。
本を売るショップとして取り扱いそうなJANコードは3種類。

  1. 一般商品JANコード
    45,または49で始まる12桁+チェックデジット1桁の13桁JANコード。
    45と49は日本に割り振られた国コードなので固定。
    雑誌やグッズなどに使われています。
  2. 書籍JANコード
    9784で始まる12桁+チェックデジット1桁の13桁JANコード。
    978は世界共通の書籍コード
    4は日本に割り振られた国コードなので固定。
    いわゆる13桁ISBNコード。
    コミックや書籍などに使われています。
    バーコードは2段になっており、上段は書籍JANコード、分類・価格のコード。
  3. インストアコード
    02、04、20〜29で始まる12桁+チェックデジット1桁の13桁JANコード。
    ポイントカード、会員証やレジ袋などの管理・販売用に使われる、小売店が独自につくって使用してよいJANコード。
    20〜29始まりのJANコードを使用すればいい話だが、02、04も準備しておくに越したことはない。

JANコードのチェックデジットはモジュラス10、ウエイト3-1という方法で算出されるため0〜9の数字になります。

/**
 * ISBN13桁のチェックディジットを返す
 */
function getCheckDigit13($jan12)
{
    $sum_odd = 0;
    $sum_even = 0;
    for ($i = 11; $i >= 0; $i--) {
        if ($i % 2) {
            $sum_odd += substr($jan12, $i, 1);
        } else {
            $sum_even += substr($jan12, $i, 1);
        }
    }
    $sum = $sum_odd * 3 + $sum_even;
    $division = $sum % 10;
    return (10 - $division) % 10;
}

3. 同じJANコードが使われる

JANコードは同じコードが存在するのでユニークではありません。

  1. 書籍JANコードの場合
    「今現在販売中の本」と限定すれば書籍JANコードはユニークになります。
    ただし、本が絶版になったあと、そのJANコードは別の本に付番されます。
    そのため、今までに発売したすべての本のJANコードだと同じJANコードをもつ本が複数存在します。
  2. 雑誌の一般商品JANコードの場合
    雑誌に付番されている雑誌コードから一般商品JANコードが作成されます。
    その際に西暦の下1桁を使用しているため、10年ごとに同じコードになります。

以上がISBNコード(JANコード)をint型、ユニーク制約にしては行けない理由でした。
10桁と13桁のどちらも記録できるようにしてあると便利かもしれません。

海外回線からのブラウザリクエストを日本のオフィスから SOCK5プロキシを使って行う

海外回線からリクエストされた時に応答を変えるようなサービスで、実際の海外からアクセスした時のレスポンスを日本国内のPCから試したい場合は、SOCKS プロキシを使うと便利です。

実際に起動した、自分のコントロールしているサーバを使います。怪しげな VPN サービスを使わなくていいため安全です。

 SSHログインできる海外のサーバを用意する

海外リージョンで起動している AWS の EC2 などを使います。SSHでログインできるようになっており、HTTP関係のアウトバウンド通信がブロックされていなければOKです。

使っているPCで、 -D オプションをつけて SSHログインする

$ ssh -D 10080 user@example.com

これを行うと、普通にSSHのセッションが開始されますが、そのPCの 10080 ポートで SOCKS v5 プロキシが使えるようになってます。

SOCKS プロキシの接続先を、localhost:10080 にする

システム環境設定 から、プロキシの設定を行います。

Firefox はブラウザ内に固有のプロキシ設定を持っており、そこで特別な設定を行うこともできますが、デフォルトでは OS のプロキシ設定を使うようになっています。

接続を確認する

確認くんhttps://httpbin.org/ip を見て、接続元IPアドレスが変わっていることを確認できます。

Selenium でプロキシ設定済みのブラウザを起動する

システム環境設定を開いてプロキシの設定を変更するのは面倒ですが、Selenium を使えばプロキシ設定済みのブラウザインスタンスを起動できます。
( 127.0.0.1 となっている箇所は localhost でも同じです。)

Pythonスクリプトの例

Chrome

from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=socks5://127.0.0.1:10080")
options.add_argument('--proxy-bypass-list=""')
driver = webdriver.Chrome(options=options)
driver.get('about:blank')

Firefox

from selenium import webdriver
profile = webdriver.FirefoxProfile()
profile.set_preference('network.proxy.type', 1)
profile.set_preference('network.proxy.socks', '127.0.0.1')
profile.set_preference('network.proxy.socks_port', 10080)
profile.set_preference('network.proxy.no_proxies_on', '')
driver = webdriver.Firefox(firefox_profile=profile)
driver.get('about:blank')

ターミナル(シェル)でプロキシ設定済みの Chrome を起動する

open -a "Google Chrome" --args --proxy-server="socks5://127.0.0.1:10080" --proxy-bypass-list=""

Google Chrome や Chromium の場合、コマンドラインオプションでプロキシの設定を行うこともできます。ただし、一度起動してしまうと、プロセスを完全終了しないと設定がクリアされないので戻す時が若干面倒です。

Amazon Pay Checkout v2 API の署名 (RSA-SHA256 (RS256) + RSA PSS Padding) を Python で行う

Amazon Pay の API クライアントを書く際、Amazon のAPIサーバに送信するリクエストに、RSA-SHA256, RSA PSS パディングを使って署名を作り、リクエストに含めて送信する必要があります。

Java や Node はクライアントライブラリがあったので、それを使って簡単に署名できたのですが、弊社 TORICO ではサーバサイドは主に Python を使っており、既存のクライアントライブラリは無かったため、Node のライブラリを参考に署名コードを書きました。

Amazon Pay のAPI

今回は、Amazon Pay の Checkout v2 API を使う必要がありました。

https://developer.amazon.com/ja/docs/amazon-pay/intro.html

リクエストヘッダに含める署名については、
この CV2 (Checkout V2) の 署名リクエストのページに解説があります。

このページを見ていくと、「署名を計算します」の箇所に

署名を計算するには、ステップ2で作成した署名する文字列に秘密鍵を使用して署名します。 SHA256ハッシュとソルト長20のRSASSA-PSSアルゴリズムを使用します。結果をBase64エンコードして、この手順を完了します。 RSASSA-PSSを使用して計算されたすべての署名は、入力が同じであっても一意であることに注意してください。

とありますので、この「 SHA256ハッシュとソルト長20のRSASSA-PSSアルゴリズム」を Pythonでコーディングする必要があります。

ところで、この最後の「 入力が同じであっても一意であることに注意してください」は、「〜一意でない」の間違いじゃないですかね。英語版ページは調べてませんが。

Amazon Pay Scratchpad

https://pay-api.amazon.jp/tools/scratchpad/index.html

検証リクエストを発行できるサイトが用意されていますので、署名ロジックの確認に使うと良さそうです。
私は、今回の開発中は存在を知らなかったので、使っていません。

Node.js の場合

node.js の場合は、クライアントライブラリは
@amazonpay/amazon-pay-api-sdk-nodejs があり、これを使うと署名を含めたAPIリクエストが一発で行えます。

ヘッダの署名を行っているコードは

https://github.com/amzn/amazon-pay-api-sdk-nodejs/blob/master/src/clientHelper.js#L142 このあたりで、
「 SHA256ハッシュとソルト長20のRSASSA-PSSアルゴリズム」のコードは
https://github.com/amzn/amazon-pay-api-sdk-nodejs/blob/master/src/clientHelper.js#L84 ここです。

Python の場合

上記 node.js 相当のコードを書くわけですが、hmac ライブラリには相当のコードはありません。

RSA や パディングの基礎的なロジックは cryptography に入っており、実際の使い方は PyJWT がいい感じになっているので、PyJWT を参考にします。

https://github.com/jpadilla/pyjwt/blob/master/jwt/algorithms.py#L232

PSS パディングはここです。

https://github.com/pyca/cryptography/blob/main/src/cryptography/hazmat/primitives/asymmetric/padding.py#L19

この PSS の第一引数の _mgf ってなんだ、と思いましたが、同モジュール中にある
MGF1(RSAAlgorithm.SHA256())
を入れたら動きました。

署名部分のコード

署名部分のコードはこのようになります。

import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key

private_key = b'''-----BEGIN PRIVATE KEY-----
MIIEvQIB....
....N/Qn4=
-----END PRIVATE KEY-----'''

string_to_sign = 'AMZN-PAY-RSASSA-PSS\nxxxxxxxxxxxxxxxxxxxxxxxx'

key = load_pem_private_key(private_key, password=None)

signature = key.sign(
string_to_sign.encode('utf-8'),
padding.PSS(padding.MGF1(hashes.SHA256()), 20),
hashes.SHA256())

signature = base64.b64encode(signature).decode('utf-8')

後になって気づきましたが、PyJWT に RSAPSSAlgorithm という、今回の用途にぴったりなクラスがあったので、これを使うともうちょっとシンプルなコードになるかもしれません。

署名元の文字列の生成も含めたコード

checkoutSessions を行うコードはこのような感じです。

node.js のコードを参考にした箇所がいくつかあり、それらは実際には使われない、不要なコードになってます。

import base64
import datetime
from hashlib import sha256

import requests
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key

private_key = b'''-----BEGIN PRIVATE KEY-----
MIIEv....
....N/Qn4=
-----END PRIVATE KEY-----'''

config = {
'publicKeyId': 'SANDBOX-AEXXXXXXXXXXXX',
'privateKey': private_key,
'region': 'jp',
'sandbox': True,
}

constants = {
'SDK_VERSION': '2.1.4', 'API_VERSION': 'v2', 'RETRIES': 3,
'API_ENDPOINTS': {'na': 'pay-api.amazon.com', 'eu': 'pay-api.amazon.eu', 'jp': 'pay-api.amazon.jp'},
'REGION_MAP': {'na': 'na', 'us': 'na', 'de': 'eu', 'uk': 'eu', 'eu': 'eu', 'jp': 'jp'},
'AMAZON_SIGNATURE_ALGORITHM': 'AMZN-PAY-RSASSA-PSS',
}

checkoutSessionId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

options = {
'method': "GET",
'urlFragment': f"/v2/checkoutSessions/{checkoutSessionId}",
'headers': {},
'payload': ''
}

pay_date = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')

headers = {
'x-amz-pay-region': config['region'],
'x-amz-pay-host': 'pay-api.amazon.jp',
'x-amz-pay-date': pay_date,
'content-type': 'application/json',
'accept': 'application/json',
'user-agent': 'amazon-pay-api-sdk-nodejs/2.1.4 (JS/14.15.1; darwin)',
}

lowercase_sorted_header_keys = list(sorted(headers.keys(), key=lambda x: x.lower()))
signed_headers = ';'.join(lowercase_sorted_header_keys)

canonical_request = [
options['method'],
options['urlFragment'],
'', # GETパラメータだが一旦無し
] + [
f'{h}:{headers[h]}' for h in lowercase_sorted_header_keys
] + [
'', # 空行入れる
signed_headers,
sha256(options['payload'].encode('utf-8')).hexdigest()
]

canonical_request_bytes = ('\n'.join(canonical_request)).encode('utf-8')

string_to_sign = constants['AMAZON_SIGNATURE_ALGORITHM'] + '\n' + sha256(canonical_request_bytes).hexdigest()

key = load_pem_private_key(config['privateKey'], password=None)

signature = key.sign(
string_to_sign.encode('utf-8'),
padding.PSS(padding.MGF1(hashes.SHA256()), 20),
hashes.SHA256())

signature = base64.b64encode(signature).decode('utf-8')

headers['authorization'] = \
f"{constants['AMAZON_SIGNATURE_ALGORITHM']} " \
f"PublicKeyId={config['publicKeyId']}, " \
f"SignedHeaders={signed_headers}, " \
f"Signature={signature}"

response = requests.get(
f"https://pay-api.amazon.jp{options['urlFragment']}",
headers=headers
)
print(response)
print(response.json())

ファンのうるさい MacBookPro を静かにする

メインの開発機として MacBookPro 2019 16インチ Intel CPU を使っています。

ちょっとでも負荷のかかる処理をさせると、すぐファンが最高速で回ってしまいます。会社での使用時は問題ありませんが、在宅で静かな部屋の中で使っている場合、なかなか不快です。

今回、そのファンの音をなんとか静かにしようと試行錯誤したのでその記録を書きます。

M1 Mac はとても静か

おすすめ度: ★★★

主題から逸れますが、Apple Silicon M1 Mac は、Intel の MacBookPro と比較してとっても静かです。

そもそも M1 Mac の MacBookPro ではない MacBook Air は、ファンレスなので無音です。

しかし、MacBook Air は、CPU に高負荷をかけ温度が上がると、CPUの処理能力に制限を設けて温度を下げるため、開発業務には不向きです。開発業務に使うのであれば、 MacBook Pro が良いでしょう。

M1 Mac の大きな欠点として、同時に1つの外部ディスプレイしか標準で使えないことがあります。

この制限があるため、機種変更をためらっている方もいると思います。私もそうでした。

ただし、DisplayLink のチップを搭載したディスプレイアダプタをつければ、複数ディスプレイ出力に対応できます。DisplayLink チップを使うにあたり、今度は CPUリソースの消費や描画遅延が気になりますが、気にするほどの負荷はまったくありません。私の使っている機種は Plugable というブランドのものですが、全く問題なく、非常に快適です。

https://www.amazon.co.jp/dp/B08F2TSR43/

M1 Mac は、開発環境も Intel Mac 同等のものが現在は使えます。特に Docker が早いと感じます。よっぽど古いミドルウェアを扱ってない限り、問題なく開発できますので、今後開発をされる方は M1 Mac がおすすめです。

ファン付きPCスタンドを使ってみる

おすすめ度: ★☆☆

Intel の MacBook Pro の話に戻します。まず、ファンの回転数を下げるために本体を冷却させようと、ファンつきのノートPCスタンドを買ってみました。

使ってみましたが、キーボードが打ちにくくなる割りに、大した効果は見込めませんでした。5℃くらい下がったかも…と感じなくもないですが、高負荷の処理をすると結局最高速にファンが回るため、たいして変わりがありません。買わなきゃよかったと思います。

ゴム足で傾けてみる

おすすめ度: ★★☆

エアフローが少し改善されるかと思い、Mac の奥側のデスクにゴム足を貼り付け、本体を傾けて隙間を作ってみました。

結果、冷却効果は変わりませんでしたが、本体角度の影響か騒音がまろやかになり、不快感が減りました。コストも低いので、おすすめです。

後ほど、100円ショップでも同様の意図のゴム足が売ってたので、比較的一般的な方法のようです。

コストが安い割に、心理的メリットが大きいので試されることをおすすめします。冷却効果はほとんどなさそうです。

TG Pro を使ってソフトウェアでファンコントロールをする

おすすめ度: ★★☆

TG Pro というアプリが販売されており、インストールすると、温度センサーの温度とファン回転数の関係を制御できます。

(2021-08-23 現在、半額セールで JPY 1155)

冷却されるわけではないのですが、CPU温度70℃近辺のファン回転数を抑えることで、少しの消音化ができます。

それと、各種温度センサーやファン回転数を把握したり、メニューバーにファン回転数が出るのは使っててなかなか楽しいです。買って良かったと思いますし、常用しています。

↑MacBook Pro は多くの温度センサーが搭載されており、それぞれの温度を確認できる。

↑メニューバーに温度とファン回転数が出せる。

ファン制御は、最悪ハードウェア故障などにつながるため、扱いは自己責任となります。アプリ内でも、制御機能を有効にする際に警告が出ますので、よく理解して使ってください。

試用で15日間使えますので、興味があれば使ってみると良いでしょう。

Intel Power Gadget で CPU 周波数と温度を監視する

おすすめ度: ★★☆

こちらも直接冷却につながりませんが、温度を監視するのに Intel Power Gadget が便利です。
Intel のサイトからダウンロードでき、無料で使えます。

https://software.intel.com/content/www/us/en/develop/articles/intel-power-gadget.html

CPUの動作周波数と温度、電力、そしてシステムがCPUに要求している周波数などがモニタリングできるので、発熱する原因の調査に有効です。後述する、クロックアップ禁止の動作確認にも使えます。

Turbo Boost Switcher Pro でクロックアップを禁止する

おすすめ度: ★★★

Intel Mac の CPU は、Turbo Boost というテクノロジによって、処理量によって動作クロックが随時変更されるようになっています。私の Mac は 定格2.3GHz 動作ですが、負荷の高い処理を行おうとすると 4GHz ほどの動作クロックになるようです。

とはいえ、この Turbo Boost はバッテリーを多く消費し、発熱も高くなるため、安定動作を求めて OFF にする場合も多いようで、実施を記録したブログも多く見つかります。

高速化の処理を無効化するため、処理低下が感じられそうに思いますが、他のブログでは、多くが体感的な処理速度はそれほど感じかったと書いています。

実際、私も Turbo Boost を無効化したところ、遅くなったという感じはまったくしませんので、発熱を抑えたい方は Turbo Boost の無効化をおすすめします。

Turbo Boost を無効化するのは、Turbo Boost Switcher Pro というアプリを購入して実行する必要があります。起動時に自動的に無効化するには Pro 版が必要で、買い切り10ドル弱です。

http://tbswitcher.rugarciap.com/

(サイトの見た目はけっこう怪しい感じ)

他の紹介記事

Turbo Boost Switcherのご紹介: Macの爆音爆熱問題はTurbo Boostをオフにして解決しよう

Turbo Boost Switcher で Turbo Boost を無効化した後に、Intel Power Gadget で CPU のグラフを見ると、システムが CPUのオーバークロックを要求してるのをガン無視してるのがわかります。

↑矢印の箇所で開発環境を起動。CPUのオーバークロック要求が出ているが、オーバークロックされない。

Mac をデスクの下に置く

おすすめ度: ★★★

気になるファン音を直接耳に入れないようにするため、試しに机の天板の下(キャビネットの上)に Mac を隠すように置いてみたら、ファン音がほとんど気にならずかなり快適になりました非常におすすめです。もっと早く気づけばよかったです。

つまり、クラムシェルモードで使うということです。Mac のキーボード、トラックパッド、タッチバー、指紋認証などが使えなくなってしまうデメリットが大きいですが、ファンノイズがうるさいことに比べれば小さいデメリットです。机の上がすっきりする副次効果もあります。

クラムシェルモードといっても、完全に閉じてしまうとキーボードの隙間からの吸気ができなくなってしまうので、本体温度が上がってしまいます。

2016? 以前の MacBook は、磁石でディスプレイの開閉をセンシングしてたため、磁石をキーボード左側に置くことで、ディスプレイを閉めたと錯覚させることができました。

最近の MacBookは、開閉センサーが磁石ではなくディスプレイヒンジ部に入っている傾きセンサーになったので、磁石をmacの上に置くことでディスプレイをOFFにする擬似クラムシェルモードはできなくなりましたが、傾きセンサーには遊びがあるため、2cmぐらい開けても閉まってると認識されます。吸気のため、それぐらい開けておくと、温度上昇は普通に使っているのと同じぐらいに保てます。

Webクライアントはリッチに、モバイルアプリクライアントはシンに運用開発する

  • Django, Nest, Laravel, Rails などの サーバサイドアプリ(APIサーバ)
  • Vue, React などの Webクライアント
  • iOS, Android 用の モバイルアプリクライアン

これらを組み合わせてサービスを作ることはよくあり、TORICOでもそうです。

Webクライアントとモバイルアプリクライアントは役割として同じものになるので、同じような感覚で開発を始めることはよくあります。

しかし、アプリのファーストリリースが終了し、運用開発のフェイズとなった時、同じような感覚で機能追加を進めていこうとするとうまくいきません。モバイルアプリは、非常にリリース速度が遅いのです。

アップデート容易さの比較

サーバサイドアプリ、Webクライアント、モバイルアプリクライアントをそれぞれ並列で比較した時、それぞれの更新の容易さ、障害時のインパクトは以下のようになると考えます。

Webクライアント

サーバサイド

モバイルアプリ

更新の容易さ

()

()

障害時のインパクト

Webクライアントとモバイルアプリクライアントは、使用用途としては同じですが、アップデート時の性質はまったく逆です。

Webクライアントは、ブラウザでアクセスすれば常に最新版のプロダクトが使えます。これはほぼ必ず自動的に入手でき、古いバージョンがそのまま使われることはまずありません。

比較して、モバイルアプリクライアントは、アップデート作成時にプラットフォーマー( Apple, Google ) の審査をまず通さなければならず、審査を通すのに数日が必要です。アップデートを提供したとしても、利用者の設定によっては自動アップデートはされないため、古いバージョンを使い続けられることもありあす。

更新難度を下げるためのテクニック

モバイルアプリのコードを動的にアップデートする方法が存在します。ゲームで多く使われています。

他に、古いアプリバージョンでAPIアクセスした時にアップデートを促してサービスを受け付けないなどの対応も考えられます。ただしどちらも基本的にプラットフォームの規約違反なので、通常は選択できません。

更新難度が高い理由

例えば、APIのエンドポイントURLをリファクタリングにより更新したい場合、Webアプリの場合はクライアントも同時に更新してリリースすれば、大抵は問題ありません。

しかしモバイルアプリのクライアントを扱っている場合、旧バージョンのユーザーを考慮して、古い仕様のAPI エンドポイントもしばらく(年単位で)残しておく必要があります。この非生産的なサービス保守は、モバイルアプリ運用をする上での大きな負荷となります。

そのため、モバイルアプリクライアントでサービス運営をする場合、なるべくクライアントで計算(判断)をさせない設計とした方が素早いサービス改修が行えます。

アップデート頻度を下げる

例えば日付時刻をモバイルアプリクライアントで表示する場合。

DBに日時型で入っている情報を、どこかで文字列にフォーマットする必要がありますが、私は文字列へのフォーマットは大抵サーバサイドで行い、文字列でモバイルアプリに送るようにしています。また、何かのリスト表示時の並び替えなども、可能であればすべてサーバサイドで行い、なるべくモバイルアプリクライアントでの計算は減らします。

そうしないと、何かの調整時…例えば、日付時刻を表示する箇所に「未定」と表示したいケースとなった時、わざわざ新たなアプリをビルドしてプラットフォーマーの審査を通過させ、新旧両バージョンのクライアントが同時に存在することを考慮してAPIの改修も行わなくてはならないという、目指す成果に対して非常に時間と手間のかかる運用開発をしなければいけません。

さらに、そのアプリに問題があり、「未定」と表示しなければならない箇所でアプリがクラッシュしてしまうビルドをリリースしてしまうと最悪です。ある一定の利用者は、不具合のあるバージョンを更新することなく、使い続け、障害が発生し続けます。それがプラットフォーマーの目に止まった場合、最悪でアプリの配信停止までありえます。

この困難さは、Web畑の開発者からの理解は難しいかもしれません。私の感覚では、モバイルアプリをリリースし運用開発を行っていくのは、コード量が同じだとするとWebクライアントの5倍大変です。5倍時間がかかるとも言えます。目標が同じなのに、4倍の回り道が追加で強いられる感覚です。

Webクライアントの運用開発

逆に、Vue や React などの Web クライアントの場合、更新が簡単な上に何か不具合があっても致命的な問題には発展しづらいため、クライアントサイドでもサーバサイドでもできるような処理は、なるべくクライアントサイドに寄せていったほうが素早いリリースが行えます。

クライアントサイドの改修ミスが、不正な支払いにつながったりセキュリティホールになることはほとんどありません(これはモバイルアプリでも言えることですが)。逆に、サーバサイド改修では、ちょっとした修正ミスがセキュリティホールにつながったりコアサービスの停止になることは少なくありません。そのため、Webクライアントのほうがサーバサイド改修より気軽に新規リリースができます。

気軽に改修できる分、どちらでも行えるような処理はWebクライアント側になるべく寄せることで、リリース頻度を高め、早く顧客体験を上げることができ、サーバの負荷軽減にもつながります。

まとめ

Webクライアントとモバイルアプリクライアントは、行う仕事としては同じようなものですが、

  • Webクライアントは更新が容易で不具合発生時のインパクトも少ないため、よりリッチに設計していったほうが良い。
  • モバイルアプリクライアントは、更新に時間がかかり、更新も強制させることはできないため、よりシンに設計し、サーバ側で表示内容などをコントロールしたほうが良い。

と、逆の運用開発方針で進めていったほうが、プロジェクトをうまく進捗できると考えています。

Webの技術でレジ(POS) を開発・運用する

当社TORICOは、主にインターネット上での書籍の販売サービス、つまりECサイトを自社開発して展開しています。

その他に、自社でイベント会場(実店舗)を営業しており、そこではお客様に会場に来てもらうことでのサービスやグッズ販売を提供しています。

実店舗を営業するにあたり、レジ(POSシステム) が必要となります。TORICOでは、POSシステムもWeb技術で自社開発し運用しています。当社と同様に Web技術で POSを開発される方に向けて、私が対応したいくつかの経験を書き残します。

サーバサイドのシステム

当社の POS システムは、40万点以上の商品を扱う必要があり、そのDBとリアルタイムに連携する必要があるのでクラウド上でのサーバサイドアプリを開発運用しています。バーコードを元に商品DBからの商品検索を行ったり、売上をDBに記録するなどの機能を担っています。一般的にECサイト開発で書くようなコードとほぼ違いはありません。通常の開発と同様に、WebセキュリティとACIDを意識して書けば問題ありません。

当社では、Django + Django Rest Framework が鉄板構成のため、今回の POSシステムのサーバサイドも Django で開発しました。

クライアントサイドのシステム

クライアント側は、大きく分けてブラウザかモバイルアプリかを使うことになります。社内スタッフしか使わないという限定的なアプリであるため、SEOやアプリストアへのレギュレーション等は考える必要が無く、コンシューマ向けアプリを作るより開発は単純化できます。

Webブラウザかモバイルアプリか。どちらにも一長一短あり、展開したい構成によってどちらが適しているか変わってきます。展開規模が多ければ、両方を作ってみるのも良いですね。

結局、当社は最初は Webブラウザで作り実運用しています。現在はモバイルアプリでの研究開発も開始しています。
Webブラウザかモバイルアプリか、両者の長所や違いについては、この後のシステム構成案の個所で再度書きます。

レシート出力方法の選択

POSシステムである以上、会計レシートの出力が必須となります。

レシートプリンタは各社から市販されていますが、主に3通りの使い方が存在します。

1. Windows のプリンタドライバ・プリントキューで印刷する

通常のプリンタと同じように、Windows のプリンタドライバとプリントキュー経由でプリントする方法です。各社から市販されているレシートプリンタは、ほとんどに Windows 用のプリンタドライバも配布されており、これを使うことで OS やブラウザの印刷機能で幅広い表現のレシートをプリントできます。

特にブラウザを使う場合、HTMLでレシートがデザインできますので、高い表現力のレシートを素早く開発することができます。レシートカッターのついているプリンタにレシートカット指示を明示的に出すことはできませんが、大抵はプリント終了時に自動でカットされます。

通常の用紙設定と違い、設定には一工夫必要ですし、ソフトウェアによっては印刷の制限があったりします。例えば Chrome では最少文字サイズが決められていたり、マージンやヘッダーのレイアウトに限界があるため、ブラウザからのレシート出力をするのであれば Firefox がおすすめです。

パソコンの中でも Window と限定しているのは、Linux, Chromebook はプリンタドライバを提供しているメーカーが少ないため除外、Mac はレジに向いている端末が無いため POSレジの候補とならないためです。

2. プリントSDK を使う

主に iOS・Android 等のモバイルアプリOSでレシートを出力するには、プリントメーカーから公開されているSDK をアプリ内に組み込んで使うのが一般的で、これ以外の方法は難しいです。Windows用のSDKを公開しているメーカーも多いです。表現力は、HTMLより数段劣りますが、応答が早く、またレシートカッターやレジドロワーなどプリント以外の機能も柔軟に使えます。

3. シリアル通信で使う

プリンタメーカーによってはシリアル通信の仕様書が公開されていますので、SDKが無い場合やSDKを使いたく無い場合には、直接シリアル通信してプリントすることになります。例えば、スター精密のプリント仕様書はこのようになります: StarPRNT 仕様書 。通信仕様を把握しないといけないため大変ですが、Python, Ruby, NodeJS, PHP など比較的モダンな言語での開発はドライバは用意されていないと思いますので、実質シリアル通信コードを書くしか方法がありません。Bluetooth 対応のレシートプリンタは、Bluetoothでペアリングを確立した後、その TTYに向けてシリアル通信を行います。

実際に書いてみると、レシートへの出力パターンはそれほど多く無く、軽量言語からシリアル通信をするのはそれほど複雑コードにはなりません。つまりどころとしては、1行の中に左寄せと右寄せの文字を同時に出したり、表を出力する時に困るぐらいでしょうか。

購入レシートなので表っぽい形式での出力は必須となるため、ここが技術的にクリアできれば問題無いといえます。

それ以外

プリンタによっては、HTTPサーバが内蔵されており、そのHTTPサーバに向かって印刷内容をリクエストすると印刷できる機能を持ったものがあります。システムを ブラウザだけで完結させるこができ、使い勝手は良いのですが、通信インフラの設置制限などを受けます。

スター精密の機種は、WebPRNT というブランド名でこの機能が搭載されています。使いたかったのですが、当社にある「mC-Print3」は無線Lanインターフェイスがなく有線LANのみ搭載で、設置環境は有線LANのケーブリングは無理だったため今回は採用できませんでした。

また、プリンタによってはクラウド経由のプリントをサポートしているものもあるようです。

バーコードリーダーとの接続方法の選択

バーコードリーダーの接続方法は、大きく分けて2つあります。

「キーボード」として認識させるもの

大抵は、バーコードリーダは「キーボード」として扱います。

バーコードを読み取ると、そのコードがキーボードの打鍵としてOSに入ってきます。これはソフトを選ばず使えるため、使い勝手は良く、大抵この方法で問題ありません。

シリアルポート接続

バーコードリーダーによっては、シリアルポートとして接続させることもできます。Bluetoothで接続でき、市販のモバイルのレジアプリとの対応を謳っている製品にはこの機能が入っていると思います。キー入力に影響が無いためアプリの自由度は高まりますが、入力を受け付けるサービスを別途用意しなければいけないので、開発が少し手間です。

クライアントシステム構成の例

当社で運用している(検討している)具体的なハードウェア構成パターンを2つ書きます。

構成1. Windows + Firefox + OSのプリントキューでレシート印刷

当社で実際の販売点で使っている構成です。

Webブラウザを使ったレジアプリの場合、ハードウェアを Windows にすると、WIndows 特有の恩寵も多く受けられる非常に低コストで素早い開発が行えます。

利点

利点としてはこのような点があげられます。

  • 機能リリース(アップデート)が簡単。ブラウザをリロードするだけで良い。
  • プリンタドライバが豊富。間違い無く用意されている。
  • レシートを HTMLでレイアウトができるため、表現力が豊富で開発も早い。

特に、アップデートが早くて簡単なのは非常に高い優位があります。

フレームワーク

Webサイトのフロントエンドフレームワークとして、Vue, React, Angular 等のモダンなフレームワークを使うことで、レスポンスの良く体験に優れた POS アプリが簡単に作れます。CSSフレームワークも、Tailwind や Bootstrap等を用いることで美しいものが手軽に作れます。

弊社の鉄板構成は、Nuxt + TypeScript + Bootstrap です。

タッチ前提のUIにすると良い

開発中は、トラックパッドやマウスなどのポインティングデバイスでの利用を考えてしまいますが、実際のレジ現場ではタッチパネルを使ったほうが断然使いやすいので、タッチを前提としたUIにした方が良いです。ハードウェアも、Windowsタブレットを採用するのが良いでしょう。

Firefoxに限定している理由

ブラウザを Firefox に限定しているのは、Chrome の場合は最少フォント制限がありレシート印刷が難しいのと、Firefox はプリント時の余白設定も柔軟に行えるためです。

Firefoxでのプリント設定例

レシートプリント時、以下の設定を行うことで、専用システムのレシート出力に比べて遜色ないプリントができます。

  1. 余白を「なし」に設定する
  2. 倍率を「100%」に固定する
  3. ヘッダー・フッターはプリントしない
  4. プリントプレビューを表示しなくする

1〜3は、メニュー内の「プリント」をクリックすることで設定できます。

倍率を100% とし、横幅の制限は HTML上で作ります。例えば80mmプリンタを使うのであれば、レシートの内容は width: 70mm 程度にしたdiv の中に組んでいき、それをプリントさせるということです。

Firefoxでプリントプレビューを表示しない

Firefoxで プリントプレビューを表示しないようにするには、

about:config を開いて、print.always_print_silent を検索し、false となっているのを true にします。

ちなみにこの設定は、プラグインで簡単に設定できないかと思ったのですが、プラグインから about:config を操作するのは無理みたいで、実現できるものは存在しませんでした。

USB電源管理を無効化する

プリンタは USBで接続することになると思いますが、Windowsは使用方法によっては USBの仮想プリンタポート番号がいつの間にか変わってしまう場合があります。それを防ぐため、下記の記事を参考に、USBでの電源管理を無効化しておくとトラブルを防げます。

Windows10 (1803) USBの仮想プリンターポートを固定化する

デバイスマネージャの「ユニバーサル シリアル バス コントローラー」の中のUSB Root HubもしくはUSB ルート ハブ(USB 3.0)のプロパティを開き 「電源の管理」タブの「電力の節約のために、コンピューターでこのデバイスの電源をオフにできるようにする(A)」のチェックを外します。 

バーコードリーダ対応ライブラリ

Vue.js でキーボードとして認識されたバーコードリーダーからのバーコード入力を読み取る用途に

vue-barcode-scanner というライブラリが開発されており、これを使うと何の問題も無くバーコードリーダーとの連携ができます。

お客様ディスプレイ

レジスタッフだけではなく、お客様にも金額を確認してもらうため、お客様用のディスプレイの用意が必要になります。

ディスプレイ自体は、1万円弱でラズパイ用のディスプレイが多く出回っているのでそれを購入し、ブラウザから window.open() 等でお客様ディスプレイ用のウインドウを出します。

レジで打っている内容を、お客様ディスプレイに反映させる方法としては、ローカルストレージを使うのが一番手軽で良いと思います。

レジでバーコードをスキャンした時、その商品名や金額、合計金額をブラウザのローカルストレージに入れる。

お客様用ディスプレイのブラウザウィンドウで稼働しているJSコードは、ローカルストレージを500msとかでポーリングして監視し、変更があったらブラウザウィンドウに描画する。

プッシュ通知や PubSub, ウェブソケット等使うより素朴ですが、ローカルストレージをポーリングする方法は構成要素も少なく、安定して稼働できると考えています。

構成2. Android + Flutter + 専用プリンタドライバ

現在、研究開発段階でまだ実用化はできていません。

Sunmi というメーカーの POS 用品が美しく、今後は使っていきたいと思っているため、Flutter でアプリ開発を初めています。

レシート出力については、flutter_sunmi_printer というライブラリが pub で公開されており、これを使うと sunmi の内蔵プリンタへの出力が容易に行えます。

テーブル出力についても、このライブラリは 「Bootstrap のように 1行を12カラムに分けてレイアウトする」という方式を取っており、比較的容易に表組みを作れました。

実は、Windows POSシステムを作ると、開発は楽なのですがセットアップの工程が多かったりトラブルも多く、あまり安定稼働に向かないと考えています。その点、Android 主体のシステムは、アプリさえしっかり書いておけば安定動作はさせやすい(機材トラブルが少ない) と考えています。

Django の ORマッパーで生成されたSQLを実行前に置換し、JOIN時のインデックスを強制する

Django には、便利な ORマッパーが搭載されており、SQLを一切書かずともRDBの操作が行えます。

今回、SQL実行時に思ったようにインデックスが使用されず、パフォーマンスが出ない問題がありました。

これは、生SQLを実行した場合も同様にインデックスが使われなかったため、Django の問題ではなく、実行計画が最適に作成されなかったというSQL上の問題(というか仕様?)です。

SQL内にインデックスヒントを強引に書き込むことでインデックスを強制した所、正常なパフォーマンスとすることができたので、その方法を書きます。

MySQLの強制インデックス

今回、SQLを発行したページは Django の Admin 内で、子モデルの Admin ページから、親モデルを select_related して、リスト表示するというコードとなっていました。

@admin.register(models.Child)
class ChildAdmin(admin.ModelAdmin):
    ...
list_select_related = (
    'parent',
)

DB の種類は MySQL です。発行される SQL は以下のようになります。

SELECT *
FROM child
INNER JOIN parent
ON child.title_id = parent.title_id
ORDER BY child.id
LIMIT 100

※ 実際には SELECT 句の中は各フィールドが明示的に書き出されます

実際にこのSQLをSQLコンソールから実行してみると、50秒ほどかかりました。

(child は約100万レコード、parent は約40万レコードほどの分量です。)

予想より多くの時間がかかっており、テーブルフルスキャンが発生している印象です。

EXPLAIN してみると、possible_keys に title_id というキーを認識しているものの、実際にはキーが使用されると判断されない状況でした。

EXPLAIN 結果

※ 実際のテーブル名は child, parent ではないため、画像を加工しています。

ためしに、認識して使用されていないキー title_id の使用を強制し、SQLを発行してみます。

SELECT *
FROM child
  INNER JOIN parent
  FORCE INDEX (title_id)
  ON child.title_id = parent.title_id
ORDER BY child.id
LIMIT 100

FORCE INDEX (title_id) を追加しています。

すると、50ミリ秒 ほどでSQLを完了することができました。今回はインデックスが活用されています。

EXPLAIN で見ても、key が使われています。

解決へのアプローチ

インデックスが使われない問題は MySQL のサーバ側にあります。インデックスの再構築などを行うことで改善する可能性はあります。

ただし、今回は MySQL サーバへの変更などは行わず、コード上の変更により、 インデックス強制の SQL をなんとか発行する方針としました。

Django の SQL 生成のフック

Django は、搭載されている OR マッパーで SQL を組み上げます。その中には、テーブルジョインする際にインデックスを指定するオプションはありません

そこで、生成されたSQL を発行前にフックし、何らかの処理(置換)を行うことで、インデックス強制をさせることを考えました。

Python は、変数のほかクラス・関数などあらゆるものがファーストクラスオブジェクトであり、使用時に上書きや代入することができます。

特に、コア機能のメソッドを外部から上書きすることで動作を変更させる手法はパッチ、もしくはモンキーパッチと言われ、フレームワークやライブラリの主要機能を一時的に書き換えることも容易に行えます。

今回は、Djangoで生成されるSQL部分をパッチすることで、SQLの置換が行えそうです。

動作の安定性を失ったり(特にスレッド安全性など)、セキュリティ上の問題も生みやすく、動作を追うことが困難になるため、安直に行うべきものではありませんが、課題の解決やビジネス成長につながるのであれば選択肢として考えることはできます。Pythonは、速度など多くの犠牲のもと、柔軟な拡張性を獲得していると言えます。

SQL生成の箇所

Django のORマッパーを追っていくと、

  1. まず Model.objectsQuerySet を生成する
  2. QuerySetQuery を作成する
  3. Query が、 SQLCompier (django.db.models.sql.compiler.SQLCompiler) を作り、 as_sql メソッドを使ってSQL を組み上げる

という動作をします。

Model > QuerySet > Query > SQLCompiler

的な感じです。(記号はイメージです。has a 関係のようなものを表しているつもりですが、厳密ではありません)

SQLCompiler.as_sql を読んでみると、200行ほどからなるそこそこ分量のあるメソッドで、その中には JOIN 時にインデックス強制をできそうなフックポイントなどは無いため、簡単に処理は追加できません。

今回は、この as_sql を一旦どこかに退避し、新しくSQL置換機能をもった as_sql を勝手に作って SQLCompler の as_sql として置き換え、その勝手に作った as_sql の中で、もともとの as_sql を呼ぶことで、機能の拡張を行うものとします。

パッチのコード

具体的なコードとしては

def patch_sql_transformer():
    """
    queryset.query に sql_transformer というメソッドを付与すると、
    生成済み SQL をその sql_transformer で処理(置換など)して返す
    """
    from django.db.models.sql.compiler import SQLCompiler
    o_as_sql = SQLCompiler.as_sql

    def _decorate_as_sql(self, *args, **kwargs):
        sql, params = o_as_sql(self, *args, **kwargs)
        transformer = getattr(self.query, 'sql_transformer', None)
        if transformer:
            sql = transformer(sql)
        return sql, params

    SQLCompiler.as_sql = _decorate_as_sql

このようなコードとしました。このコードを、アプリの起動時のどこかで1回だけ実行することで、as_sql の機能が書き換わります。

機能としては、クエリセット内の Query のインスタンスに、 sql_transformer という関数がついていれば(つけていれば)、生成された SQL をその関数を通してから実行します。

今回、sql_transformer として実行したい関数は下記のものです。

def _transform_sql(sql):
    return sql.replace(
    'INNER JOIN `parent` ON',
        'INNER JOIN `parent` FORCE INDEX (title_id) ON')

近距離パワー型のアプローチですが、INNER JOIN 句を文字列置換して、強制的にインデックスを使わせます。

今回は Admin ページ内だけでこれを適用したかったため、このメソッドを ModelAdmin の get_queryset 内で作り、Query に仕込みます。

@admin.register(models.Child)
class ChildAdmin(admin.ModelAdmin):
   ...
    def get_queryset(self, request):
        qs = super().get_queryset(request)

        def _transform_sql(sql):
            return sql.replace(
                'INNER JOIN `parent` ON',
                'INNER JOIN `parent` FORCE INDEX (title_id) ON')
        qs.query.sql_transformer = _transform_sql
        return qs
 
    list_select_related = (
        'parent',
    )
...

これにより、インデックスが強制指定で使われるようになり、速度を改善させることができました。

なぜ Query にメソッドを作るのか?

SQL の生成時は

Model > QuerySet > Query > SQLCompiler

とモデルが関係していますが、今回、なぜ半端な位置の Querysql_transformer を生やす、というデザインにしたかというと、それ以外に適した場所がなかったからです。

クエリセットを生成中の、QuerySet や、 SQLCompiler は、クエリセットをチェーンする時

例えば

qs = MyModel.objects.filter(active=True)
qs = qs.filter(item_type=xxx)
qs = qs.order_by('-id')
qs = qs.limit(100)

のようなコードの場合 (実際、Django の ModelAdmin の中では上記相当のコードが発行されています)、毎回 queryset インスタンスが作り直されます。

なので、queryset インスタンスになにかフック用の関数を付与したとしても、SQLCompiler の SQL 構築まではインスタンスが無くなり、メソッドも失われてしまいます。同様に、SQLCompiler になにか関数を作っても、クエリセットが作り直される時に消えます。

では、その中間にある Query はというと、QuerySet のコンストラクト引数として常に引き渡され、場合によっては deepcopy され、インスタンスのプロパティや変数などは大元の QuerySet がすべて消えるまで生存しており、かつ SQLCompiler の第一引数でもあるためアクセスが容易ですので、今回は Query のインスタンスを用いて関数の引き回しをすることにしました。

サーバレスアプリケーション (HTML/JSのみ) で、Google Analytics API を使ってアクティブユーザー数を表示するダッシュボードを作る

Google Analytics は、API経由で様々な数値を取得することができます。
今回は、API経由でサイトのアクティブユーザー数を取得して、Nuxtで作ったダッシュボード風JSアプリに表示してみます。

Google Analytics の APIから値を表示してウェブページに表示する場合、APIとの通信をサーバサイドで PHP や Python のプログラムで取得してウェブブラウザに表示時する方法と、ウェブブラウザ自体が直接 Google Analytics API にリクエストして表示する方法と、どちらの方法でも実現できます。

サーバサイドで行う場合、認証情報やシステムを隠蔽でき、またHTTPリクエストが無くともバックグラウンドで値を取得し続けることができるなど、大きな利点がありますが、常時起動するサーバを用意しておかないといけないため構築が若干手間です。

今回は、手間をかけずに実現したかったため、ブラウザと静的リソースのみで動作するものを作ります。
APIへの認証は、ブラウザにログインしている Google ユーザーが行います。

認証のしくみ

Googleの提供している APIは、OAuth2 の認証・認可が必要となります。

アプリのページを開くと、Google のログインページを表示して認証を求め、認証されるとダッシュボードアプリの指定したコールバックURL にリダイレクトされるようにします。

リダイレクトされると、コールバックURL末尾の # (ハッシュ, フラグメント) の後にアクセストークンが含まれた状態になりますので、それを nuxt 内の JSでパースし、ブラウザのメモリ(変数)に格納します。

注意点として、認証情報(アクセストークン)がブラウザの変数に格納されるため、XSSが発生した際にアクセストークンが漏洩するリスクがあります。これはJSで認証情報を扱う以上避けられません。

XSSによる認証情報漏洩を防ぐには、GoogleとのAPIリクエストをサーバサイドで行い、サーバとの認証は httpOnly属性のついたセッションクッキーで行うべきですが、今回は、静的リソースなのでXSSは通常発生しないという前提の元、リスクを受容した上でクライアントサイドJSで機能を提供するものとします。

コールバックURLから取得したアクセストークンを認証ヘッダに含めて、ブラウザから Google Analytics にAPIリクエストをすることで、アクティブユーザー数などを取得できます。

アクセストークンは、localStorage に保存すると、セッション終了後も保持されるため漏洩の危険性が上がります。そのため変数として保存するだけにとどめます。ただし、OAuth2の state パラメータは、ブラウザセッションが変更されても維持する必要があるため、localStorage に一時的に保存します。

アプリのページを開くたびに、OAuth2のアクセストークンをリクエストし続けるとすると、毎回 Google のアカウント選択ページが出て面倒なように感じられるかもしれませんが、実際に使ってみるとそんなことはなく、一度ブラウザと Google の認証セッションクッキーが作られればあとはリダイレクトしか発生せず、1秒程度で自動的に認証が終わりアクセストークンが取得済みの状態になりますので、使用感として悪くはありません。

 試しにAPIにリクエストする

https://developers.google.com/analytics/devguides/reporting/realtime/v3/reference/data/realtime/get?#try-it

こちらの Google アナリティクスのページを見ると、APIの説明と共に、中央もしくは右側にAPIのテストリクエストができるフォームが表示されます。

ids に、 ga: を先頭につけた GoogleAnalytics のID番号を書き、metrics は rt:activeUsers を入れ、

下部 「Google OAuth 2.0」と「API key」にチェックを入れ、「Execute」 ボタンを押します。

すると、さらに下部にレスポンスデータが表示されます。
この情報を、nuxt 等ウェブアプリケーションで取得できます。

作り方

Google Cloud Platform のプロジェクトを作る

まず、Google APIを使うため、GCPのプロジェクトを作ります。

GCPのダッシュボードの上部ヘッダから、プロジェクトを新しく作ります。
https://console.cloud.google.com/home/dashboard

ライブラリの追加

プロジェクトを作ったら、APIとサービスページを開きます。
https://console.cloud.google.com/apis/dashboard

左メニューの「ライブラリ」をクリックし、ライブラリの追加ページで、Google Analytics API と Google Analytics Reporting API を有効にします。

認証情報の追加

次は、認証情報ページを開きます。
https://console.cloud.google.com/apis/credentials

上部「認証情報を作成」リンクから、「API キー」を選択し、APIキーを一つ作ります。

続いて、「認証情報を作成」リンクから、「OAuthクライアント ID」を選択します。

アプリケーションの種類 は、今回は「ウェブアプリケーション」で、名前は、適当に「nuxt client」とかにします。

「承認済みの JavaScript 生成元」は、クロスオリジンリクエストを許可するオリジン名です。
平文 http でもOKなので、

http://localhost:3000

などを追加しておきます。

「承認済みのリダイレクト URI」は、OAuth2 の認証フローのリダイレクトURL(コールバックURL)です。
認証完了時、このURLに、アクセストークンが URLに含まれた形でリダイレクトされます。
今回は、

http://localhost:3000/code

としました。
本番用のURLが決まっていたら、その分も追加します。後からでも追加できます。

スクリーンショットの例は、ローカルPCで docker + Nginx でポート80 でサービスする場合を考慮し、
http://localhost も追加してあります。

ちなみに、クライアントID とクライアントシークレット が作成されますが、ブラウザからのリクエストの場合はクライアントシークレットは使いません。

OAuth 同意画面は、今回は社内用途のみ考慮しているので「内部」を選択し、作成します。

プロジェクトの状態によっては、この選択肢は無いかもしれません。

これで、GCP のプロジェクトの設定は終了です。

アプリを書く

今回は、nuxt + TypeScript で書きます。すべてを載せると冗長なので、要点のみ記載します。

流れとしては、

1.
まずブラウザで / ページ表示時。
最初はアクセストークンが無いため、
store/auth.ts の navigateTokenRequestUrl がコールされる。

2.
navigateTokenRequestUrl の中では、ステートパラメータを作ってローカルストレージに保存し、
Google の認証URLに遷移。

3.
認証が完了すると、リダイレクトURL である /code に着地します。

4.
/code では、URLのハッシュ(フラグメント) から、アクセストークンとステートを取得し、
ステートの一致を検証後、アクセストークンを保存し、/ に遷移します。

5.
/ では、今回はアクセストークンがあるため、
コンポーネント内でそのアクセストークンを使って Google API でリアルタイムユーザー数を取得して表示します。

6.
このアクセストークンの寿命は1時間のため、1時間するとGoogle API は http 401 を返すようになります。
その場合、もう一度アクセストークン取得URLに遷移することで、アクセストークンの取り直しをします。

この時、Google とのセッションクッキーが認証済みであれば、ユーザー操作は不要で、一瞬画面がちらつくだけでアクセストークンの更新が完了します。

設定ファイル settings.ts

export const GOOGLE_API = {
authAPIEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
analyticsAPIEndpoint: 'https://www.googleapis.com/analytics/v3/data/realtime',
clientId: '<GoogleAPIの認証情報で作成されてたクライアントID>.apps.googleusercontent.com',
apyKey: 'AIz<GoogleAPIの認証情報で作成されてたAPIキー>o04',
scope: 'https://www.googleapis.com/auth/analytics.readonly'
}

export const GOOGLE_ANALYTICS_ACCOUNTS = [
{
id: '157xxxxx',
title: 'サイト1'
},
{
id: '872xxxxx',
title: 'サイト2'
},
]

型ファイル types/tasks.d.ts

interface TaskResult {
success: boolean;
message: string;
}

Vuex Store モジュール store/auth.ts

import { Action, Module, Mutation, VuexModule } from 'vuex-module-decorators'
import { GOOGLE_API } from '~/settings'

// OAuth2 state パラメータの localStorage一時保存用
const LOCAL_STORAGE_AUTH_STATE_KEY = 'authState'

@Module({
name: 'auth',
stateFactory: true,
namespaced: true
})
export default class extends VuexModule {
accessToken: string = ''

/**
* 認証済みか?
*/
get authorized () {
return this.accessToken !== ''
}

/**
* アクセストークンを保存
*/
@Mutation
setAccessToken (token: string) {
this.accessToken = token
}

/**
* OAuth2 認証URLに移動する
*/
@Action
navigateTokenRequestUrl () {
// 乱数で state パラメータを作成
const authState = [...Array(30)].map(
() => Math.random().toString(36)[2]).join('')
// state パラメータをローカルストレージに保存する
window.localStorage.setItem(LOCAL_STORAGE_AUTH_STATE_KEY, authState)
const params = [
['scope', GOOGLE_API.scope],
['include_granted_scopes', 'true'],
['response_type', 'token'],
['state', authState],
['redirect_uri', `${window.location.origin}/code`],
['client_id', GOOGLE_API.clientId]
]

window.location.href = `${GOOGLE_API.authAPIEndpoint}?` +
params.map(i => `${i[0]}=${encodeURIComponent(i[1])}`).join('&')
}

/**
* OAuth2認証完了後のURLのハッシュ(フラグメント)を解析して、アクセストークンを保存
*/
@Action
parseResponseParamsString (paramsString: string) : TaskResult {
// #key=value&key2=value2 形式の文字列を URLSearchParams にする
const usp = new URLSearchParams(
paramsString.replace(/^#/, '')) as any
// URLSearchParams を辞書型(マップ型)変数に変換
const paramsDict = [...usp.entries()].reduce(
(dict, e) => ({ ...dict, [e[0]]: e[1] }), {})
// localStorage に保存されている state と一致しているか検証
if (paramsDict.state !== window.localStorage.getItem(
LOCAL_STORAGE_AUTH_STATE_KEY)) {
return {
success: false,
message: 'stateが一致していません'
}
}
// state はもう使わないので消す
window.localStorage.removeItem(LOCAL_STORAGE_AUTH_STATE_KEY)
// アクセストークンがあるか検証
if (!paramsDict.access_token) {
return {
success: false,
message: 'アクセストークンが取得できません'
}
}
// アクセストークンがあったので変数に保存する。認証成功。
this.setAccessToken((paramsDict.access_token))
return {
success: true,
message: ''
}
}
}

pages/index.vue

ユーザーが最初に表示するページ

<template>
<div>
<header class="d-flex text-white p-2">
<div class="flex-grow-1 py-1">
Developer Dashboard
</div>
</header>
<div v-if="authorized">
<div class="container-fluid">
<div class="row">
<div
v-for="account in googleAnalyticsAccounts"
:key="account.id"
class="col-6 col-md-4 col-xl-3 my-3"
>
<RealtimePanel
:analytics-id="account.id"
:title="account.title"
/>
</div>
</div>
</div>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import { authStore } from '~/store'
import RealtimePanel from '~/components/RealtimePanel.vue'
import { GOOGLE_ANALYTICS_ACCOUNTS } from '~/settings'
@Component({
components: {
RealtimePanel
}
})
export default class Index extends Vue {
get accessToken () {
return authStore.accessToken
}

get authorized () {
return authStore.authorized
}

get googleAnalyticsAccounts () {
return GOOGLE_ANALYTICS_ACCOUNTS
}

requestToken () {
authStore.navigateTokenRequestUrl()
}

mounted () {
// 認証済みでなければトークン取得URLへ遷移
if (!authStore.authorized) {
this.requestToken()
}
}
}
</script>

pages/code/index.vue

OAuth API 認証後のリダイレクトURL(コールバックURL)

<!--
OAuth2 認証後にリダイレクトされるURL
URLに含まれるハッシュ(フラグメント)から、アクセストークンを取得して変数に格納する。
-->
<template>
<div>
code received
</div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'
import { authStore } from '~/store'
@Component({
components: {
}
})
export default class Index extends Vue {
getRequestToken () {
authStore.navigateTokenRequestUrl()
}

async mounted () {
const taskResult = await authStore.parseResponseParamsString(
window.location.hash.replace(/^#/, ''))
if (!taskResult.success) {
throw new Error(taskResult.message)
}
this.$router.push('/')
}
}
</script>

components/RealtimePanel.vue

<!--
GAのアカウント1つに対応
一定時間ごとに、リアルタイムユーザー数を更新し続けるコンポーネント
-->
<template>
<div class="card">
<div class="card-header h2 py-3 text-truncate">
{{ title }}
</div>
<div class="card-body">
<div v-if="errorMessage" class="my-4">
{{ errorMessage }}
</div>
<div v-if="responseSuccess" class="text-center my-4">
<div class="display-1 fw-bold">
{{ activeUsers|addComma }}
</div>
<div class="text-muted small">
Active User
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'
import { authStore } from '~/store'
import { GOOGLE_API } from '~/settings'

@Component({
components: {
}
})
export default class extends Vue {
activeUsers: number | null = 0
responseSuccess: boolean = false
errorMessage: string = ''

@Prop({
type: String,
required: true
})
title!: string

@Prop({
type: String,
required: true
})
analyticsId!: string

mounted () {
this.reloadPolling()
}

reloadPolling () {
this.reload()
setTimeout(() => {
this.reloadPolling()
}, 20000)
}

async reload () {
const response = await this.$axios.get(
GOOGLE_API.analyticsAPIEndpoint, {
params: {
key: GOOGLE_API.apyKey,
ids: `ga:${this.analyticsId}`,
metrics: 'rt:activeUsers'
},
headers: {
Accept: 'application/json',
Authorization: `Bearer ${authStore.accessToken}`
},
validateStatus: _ => true
}
)
// トークン期限切れ
if (response.status === 401) {
authStore.navigateTokenRequestUrl()
return
}
// 403: アクセス過多など
if (response.status !== 200) {
this.responseSuccess = false
this.errorMessage = `ERROR: ${response.status}`
return
}
if (response.data) {
this.activeUsers = parseInt(response.data.totalsForAllResults['rt:activeUsers'])
this.responseSuccess = true
this.errorMessage = ''
}
}
}
</script>

Ubuntu server 20.04 に Rancher で Kubernetes (シングルノードk3s) + Ingress 環境を構築し、ウェブアプリをホストする

現在の最新版のRancher 2.5.2 を使った所、1年前のものと違った点で環境構築に躓いた所があったので書きます。

Rancher 添付の K3S のシングルノードクラスタで、Ingress で外部リクエストにレスポンスする所まで行います。

1. Docker エンジンのインストール

curl 'https://releases.rancher.com/install-docker/19.03.13.sh' | bash
sudo usermod -aG docker <yourname>

Ubuntu Server 20.04 のインストーラは、途中でチェックボックスでDocker をインストールできるうようになっていますが、このインストーラ途中での Docker のインストールはしないでください

Ubuntuのインストーラでインストールする Docker は、snap のものです。この snap版 Docker は、ストレージのマウントができないため、今回の Rancher の中で起動するシングルノードクラスタの中でホストのストレージをマウントさせることができません。(できそうな気がしますがわかりません)

なので、snap 版の Docker は使わず、Rancher が提供しているインストールスクリプトで Docker をインストールします。

github の https://github.com/rancher/install-docker を見ると、バージョンごとのdocker インストールスクリプトがありますので、それを使います。README にある通り、これらのスクリプトは releases.rancher.com でホストされてますので、bash で実行します。

curl 'https://releases.rancher.com/install-docker/19.03.13.sh' | bash

インストール完了時、ユーザーを docker グループに追加させるコマンドが表示されるので、実行しておきます。

sudo usermod -aG docker <yourname>

2. Rancher の実行

docker run -d --restart=unless-stopped -p 80:30080 -p 443:30443 -p 32080:80 -p 32443:443 -v /data/rancher:/var/lib/rancher -v /data:/data --privileged rancher/rancher

Rancher は、Docker コンテナとして実行するだけなので、明示的なインストールは不要です。

まず参考にするドキュメントは、Github の README が良いでしょう。 https://github.com/rancher/rancher

ここに書いてある起動スクリプト

sudo docker run -d --restart=unless-stopped -p 80:80 -p 443:443 --privileged rancher/rancher

これは、ただ起動する分には問題ありませんが、Pod の中でホストボリュームをマウントしたり、Ingress でホストリクエストを受けたりする場合に不足を感じます。

ポイントとして、k3s のシングルノードクラスタも、このコンテナ内で作られるため、Rancher起動時にホストボリュームのマウントやポート転送をしておく必要があります。

rancher イメージで指定しているポート 80 および 443 は、Rancherの Web UI および Kuberntes の API に使われるポートです。このポートを他のホストポートに逃し、80 と 443 は Ingress で受けれるようにします。

-p 32080:80 -p 32443:443 -p 80:30080 -p 443:30443

ホストの 32080と32443 を Rancherの 80, 443 へ、ホストの 80,443 を Ingress の NodePort 32080, 32443 へ転送する設定です。

また、上記のまま起動すると、コンテナ削除時に Rancher (Kubernetes) の設定が消えてしまいますので、永続化させるためにホストのボリュームをマウントします。

ホストの /data ディレクトリをすべて、Kubernetes 用とします。/data/rancher ディレクトリを作り、 -v /data/rancher:/var/lib/rancher でマウント。

さらに、ホストの /data/ をその他の Podでも使う想定のため、 -v /data:/data で Rancher コンテナにマウントしておきます。

コマンド全体としてはこのようになります。

docker run -d --restart=unless-stopped -p 80:30080 -p 443:30443 -p 32080:80 -p 32443:443 -v /data/rancher:/var/lib/rancher -v /data:/data --privileged rancher/rancher

3. kubeconfig の取得

起動できたら、ウェブブラウザで rancherのホストの 32443 ポートへリクエストします。

https://<your-rancher-host>:32443/

Adminパスワードを設定し、Webコンソールのデフォルトビューがどっちがいいか聞かれるのでどちらかを答えます。どっちでも良いですが、シングルノードの場合は Cluster Exproler のほうが見やすいと思うので右側を選択します。なお、どちらのビューでも、クリックするだけでもう一方のビューに切り替えができます。

起動したら、一旦 Cluster Manager に移動し、

Cluster を選択します。そうすると、右上に Kubeconfig file とありますので、そこをクリックします。

クリックすると Kubeconfig ファイルが表示されるので、それをコピペして、mac に .kube/config-my-cluster みたいな名前で 保存します。ファイル名の部分は適当に変更してください。

ちなみに、kubectl はすべて mac から操作すると思いますので、 Rancher 起動ホストにこの kubeconfig を保存する必要は無いと思います。

4. mac から kubectl を実行 (試しにネームスペースを作る)

kubectl が無い場合は、brew でインストールしておきます。

#!/usr/bin/env zsh
export KUBECONFIG=${HOME}/.kube/config-my-cluster
kubectl create namespace <my-namespace-name>

これを実行し、 created が表示されればOKです。 my-namespace-name は適当に決めてください。今後も出てきます。(自分の github や twitter のアカウント名で良いと思います。)

5. Ingress Controller の作成

イングレスコントローラを起動します。

普通に起動するだけなら https://github.com/kubernetes/ingress-nginx ここにあるマニフェスト

https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.41.2/deploy/static/provider/baremetal/deploy.yaml

これを、kubectl で実行するだけなのですが、これだと 80, 443 でリッスンするため Rancher コンテナ内のシングルノードクラスタでは応答できません。なので、NodePort を開けます。(もっと良い方法あるかも。ベストプラクティスがわかりませんでした)

上記 yaml マニフェストファイルをダウンロードして、270 行目あたりにある ingress-nginx-controller

サービスの項目の ports nodePort  に、30080 と 30443 を追加します。

---
# Source: ingress-nginx/templates/controller-service.yaml
apiVersion: v1
kind: Service
metadata:
annotations:
labels:
helm.sh/chart: ingress-nginx-3.10.1
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx
app.kubernetes.io/version: 0.41.2
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/component: controller
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
type: NodePort
ports:
- name: http
port: 80
protocol: TCP
targetPort: http
nodePort: 30080 # 追加
- name: https
port: 443
protocol: TCP
targetPort: https
nodePort: 30443 # 追加
selector:

そして、このマニフェストを mac から apply します。

#!/usr/bin/env zsh
export KUBECONFIG=${HOME}/.kube/config-my-cluster
kubectl apply -f ingress.yml

これで、80,443 ポートをレスポンスする準備ができました。

6. SSL(TLS)証明書があれば入れとく

#!/usr/bin/env zsh
export KUBECONFIG=${HOME}/.kube/config-my-cluster
kubectl -n <my-namespace-name> create secret tls cert-wildcard-mydomain \
  --key wildcard.mydomain.key \
  --cert wildcard.mydomain.crt

SSL 証明書の key ファイルと crt ファイルを、tls secret 「cert-wildcard-mydomain 」として 登録します。

この証明書を Ingress から使えるようになります。無ければ不要です。
(例では wildcard と書いてありますが特にワイルドカード証明書でなければいけないわけではありません)

7. 適当にサービスを作る

alpine nginx で、適当にサービスしてみます。

deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: <my-namespace-name>
name: test-alpine-deployment
spec:
selector:
matchLabels:
app: test-alpine

strategy:
type: Recreate
template:
metadata:
labels:
app: test-alpine
spec:
containers:
- image: nginx:stable-alpine
name: test-alpine
volumeMounts:
- name: test-volume
mountPath: /data/test
volumes:
- name: test-volume
hostPath:
path: /data/test  

検証用に /data/test ディレクトリをマウントしています。予め、ホスト上に /data/test ディレクトリを作っておいてください。

service.yaml
apiVersion: v1
kind: Service
metadata:
name: test-alpine-service
namespace: <my-namespace-name>
spec:
ports:
- port: 80
protocol: TCP
targetPort: 80
name: test-alpine-http
selector:
app: test-alpine
ingress.yaml
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: test-alpine-ingress
namespace: <my-namespace-name>
spec:
# 先程 SSL(TLS) 証明書をインポートした場合はここで指定
#tls:
# - hosts:
# - myhost.example.com
# secretName: cert-wildcard-mydomain
rules:
- host: myhost.example.com
http:
paths:
- path: /
backend:
serviceName: test-alpine-service
servicePort: 80

applyします

#!/usr/bin/env zsh
export KUBECONFIG=${HOME}/.kube/config-my-cluster
kubectl apply -f deployment.yaml
kubectl apply -f service.yml
kubectl apply -f ingress.yml

検証

httpサービスを検証

Rancherホストに対して、httpリクエストすると Nginxのデフォルトページが見れます。

ホストボリュームのマウントを検証

Pod にログインします。

#!/usr/bin/env zsh
export KUBECONFIG=${HOME}/.kube/config-my-cluster
podname=$(kubectl -n <my-namespace-name> get pod -l app=test-alpine -o jsonpath="{.items[0].metadata.name}")
kubectl -n <my-namespace-name> exec -it ${podname} -- /bin/ash

ログインしたら、/data/test に移動し、ファイルを作るなどしてホストの /data/test ボリュームがマウントされていることを確認します。

一定期間より昔のGmailを自動的に削除する方法 (Google Apps Script を使う)

Google Apps Script を使って、一定時間より古い Gmail を自動的に削除するスクリプトを定期動作させる方法を書きます。

ちなみに、Google Drive の容量が不足している場合、Drive にメール以外の容量の大きなファイルがあるかもしれません。Google Drive にある容量の大きなファイルは、 https://drive.google.com/drive/quota こちらから一覧で見れます。

1.

Google Apps Script のページを開く。

https://script.google.com/

もしログインしてなければ、「Start Scripting」のボタンを押してログインします。

2.

左上の「新しいプロジェクト」のボタンを押します。

3.

上の「無題のプロジェクト」をクリックし、「古いGmailを削除」にプロジェクト名を変更します。

下記のスクリプト入力欄(コード.gs)にこのスクリプトを貼り付けます。

function deleteOldGmails() {
// 1年以上前のメールを削除
var deleteThreads = GmailApp.search('older_than:1y -is:starred');
Logger.log('該当スレッド: ' + deleteThreads.length + '件');
for (var i = 0; i < deleteThreads.length; i++) {
deleteThreads[i].moveToTrash();
if (i > 1000) {
Logger.log('1000件削除しました');
break;
}
}
Logger.log('終了');
}

上記スクリプトは、1年以上前に受信したメールでスターのついていないものを、1回で1000件まで削除するスクリプトです。

一度に多くのメールを削除しようとすると、スクリプトの実行がタイムアウトすることがあります。もしスクリプトがタイムアウトするようであれば、この数(1000)は適宜減らす調整をしてください。(500とか)

すべてのメールを削除対象とせずに、特定のラベルのみ対象にする場合は、下記のようなスクリプトとします。

function deleteOldGmails() {
var queries = [
'label:削除したいラベル1',
'label:削除したいラベル2',
'category:updates',
'category:forums',
'category:promotions',
];

for(var i=0; i < queries.length; i++){
var query = queries[i];
Logger.log('削除開始:' + query);
var criteria = '' + query + ' older_than:100d -is:starred'
var deleteThreads = GmailApp.search(criteria);
Logger.log('該当スレッド: ' + deleteThreads.length + '件');
for (var j = 0; j < deleteThreads.length; j++) {
   deleteThreads[j].moveToTrash();
}
Logger.log('' + query + 'の処理を終了')
}
}

こちらの場合は、「削除したいラベル1」「削除したいラベル2」「新着」タブ、「プロモーション」タブ、「フォーラム」タブの、100日以上古い、スターのついていないメールを削除するスクリプトとなります。

4.

スクリプトを貼り付けたら、保存ボタンを押してから実行ボタンを押します。

5.

Authorization required と出たら、「許可を確認」ボタンを押します。

「アカウントを選択してください」と表示されたら、自分のアカウントを選択。

「Googleアカウントへのアクセスをリクエストしています」と出たら、「許可」をクリック。

※ ちなみに、Gsuiteではなくフリーの gmail の場合、ここですぐ「許可」ボタンは押せません。
「詳細」→「メッセージダイアログ(安全ではないページに移動)」→「許可」と押していきます。

6.

スクリプトの実行が始まります。数分で終わります。

7.

実行結果は、「表示」→「ログ」で見れます。

8.

自動実行するために、トリガーを設定します。トリガーボタンを押します。

9.

右下の「トリガーを追加」ボタンを押します。

10.

イベントのソースを選択: 時間主導型

時間ベースのトリガーのタイプを選択: 時間ベースのタイマー

時間の感覚を選択(時間): 1時間おき

のように設定して、「保存」をクリック。

これで、一定時間ごとに Gmail の削除が動作するはずです。

TORICO全社会イベント TORICO NIGHT 2019 でオペレータしました

Qiita の TORICO Advent Calendar 2019 の記事として書きました

TORICO NIGHT

TORICOでは、年2回のペースで全社員任意参加の食事会「TORICO NIGHT」を開催しています。

2017年から開始して、201911月の開催で5回を迎えました。

多くの会社は、12月に「忘年会」という形で食事会をされると思いますが、TORICO12月は商品が非常に多く売れる時期であり、物流チームも事務チームも気が抜けなくなるため、その時期を避けて秋口に食事会をします。

開催場所

TORICOでは、マンガの原画展やトークイベントを行うためのイベントスペースを自社運営しています。TORICO NIGHT を開催する際も自社イベントスペースを使うことが多くあります。

今回の TORICO NIGHT は、自社運営の池袋のイベントスペース「池袋虜」で行うこととしました。

運営スタッフ

TORICO NIGHT の運営スタッフ(幹事)は、毎回くじ引きなどで決めます。イベントの最後に、当会の幹事が決めた方法で次回幹事を決めることが多いです。2019年春のTORICO NIGHT での決め方は「役員によるくじ引き」で、私は自分自身を引き当ててしまったため2019 TORICO NIGHT の幹事となりました。

2019年秋の幹事は、部門がそれぞれ違う5名が選ばれました。その5名で、次のTORICO NIGHT の全てを決め、実施することになります。

実施6週間前

顔合わせ、役割分担決め

実施時期が近づいたので、幹事チームで集まっておおよその役割を決めます。

私は、製作物のデザインや製作、本番のPA、照明のオペレーションを担当することにしました。

その他のメンバーで、ケータリングの手配や参加者への案内、会場押さえなどをします。当日の進行は全員で作ります。

今後、週イチで一時間ほど使って幹事チームで集まることを合意し、Slackのプライベートチャンネルを作ってミーティングは終了しました。

実施5週間前

テーマ決め、ケータリング決め

会のテーマやおおよその進行を決めます。ケータリングは早めに業者を決めて、予約を取ります。

予算としては単価3000円強、ドリンク別 程度からあります。前回のケータリングの感想をメンバーで話し合いながら、良さそうな所を選定します。

今回の会のテーマとして、クイズ主体で行ってみようと決めたので、問題の提出を宿題としてMTGは終了しました。

実施4週間前

進行の詰め

全体のタイムテーブルを詰めます。

クイズ以外のゲームのテストプレイをしながら、全体の時間規模を見積もります。

この段階で必要な製作物が見えてきますし、会場設営の図面も見えてきます。

実施3週間前

必要なものの洗い出し。当日の司会進行の確認など。

私は音響と照明を担当するため、現地会場を観察しながら、機材設備の数や状態を確認します。

今回はクイズのコンテンツとして、社外の脱出イベントで見るような謎解きゲームを少し盛り込みたいと思っていたので、会場を観察してその題材も探します。

会場である池袋虜は、TORICOが運営する前はクラブとして使われていました。ワンマンオペレーションか出来るよう、ステージ上のDJ卓のまわりにPA・照明設備が配置されています。

池袋虜の図面

この形だと外音を作るのも大変ですし、画面や照明を見ながらのオペレーションができませんので、なんとか配置を直したいところです。

マルチケーブルを会場奥まで取り回して、メイン卓を移動させるのも考えましたか、1日の社内イベントのためだけにやるのは手間がかかりすぎるので低優先とし、他の方法を探します。

池袋虜のPA卓「PRESONUS StudioLive 16.0.2 USB」は、USBで接続したPCでリモートコントロールができます。調光卓も、すでにWindows PC の Surfaceでコントロールできるようになっています。そのため、15mUSBケーブルで、コントロール用のPCだけ客席に配置する方法で行くことにしました。

当日は客席の隅に私のオペレーションブースを作り、PC4台で演出をコントロールすることにします。4台の役割としては

Mac1 スライド (keynote)
Mac2 BGM (iTunes, vlc)
Mac3 PAミキサー
Win1 調光卓

となります。

1PCに複数のアプリを使って台数を減らせますが、オペレーターである私が混乱してミスをする可能性もあるため、1PCに1つのアプリでわかりやすくします。

ディスプレイは、今回は全部で3台使いました。HDMI分配器を使って全てのディスプレイに同じものを映します。

実施2週間前

クイズ問題の調整と確定

配点の計算、想定できるエラーのシミュレーション、集計と伝達方法など詰めます。

デザインクリエイティブもほぼ出来上がっているので、試し刷りをして使用感を確認します。

制作物

作ったものとしては、

  • ネームカード用紙
  • 謎解き用問題用紙
  • ドリンクメニュー
  • 会場地図
  • くじ引き用ネームカード

などを作りました。

ネームカード用紙

名刺カードにプリントしたのですが、フチなしプリントはA4に8面配置のクリアカット用紙がきれいに印刷できて使いやすく、よかったです。

四隅に色がついているのは、謎解きゲームで使うためです。

くじ引き用ネームカード

次回幹事決めのためのくじを作ります。これも名刺カードで作りました。くじ引きは電子化(アプリ化)もできますが、アプリにすると運営が操作しているように見える可能性もあるため、印刷物で作ります。

コストと使いやすさ、当選発表の時の見た目のバランスを考えて、名刺カードで刷ることにしました。自動印刷をしやすくするため、A4面付けの用紙ではなく既に名刺サイズになっている用紙を使います。

HTML + jQuery でページを作って印刷しました。

Github

HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="business-card.css" rel="stylesheet">
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"
            integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo="
            crossorigin="anonymous"></script>
</head>
<body>
<script>
const nameSource = `
漫画 全館子
スキマ ネ子
ホーリン ラブ子
まんが 王子
マンガ テンコ
`;
    const names = nameSource.split('\n').filter(i => i);
    $(function () {
        const $body = $('body');
        for (const name of names ){
            const $card = $('<section>').addClass('sheet');
            const $name = $('<div>').addClass('name');
            $name.text(name);
            const $signature = $('<div>').addClass('signature');
            $signature.text('TORICO NIGHT 2019.11');
            $card.append($name);
            $card.append($signature);
            $body.append($card);
        }
    });
</script>
</body>
</html>

SCSS

* {
  box-sizingborder-box;
}
htmlbody {
  margin0;
  padding0;
}
@page {
  size91mm 54.5mm;
  margin0;
}
@media print {
  body {
    width91mm/* needed for Chrome */
  }
}
.sheet {
  width91mm;
  height54.5mm/* 0.5mm余裕をもたせる */
  // page-break-after: always;
  page-break-beforealways;
  positionrelative;
  margin0;
  padding5mm;
  &:nth-of-type(1) {
    page-break-beforeauto;
  }
  .name {
    width100%;
    margin-top15mm;
    height10mm;
    line-height10mm;
    //border: 1px solid black;
    font-size13mm;
    text-aligncenter;
  }
  .signature {
    width100%;
    margin-top10mm;
    height10mm;
    line-height10mm;
    font-size3mm;
    color#777;
    text-aligncenter;
  }
}
/* プレビュー用のスタイル */
@media screen {
  body {
    background#eee;
  }
  .sheet {
    backgroundwhite/* 背景を白く */
    box-shadow0 .5mm 2mm rgba(000.3); /* ドロップシャドウ */
    margin5mm;
  }
}

ドリンクメニュー

てきとうに時間をかけずにささっと。

(ダサい枠線の黒丸はクイズ中の謎と関連させるためです)

実施1週間前

準備は終わっているため、大きな作業は特にありませんでした。
当日の進行やクイズ問題の出題方法の確認を行います。

また、購入物が揃っているかの確認も行っておきます。

ちなみに、Keynote のスライドは、150枚ほどになりました。スライドをしっかり組むほど、当日はリラックスしてオペレーションできます。

当日

準備通りにイベントを進行します。小さなトラブルはいくつかありましたが、リカバリしつつ無事にイベントを終えることができました。

次回運営担当者に向けて

仕事のついでに行うものなので、できる範囲で、リラックスして設計してください。ただし、自社運営会場を使う場合、ケータリングやドリンクの提供方法は、早めに決めたほうが良いでしょう。

スライドを使うのであれば、やっぱり Keynote が使いやすく美しいと思います。

一生懸命がんばっても、参加者に冷たい態度を取られることはあるかもしれません。そういった方を見つけてしまっても、心を動かされずに練習どおり最後まで通し切るのが良いでしょう。それが、他の楽しんでいる方のためになります。

旧Redash(V2) から 新Redash(V8) にアップデートを行いました

Qiita の TORICO Advent Calendar 2019 の記事として書きました

社内での実績閲覧のツールとして Re:dash を使っています。

最初に導入した Re:dash v2 のまましばらくアップデートを行っていなかったので、v8 にアップデートを行いました。

アップデート方針

v8 は dockerコンテナで提供され、 docker-compose で起動させることができます。提供されている docker-compose.yml は、Postgres や redis コンテナの起動も自動でされますし、インストールスクリプトも良くできており、初期状態の ubuntu サーバであればコマンド一発で docker エンジンのインストールも含めアプリの起動まで行うことができます。

従来の EC2イメージの Redash から、スクリプトでアップデートできたら楽なのですが、少し試したところ Postgres の DB のスキーマ名が変わっていたり、Postgresのサーバのバージョンも 9.3 -> 9.6 への大きなアップデートとなることもあり、今回は「Redash v8 を新規EC2で起動し、旧サーバからDBのレコードをコピーする」という方針としました。

Redash V8 サーバの構築

https://github.com/getredash/setup/ こちらのスクリプトを使うことで、非常に簡単に起動させることができます。

1. EC2 で Ubuntu18 サーバを起動

2. ホームディレクトリで、

git clone git@github.com:getredash/setup.git

3.

cd setup
bash setup.sh

あとは、スクリプトの中で docker エンジンのインストール、postgres のデータファイル構築や ランダムなDBパスワードの作成、初期スキーマの構築などすべて行ってくれます。スクリプト終了時に docker-compose up -d まで行うため、ブラウザでアクセスすれば Redash のサインインページが表示されます。

postgres コンテナへの接続

インポートするために、Postgres の Docker コンテナに外部から接続できるようにします。

上記スクリプトで Redash をインストールした場合、/opt/redash/docker-compose.yml が作成されますので、この postgres サービスの箇所に

 postgres:
image: postgres:9.6-alpine
env_file: /opt/redash/env
volumes:
- /opt/redash/postgres-data:/var/lib/postgresql/data
restart: always
# 追加
ports:
- "5432:5432"

ports項目を追加し、ホストマシンの5432ポートをコンテナに接続します。
/opt/redash/env の POSTGRES_PASSWORD に作成されているパスワードと、ユーザー名 postgres でコンテナの postgres に接続できます。

旧サーバの暗号キー等の設定のコピー

旧サーバの、/opt/redash/.env の設定値を新サーバにコピーします。

REDASH_COOKIE_SECRET 

REDASH_COOKIE_SECRET は、DBの data_sources テーブルにある暗号化フィールドの復号化に使われますので、テーブル内容をそのままコピーする場合は一致させる必要があります。

これを一致させない場合、Redash 起動時の data source の選択の際にローディングスピナーがぐるぐる回り続けることになります。APIレスポンス自体はHTTP500が返ってきており、docker-compose のログには Pythonの例外が、暗号化内容を復号できない旨のメッセージと共に出力されます。肝心のトレースバック(スタックトレース)は保存し忘れました。

実際のDBの復号化には settings.DATASOURCE_SECRET_KEY が暗号キーとして使われ、settingsを読んでみると

COOKIE_SECRET = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
SECRET_KEY = os.environ.get('REDASH_SECRET_KEY', COOKIE_SECRET)
DATASOURCE_SECRET_KEY = os.environ.get('REDASH_SECRET_KEY', SECRET_KEY)

このようになっており、DBの暗号キーとクッキーの暗号キーは個別に設定できるようですが、旧Redashの場合はそれぞれ同じ値を使っていると思いますので、新サーバの /opt/redash/env には 旧サーバの /opt/redash/.env から REDASH_COOKIE_SECRET の内容だけコピーしておきます。REDASH_SECRET_KEY ができていたら項目ごと消しておきます。

その他の設定

Google (Gsuite ) を使っている場合は、

REDASH_GOOGLE_APPS_DOMAIN
REDASH_GOOGLE_CLIENT_ID
REDASH_GOOGLE_CLIENT_SECRET

をコピーすることで新サーバでもGoogle 認証が行えるようになります。そのほか、REDASH_MAIL_* など、旧サーバの設定内容はそのままコピーします。

REDASH_GOOGLE_CLIENT_ID="1xxxxxxxx.apps.googleusercontent.com"

このように、ダブルクォーテーションで囲っているとV8ではうまく動作しませんので、envファイル内でダブルクオーテーションは書かないようにします。

テーブル内容のコピー

おそらく、慣れてる方であれば pg_dump を使うのが良いと思うのですが、私は postgres や pg_dump の経験があまり無いため、datagrip を使ってSELECT 結果から INSERT 文を生成し、それをコピペで流し込むことにしました。

旧Redash からの変更点として、データベーススキーマ名が redash から postgres に変更されたこと、また旧版の queries テーブルや dashboards にあった user_email フィールドが無くなっていることなどあり、pg_dump での対応が難しそうだと思ったためです。

データ量として 700クエリー、30ダッシュボード、60ユーザー程度の規模ですので、コピペで十分対応できました。なお、query_results テーブルに過去のSQL結果が入ってますが、2GB程度のストレージを使っており、不要だと思ったため今回は query_results は移行しませんでした。

旧サーバの postgres に接続し、以下のテーブル内容をコピーしていきます。

organizations
users
data_sources
groups
data_source_groups
queries
visualizations
dashboards
widgets

外部キー制約があるため、流し込む順番が違うと失敗しますが、上記の順番で INSERT できると思います。

queries , dashboards には user_email という不要フィールドがあり、queries にはさらに query_results への参照があるのですが、それらは不要なため下記のようなSELECT 文で datagrip 上で検索し、INSERT 文としてコピーして 新DBに INSERT します。

SELECT id, updated_at, created_at, org_id, data_source_id, name, description, query, query_hash, api_key, user_id, last_modified_by_id, is_archived, options, version, is_draft, schedule_failures, search_vector, tags, schedule
FROM public.queries
ORDER BY id;
SELECT id, updated_at, created_at, org_id, slug, name, user_id, layout, dashboard_filters_enabled, is_archived, version, is_draft, tags
FROM public.dashboards
ORDER BY id;

ついでに、MySQL データソースは RDS用のものが新たに出来ていたため、 data_sources の type フィールドは mysql から rds_mysql 変更しました。

シーケンスの更新

INSERT しただけではオートインクリメントPKに使われるシーケンス番号が1から始まり、新しいクエリを保存する時エラーになってしまうので、シーケンスも更新しておきます。

SELECT * FROM users_id_seq;
SELECT MAX(id) + 1 FROM users;
ALTER SEQUENCE users_id_seq RESTART WITH 61;

SELECT * FROM groups_id_seq;
SELECT MAX(id) + 1 FROM groups;
ALTER SEQUENCE groups_id_seq RESTART WITH 6;

SELECT * FROM data_sources_id_seq;
SELECT MAX(id) + 1 FROM data_sources;
ALTER SEQUENCE data_sources_id_seq RESTART WITH 24;

SELECT * FROM data_source_groups_id_seq;
SELECT MAX(id) + 1 FROM data_source_groups;
ALTER SEQUENCE data_source_groups_id_seq RESTART WITH 22;

SELECT * FROM dashboards_id_seq;
SELECT MAX(id) + 1 FROM dashboards;
ALTER SEQUENCE dashboards_id_seq RESTART WITH 37;

SELECT * FROM queries_id_seq;
SELECT MAX(id) + 1 FROM queries;
ALTER SEQUENCE queries_id_seq RESTART WITH 699;

SELECT * FROM visualizations_id_seq;
SELECT MAX(id) + 1 FROM visualizations;
ALTER SEQUENCE visualizations_id_seq RESTART WITH 1230;

SELECT * FROM widgets_id_seq;
SELECT MAX(id) + 1 FROM widgets;
ALTER SEQUENCE widgets_id_seq RESTART WITH 695;

このように、MAX(id) の結果をコピペしてALTER SEQUENCE していきました。1行で書けそうなのですが、経験が無く書き方がわからなかったため、愚直に書いてます。

これで、新サーバで旧サーバの状態が再現されていると思います。

nginx に SSL証明書を組み込む

自己認証局のSSL証明書がありますので、使います。

参考: dockerでredashのnginxをssl化したら思いの外ハマった

mkdir -p /home/ubuntu/nginx/certs

certs に、証明書 ( my-awesome.crt, my-awesome.key )を入れる 

/home/ubuntu/nginx/default.conf を作成

upstream redash {
server redash:5000;
}

server {
listen 80 default;
return 301 https://$host$request_uri;
}

server {
listen 443;
ssl on;
ssl_prefer_server_ciphers on;
ssl_protocols TLSv1.2;
ssl_ciphers "ECDHE+RSAGCM:ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:!EXPORT:!DES:!3DES:!MD5:!DSS";

gzip on;
gzip_types *;
gzip_proxied any;

ssl_certificate /etc/nginx/certs/my-awesome.crt;
ssl_certificate_key /etc/nginx/certs/my-awesome.key;

error_log /var/log/nginx/nginx_error.log;
access_log /var/log/nginx/nginx_access.log;

location / {
proxy_ssl_server_name on;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_pass http://redash;
}
}

/opt/redash/docker-compose.yml を修正

 nginx:
image: redash/nginx:latest
ports:
- "80:80"
- "443:443"
depends_on:
- server
links:
- server:redash
restart: always
volumes:
- /home/ubuntu/nginx/default.conf:/etc/nginx/conf.d/default.conf
- /home/ubuntu/nginx/certs:/etc/nginx/certs

最後に

繰り返しとなりますが、データソース選択ビューで、INSERTしたはずのデータソースが表示されず、ぐるぐるとローディングスピナーが回り続ける場合、旧サーバと settings.DATASOURCE_SECRET_KEY が一致していません。/opt/redash/env の REDASH_COOKIE_SECRET を旧サーバと同じにして、REDASH_SECRET_KEY 設定を消すとデータソースが見れるようになると思います。

CloudFront の Origin を、ボットかどうか(User-Agentによって)判定して切り替える

Webpack でビルドしたWebアプリを S3 にデプロイし、S3 + CloudFront でホストしていました。

ブラウザからのリクエストは問題ないのですが、検索エンジンやTwitterなどのボットからのアクセスの場合、JSを動かせないため内容のほぼ無いページが評価されてしまい、情報の価値がありません。

そのため、Lambda@Edge + CloudFront で、User-Agent を判定し、JSを動かせないようなUAの場合はオリジンをサーバサイドレンダリングするサーバに切り替えるようにしました。

Lambda@Edge とは

Lambda ファンクションを CloudFront のエッジサーバにデプロイし、動作させることができます。

CloudFront で動作させる箇所を4箇所から選ぶことができ、オリジンサーバへリクエストする際に Lambda ファンクションでリクエストヘッダを変更したり、オリジンサーバを変更したりできます。

AWSのページにわかりやすい図があります

ちなみに、Lambda@Edge について以前書いたブログ記事もあります

失敗例

AWSの図を見る限り、Origin Request で Lambda ファンクションを動かし、
リクエストヘッダの User-Agent ヘッダで Origin を変えれば良さそうです。

実際にそのようなコードは書けるのですが、Origin Request の際は User-Agent ヘッダは常に
「Amazon CloudFront」になるため、判定には使えません。

なお、CloudFront のビヘイビアの設定で、ユーザー端末の User-Agent ヘッダをオリジンまで通すことは可能です。
(ビヘイビア→ Cache Based on Selected Request Headers → Whitelist Headers で、User-sAgent を Add Custom)

ただし、これを行うと警告が出ます。

Some headers have a lot of possible values, and caching based on the values in these headers would cause CloudFront to forward more requests to your origin. We recommend that you do not cache based on the following headers:User-Agent

この警告にある通り、キャッシュの効率が悪くなるので、よほどの理由がない限りやらないほうが良いでしょう。

方針

このような方針にしました。

1. Viewer Request に入ってきた時点で、Lambda@Edge で User-Agent ヘッダを判定。
ホワイトリストで評価し、もしJSを扱えないようなUA(ボット等)の場合、
bot-user-request=1 のリクエストヘッダを付与する。

2. CloudFront のビヘイビアで、bot-user-request のHTTPヘッダは通す (Cache Based on Selected Request Headers にする)

3. Origin Request の時点で、Lambda@Edge で bot-user-request ヘッダがあれば、オリジンのドメインを変更

コード

Viewer Request

'use strict';

/*
Cloudfront の Viewer Request 用のファンクション。

Google Bot からのアクセスの場合、Httpヘッダの bot-user-agent = "1" を設定する。
この後、CloudFront のビヘイビアで bot-user-agent のヘッダーを通し、
さらに Origin Request のLambda で、bot-user-agent を判断し、
Originを切り替える
*/

const bots = [
'Twitterbot',
'facebookexternalhit',
'compatible; Google',
'Googlebot',
];


exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;

const isBot = bots.some(v => {
return request.headers['user-agent'][0].value.includes(v)
});
if (isBot) {
request.headers['bot-user-agent'] = [
{
'key': 'bot-user-agent',
'value': '1'
}
];
}

callback(null, request);
};

Viewer Response

'use strict';

/*
bot-user-agent の HTTPリクエストヘッダがあったら、
オリジンを書き換える。

*/
const newDomainName = 'new.example.com';

exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;

if ('bot-user-agent' in request.headers) {
if ('custom' in request.origin ) {
request.origin.custom.domainName = newDomainName;
request.headers['host'] = [{ key: 'host', value: newDomainName}];
// HTTPSでリクエストする場合
request.origin.custom.port = 443;
request.origin.custom.protocol = 'https';
}
}

callback(null, request);
};

AWS LambdaをCloudFrontエッジサーバで動かす (Lambda@Edge)

AWSの Lambda ファンクションを、CloudFrontのエッジサーバにデプロイして、リクエストやレスポンスをインターセプトして処理を行うことができます。Lambda@Edge と呼ばれます。

Webアプリケーションフレームワークにある「ミドルウェア」のような使用感で使えます。

例えば、S3 をオリジンに使っている時(サーバレス環境などで)、少しだけ動的に認証やリダイレクトなどのHTTPヘッダーを扱う処理をしたい時など、便利です。

ポイントとして、us-east-1 (バージニア北部) リージョンの Lambdaファンクションのみ選択できます。

AWSの公式ドキュメント

1. 概要

1-1. フックポイント(トリガー)

Amazonのページにわかりやすい絵があります

リクエスト:

クライアント → [Viewer Request]→ CloudFront → [Origin Request] → Origin

レスポンス:

クライアント ← [Viewer Response] ← CloudFront ← [Origin Response] ← Origin

Viewer Request, Origin Request, Origin Response, Viewer Response の4箇所で処理を差し込めます。

今回は、例として Origin Response にファンクションを差し込んでみます。

1-2. Lambdaファンクションのパターン

公式ドキュメントにサンプル集があります(便利)。

今回は、Origin Response で処理をさせるため、引数にはレスポンスが入っており、処理としてはレスポンスを返す( callback(null, response) を実行する) 必要があります。

リクエストとレスポンスを取得するファンクションの定番パターンは

'use strict';

exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const response = event.Records[0].cf.response;
// なにか処理...
callback(null, response);
}

このようになります。responseを加工して返すか、新しいレスポンスデータを作って返すことになるでしょう。

1-3. eventの中身をすべてJson で表示するファンクション

最初は、event の中身をすべて見れるようなファンクションを作ると動作を早く理解できます。このようになります。

'use strict';

exports.handler = (event, context, callback) => {
const response = event.Records[0].cf.response;
response.status = 200;
response.statusDescription = 'Ok';
response.headers['content-type'] = [{
"key": "Content-Type",
"value": "application/json",
}];
response.body = JSON.stringify(event, null, ' ');
callback(null, response);
}

2. 実際に作ってみる

2-1. CloudFront ディストリビューションの作成

2-1-1. CloudFront のページ  から、 Create Distribution

2-1-2. Web の Get Started

2-1-3. Lambda Function Associations は、ここでは設定しない。(あとで Lambdaのページから設定する)

オリジンは適当なS3にでもしておいてください。

キャッシュ時間は、すべて0にしてキャッシュさせないようにすると検証が楽です。

Create Distribution で作成。

2-2. Lambda 関数の作成

2-2-1. us-east リージョンの Lambdaページから「関数の作成

2-2-2. ロールは、「カスタムロールの作成」

2-2-3. ロール作成ページが表示されるので、ロール名をつけて許可

2-2-4. このロールに、EdgeLambdaの「信頼関係」(サービスプリンシパル) が必要なので、一旦 IAM のロールの「信頼関係」タブを見る

2-2-5. 信頼関係の編集 をクリック

2-2-6. Principal をリストにして、"edgelambda.amazonaws.com" を追加して、ポリシーの更新。

"Service": [
"edgelambda.amazonaws.com",
"lambda.amazonaws.com"
]

2-2-7. 信頼されたエンティティに edgelambda.amazonaws.com が追加されている。

2-2-8. Lambda のページに戻ると、「既存のロール」に先程のロールが設定されるので、「関数の作成」をクリック。

2-2-5. 関数作成のウインドウが出るので、関数を書いて、「保存」。

'use strict';

exports.handler = (event, context, callback) => {
const response = event.Records[0].cf.response;
response.status = 200;
response.statusDescription = 'Ok';
response.headers['content-type'] = [{
"key": "Content-Type",
"value": "application/json",
}];
response.body = JSON.stringify(event, null, ' ');
callback(null, response);
}

2-2-6 「テスト」ボタンをクリック

2-2-7. このテストページでは、ファンクションの第一引数を設定してテストできる。

今回のLamda関数は、第一引数 event が何であってもオウム返しするだけなのでどんなデータでもテストできるが、Amaon CloudFront Access Request In Response が実際の Origin Response の event に近いので、これを使ってテストイベントを作る。

2-2-8. 「テスト」をクリックしてテストを実行。

2-3. CloudFront にデプロイ

作ったファンクションを CloudFront にデプロイする。

2-3-1. Lambda の Designer から、CloudFront を選択

2-3-2. 「グローバル展開が保留中」と表示されるので、下にある トリガーの設定で、「Lambda@Edgeへのデプロイ」をクリック

2-3-4. デプロイページで、「オリジンレスポンス」を選択し、確認チェックボックスを入れて「デプロイ」

デプロイされる。

2-4. テストリクエスト

curlかなんかでリクエスト。eventの内容がわかる。

% curl -v 'http://xxxxxxxxxxxxxx.cloudfront.net/'

* Trying ****:****:****:****:****:****:****:****...
* TCP_NODELAY set
* Connected to xxxxxxxxxxxxxx.cloudfront.net (****:****:****:****:****:****:****:****) port 80 (#0)
> GET / HTTP/1.1
> Host: xxxxxxxxxxxxxx.cloudfront.net
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 2663
< Connection: keep-alive
< x-amz-bucket-region: ap-northeast-1
< Date: Sat, 19 Jan 2019 13:30:09 GMT
< Server: AmazonS3
< X-Cache: Error from cloudfront
< Via: 1.1 xxxxxxxxxxxxxx.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: xxxxxxxxxxxxxx
<
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "xxxxxxxxxxxxxx.cloudfront.net",
"distributionId": "xxxxxxxxxxxxxx",
"eventType": "origin-response"
},
"request": {
"clientIp": "****:****:****:****:****:****:****:****",
...
...

この出力は、今回の Lambda 関数の第一引数そのものなので、Lambda のテストデータとしてコピペしておくと今後のテストに便利です。

2-5. 修正後のデプロイ

アクションメニューに「Lambda@Edge へのデプロイ」項目が追加されるのでここからデプロイすると楽です。

AWS CloudFrontにACMの独自SSL証明書を適用する

CloudFront には、AWS ロードバランサー (ELB,ALB) と同じように ACM (Certificate Manager) で無料で作成したSSL証明書を適用することができます。

ポイントは、us-east-1 (バージニア北部) リージョンのACM で作った証明書のみ選択できるというところです。

1. ACMでSSL証明書を作る

1-1. us-east-1 の ACM へ行く https://console.aws.amazon.com/acm/home?region=us-east-1#/

1-2. 証明書のリクエスト をクリック

1-3. パブリック証明書のリクエスト → 証明書のリクエスト

1-4. ドメイン名を入力。「この証明書に別の名前を追加」すると、SSL証明書の SAN (Subject Alternative Name ) になる。

1-5. 検証方法はお好みで。DNSだと、使用後にレコードを消さないと後々レコードを見つけた時に混乱の原因になるので、削除するひと手間が多くかかる。Eメールの方が好み。

検証が終われば、SSL証明書が使えるようになります。

2. CloudFront に適用する

2-1. CloudFrontの、Distribution Settings を開く

2-2. Alternate Domain Names に、使うドメインを書く

2-3. SSL Certificate で Custom SSL Certificate (example.com): を選ぶと、us-east-1 (バージニア北部) リージョンのACMで作った証明書の一覧が出てくる。
先程作ったSSL証明書を選択。

これで 独自ドメインのSSL証明書が CloudFront で使えます。

あとは、DNSで、使うドメインのCNAME を CloudFront のドメイン (xxxxxxxxxxx.cloudfront.net) に向ければ、独自ドメインのHTTPリクエストがSSL通信でこの CloudFront を経由するようになります。

Bootstrapでスマートフォン/PC両対応ページを手軽にレイアウトする 第1会 スペーシングユーティリティ

社内勉強会で行った内容の復習です。

※ サンプルドキュメントの内容はダミーです

はじめに

TORICOでは、ウェブサービスのCSSフレームワークとしてBootstrap4を多く採用しています。

ユーティリティクラス(margin,padding,文字サイズなど)、プリセットデザイン(card,buttonなど)、グリッドレイアウトが必要十分なだけ収まっており、容量と使う機能のバランスがちょうどよいためです。

漫画全巻ドットコムなどのサービスの特集ページもBootstrap4が適用されており、
非デザイナーでも十分な自由度を保ったレイアウトを少ないコード量で書くことができます。
今回は、非デザイナー向けに、簡単にBootstrapの使い方を教えます。

エディタアプリの準備

テキストエディタを使います。好みのテキストエディタが特になければ、Microsoft の Visual Studio Code (以下VSCode) をインストールしてください。Windows でも Mac でも、無料で使えます。

Visual Studio Code

その他では、Atom(Github), ブラケッツ(Adobe), サクラエディタnotepad++  あたりが無料で使えて使いやすいと思います。

サンプルドキュメント

今回は、サンプルとしてこのような文章を使います。

新年会のお知らせ

◆ 幹事
漫画全巻子 @zenkanko

◆ 日時
1月20日

◆ 会費
4,000円

◆ 場所
新宿西口 ○○家

◆ 参加方法
Slackの #新年会2019 に参加し、参加の意思がある旨発言してください。

◆ キャンセル規定
3日前以降のキャンセルは会費をもらいます。注意してください。

ドキュメントの構造を把握する

このドキュメントの構造としては、
「新年会のお知らせ」がドキュメント全体のタイトル、「幹事」「日時」がその子セクションの見出しとなっています。

ドキュメントの構造をマークアップする

このドキュメントの構造を、コンピュータがわかる形で意味付けしていく作業を「マークアップ」と呼びます。マークアップのために「HTML」という言語が使われます。
それでは、HTMLを使ってこのドキュメントをマークアップします。

ドキュメント全体のタイトルを h1 タグ、小セクションの見出しを h2 タグ、その中の文章を p タグでマークアップすると

<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>新年会のお知らせ</h1>

<h2>幹事</h2>
<p>漫画全巻子 @zenkanko</p>

<h2>日時</h2>
<p>1月20日</p>

<h2>会費</h2>
<p>4,000円</p>

<h2>場所</h2>
<p>新宿西口 ○○家</p>

<h2>参加方法</h2>
<p>Slackの #新年会2019 に参加し、参加の意思がある旨発言してください。</p>

<h2>キャンセル規定</h2>
<p>3日前以降のキャンセルは会費をもらいます。注意してください。</p>
</body>
</html>

こうなります。

本来は、h1,h2,pタグの他に、sectionタグなんかもほしい所ですが、今回は省略します。

試しに、この文章をテキストエディタ(VSCode等)で書いて new-year-party.html というファイル名で保存してください。文字コードは UTF-8 で保存してください。

文字コードはVSCode は右下に表示されています。デフォルトで UTF-8 になってると思います。改行コードは、Windowsの場合 CRLF となってると思いますが、LFにしておいたほうが無難です。

保存した new-year-party.html をダブルクリックするか、もしくはブラウザのアプリアイコンにドラッグアンドドロップして開くと、ページが表示されます。

もし、「このファイルを開く方法を選んでください」というウインドウが出たら、Google Chrome がインストールされていたら Google Chrome で、なければ Microsoft Edge を選んでください。

ブラウザは、マークアップの内容を把握し、h1 を大きな文字で、h2 を少し大きな文字で、
p を小さな文字でページに表示してくれます。

Bootstrap の導入

ドキュメントのマークアップが終わったので、見た目をきれいにするため Bootstrap を導入します。

Bootstrapの導入ページ を見ると、CDN(ファイル配信サーバー)を使う方法が書いてありますので、その通りにします。

<head>〜</head> の中に、

<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">

これをコピペします。これで終わりです。

1行目の meta name="viewport" タグは、スマートフォンでHTMLを見た時に文字サイズが小さくならないようにする指定です。(bootstrapと直接関係はありません)

2行目のlink タグで bootstrap を読み込んでいます。

ファイルを保存して、ブラウザをリロードすると、表示が少し変化します。
例えばフォントが変更され、大見出しの「新年会のお知らせ」が、ブラウザの左上に余白がほとんどなく配置されます。

レイアウト調整

次に、Bootstrapの スペーシングユーティリティを使って余白を整えていきます。
このスペーシングユーティリティは美しい見た目を作る上で一番重要といえます。

ドキュメントはここです。

例えば、各HTMLタグに、class="my-4" とか class="px-3" とか class="ml-2" のように class 属性を書くことで、余白量を調整することができます。

my-4 は、margin Y(天地)方向 サイズ4を設定
px-3 は、padding X(左右)方向 サイズ3を設定
ml-2 は、margin L(左)方向 サイズ2を指定

という指定になります。詳しくは、ドキュメントを徹底的に読んでください。

外枠をつける

将来的にスマートフォンでもPCでも美しく表示できるよう、ドキュメント全体を1つの div タグで囲って、スペーシングユーティリティで余白を作っておきます。

ついでに、カラーユーティリティを使って簡単に色をつけます。

全体的なコードはこうなります。

<html>

<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
</head>

<body class="bg-secondary p-2">
<div class="bg-white p-2 shadow-sm">
<h1>新年会のお知らせ</h1>

<h2>幹事</h2>
<p>漫画全巻子 @zenkanko</p>

<h2>日時</h2>
<p>1月20日</p>

<h2>会費</h2>
<p>4,000円</p>

<h2>場所</h2>
<p>新宿西口 ○○家</p>

<h2>参加方法</h2>
<p>Slackの #新年会2019 に参加し、参加の意思がある旨発言してください。</p>

<h2>キャンセル規定</h2>
<p>3日前以降のキャンセルは会費をもらいます。注意してください。</p>
</div>
</body>

</html>

※ VSCode では、Shift+Alt+F でコードの自動整形ができます。何かを書いたらこのショートカットキーで整形すると見やすくなります。

スマートフォンサイズで見るとこのような形になります。

各要素の余白を調整したいので、スペーシングユーティリティで調整します。
ついでに、小見出しが少し大きすぎるのと思ったので、タイポグラフィのクラスを使って調整します。

h1 に、class="my-5" をつけ、
h2 に、class="h4 mt-4" をつけました。

このようになります。

<html>

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
</head>

<body class="bg-secondary p-2">
<div class="bg-white p-2 shadow-sm">
<h1 class="my-5">新年会のお知らせ</h1>

<h2 class="h4 mt-4">幹事</h2>
<p>漫画全巻子 @zenkanko</p>

<h2 class="h4 mt-4">日時</h2>
<p>1月20日</p>

<h2 class="h4 mt-4">会費</h2>
<p>4,000円</p>

<h2 class="h4 mt-4">場所</h2>
<p>新宿西口 ○○家</p>

<h2 class="h4 mt-4">参加方法</h2>
<p>Slackの #新年会2019 に参加し、参加の意思がある旨発言してください。</p>

<h2 class="h4 mt-4">キャンセル規定</h2>
<p>3日前以降のキャンセルは会費をもらいます。注意してください。</p>
</div>
</body>

</html>

スマートフォンサイズで見るとこのようになります。

これで読みやすくなりましたが、このHTMLをPCサイズで見ると、
もっと左右の余白がほしいところです。

レスポンシブブレイクポイント

続いて、Bootstrapの持つレスポンシブデザイン機能を使い、PCサイズで見たときにもっと多くの余白を取るようにします。

レスポンシブデザインの際、PCかスマートフォンかの判断は、ブラウザの横幅で判断します。というより、実際はPCとスマートフォンを区別しているわけではありません。横幅の大きなブラウザでも小さなブラウザでも破綻なく要素がきれいに表示されるようにレイアウトを行います。

ブラウザの幅を変えていったとき、レイアウトが大きく切り替わるポイントがあります。それを「レスポンシブブレイクポイント」(以下ブレイクポイント)と呼びます。

Bootstrap4 のドキュメントを見てみると、Bootstrap4では 576px, 768px, 992px, 1200px にブレイクポイントが設定されていることがわかります。

これらすべてのポイントについて細かく考慮する必要はありません。必要に応じて、「あるブレイクポイントを超えるブラウザサイズの場合、レイアウトを変更する」ようにHTMLタグのクラスをつけていきます。

今回は、幅768px を超えるブラウザ(PCでの閲覧を想定) の際に、左右の余白を多くとることにします。

幅768px を超えるサイズのことを、Bootstrap では medium デバイス (略して md )と呼んでいます。

先程のスペーシングユーティリティの説明を読むと、デバイスサイズに応じて余白の量を変えられることがわかりますので、

<body class="bg-secondary p-2">
<div class="bg-white p-2 shadow-sm">

この箇所について、スマートフォンでは 2のサイズのパディングをとっていましたが、medium以上のサイズのデバイス(つまりタブレットやPC)では、5サイズで取るように修正します。

p-md-5 を追記します。

<html>

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
</head>

<body class="bg-secondary p-2 p-md-5">
<div class="bg-white p-2 p-md-5 shadow-sm">
<h1 class="my-5">新年会のお知らせ</h1>

<h2 class="h4 mt-4">幹事</h2>
<p>漫画全巻子 @zenkanko</p>

<h2 class="h4 mt-4">日時</h2>
<p>1月20日</p>

<h2 class="h4 mt-4">会費</h2>
<p>4,000円</p>

<h2 class="h4 mt-4">場所</h2>
<p>新宿西口 ○○家</p>

<h2 class="h4 mt-4">参加方法</h2>
<p>Slackの #新年会2019 に参加し、参加の意思がある旨発言してください。</p>

<h2 class="h4 mt-4">キャンセル規定</h2>
<p>3日前以降のキャンセルは会費をもらいます。注意してください。</p>
</div>
</body>

</html>

この指定で、PCサイズのブラウザで見た時にも十分な余白がとれるようになります。

margin と padding

余白について、padding(p) と margin(m) という2つの指定方法がありました。

padding (p) は指定要素の内側margin(m) は指定要素の外側の余白を指定します。

marginの相殺

HTMLのスタイルの仕様で、注意しなければいけない仕様に「天地方向のマージンの相殺」というものがあります。

例えば

<h2 class="my-4">小見出し</h2>
<p class="my-3">本文</p>

というHTMLがあった場合、小見出しと本文の間の余白は 4+3 のサイズとはなりません。大きい方の4のサイズとなります。

これがマージンの相殺という仕様で、HTMLをレイアウトする際には避けて通れない、重要な仕様です。必ず覚えてください。マージンの相殺は天地方向のみ発生し、左右方向では発生しません。

相殺したくない場合はいくつか回避方法があります。

  • overflow: hidden を使う
  • padding を使う
  • border を使う
  • hr を挟む
  • etc

検証コード

<html>

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
</head>

<body class="bg-secondary p-3">
<div class="bg-white p-2 my-4 shadow-sm">
<h2 class="mb-5">見出し</h2>
<div>
<p class="mt-5">↑この間のmarginが相殺されている</p>
</div>
</div>
<div class="bg-white p-2 my-4 shadow-sm">
<h2 class="mb-5">見出し</h2>
<div style="overflow: hidden;">
<p class="mt-5">overflow:hidden で margin 相殺を打ち消し</p>
</div>
</div>
<div class="bg-white p-2 my-4 shadow-sm">
<h2 class="mb-5">見出し</h2>
<div style="border-top:1px solid transparent">
<p class="mt-5">border で margin 相殺を打ち消し</p>
</div>
</div>
<div class="bg-white p-2 my-4 shadow-sm">
<h2 class="mb-5">見出し</h2>
<div class="pt-1">
<p class="mt-5">padding で margin相殺を打ち消し</p>
</div>
</div>
<div class="bg-white p-2 my-4 shadow-sm">
<h2 class="mb-5">見出し</h2>
<hr class="m-0">
<p class="mt-5">hr で margin 相殺を打ち消し</p>
</div>

</body>

</html>

詳細については、ブラウザのデバッグツール (Windowsでは Shift + Ctrl + I) のインスペクタを使って、動作を確認してください。

余計な要素を追加せずに行えるのが overflow: hidden を指定する方法です。

今回のように、見出しや本文を上から下に配置していくレイアウトの場合、枠の左右の余白を(親要素の)padding で作り、その中の見出しや本文の天地余白をmargin で作っていくとうまくまとまります。

個人的には、「要素の上だけにmargin を指定する」「要素の下だけにmaginを指定する」といった指定方法より、「天地にmarginを指定して積極的に相殺させる」ほうが、前後の関係を気にする必要がなく早く書いていけるように思います。

おさらい

  • Bootstrapを使うと、スマートフォンやPCなどデバイスのブラウザサイズが変わっても破綻なくレイアウトをすることができる(レスポンシブデザイン)。
  • レイアウトの切り替わるブラウザ幅を「レスポンシブブレイクポイント」と呼ぶ
  • Bootstrapの導入は1行の link タグのコピペだけで行える。簡単。
  • スペーシングユーティリティ重要。
  • 天地方向のマージンは相殺される。

次回は、レスポンシブグリッドを使ったレイアウトの段組みを行います。

仕事をする上でいつも思っている言葉

弊社TORICO にインターンとして大木さんというエンジニアが参加されています。ブログをお任せしたところ、職場や働き方についても書かれていました。今回はそれに刺激を受け、私も職に対しての考えを書こうと考えました。今回は、私が仕事をする上でいつも思っている言葉、そして私を変えた言葉をいくつか紹介したいと思います。

TORICOインターンブログ

時間とは、可能性だ

Time is ナントカ。パターンは多くあると思うのですが、私は「可能性」だと思っています。Time is possibility. 自分で思いついた言葉ですが、世界中で同じことを考えている方は多くいるんじゃないかと思っています。

人、特に日本で生まれた人は、手厚い人権に守られているため自由に学校や仕事を選ぶことができます。「何にでもなれる」と言っても大げさではないでしょう。ただし、何にでもなれる可能性は時間が経つごとに減っていきます。ポジションには限りがあり、競争があるためです。

人が時間を使うということは、何かを達成する可能性を取捨選択するということです。時間には、すべての人に平等な限界があります。それを何に使うかで、やりたいことをできる可能性は変わってきます。

日本で生まれて、やりたいことを選ぶ自由がある以上、好きなことをやればいいと本当に思います。そして、何かを選択すれば、それは何かができる可能性として自分の中に蓄積していきます。逆に選ばなかった可能性は捨てることになり、永遠に失われます。時間は巻き戻せないためです。時間は、自分のやりたいことを実現する可能性を高めるために使うものである、と私は考えます。

文句を言う前に頭を使え

これも私が思いついた言葉ですが、いつも忘れずに行動しています。ちなみに言葉で人に伝えたことはありません。パワハラだと言われそうなので。

何か複雑な問題があった時、それが原因でストレスを負った時、文句・愚痴を言ったり言い訳をしたくなることがあります。その言い訳の先にあるのは自分のストレスの解消や虚栄心の確保です。聞かされる人にとってはただただ時間の無駄で、人によってはそれがストレスの原因になりえます。家に帰ったら人形にでも向かって心が穏やかになるまで愚痴を言うのがいいでしょう。

求められることは、課題を効果的に解決できるまで知恵を絞り続けることです。場合によっては「解決できない理由を説明する」だったり「課題を凍結する(先送りにする)」選択もありえると思いますが、文句を言うことはゴールに近づくことにはならず、ただの時間の無駄です。社会人であれば、頭を使って問題に立ち向かいましょう。

人のえらさは平等

社会人になってすぐ、先輩から教わったことです。えらさ、はいろんな意味にとれる言葉ですが、ここでは「人より権利が優れている」という意味で、王国でいう王様のようなえらさです。

日本人である限り、他の日本人よりえらいということは決してありません。すべての人が法のもと平等であると憲法で定められています。

では、会社などの組織に所属した時のヒエラルキーは何なのかというと、ただの雇用契約上の役割や権限の違いでしかありません。アルバイトでも社長でもお客様でも、総理大臣でも、お互いに成人であればえらさの違いはなく、雇用契約であったり売買契約であったり、契約上の権限や役割の違いがあるだけです。組織内での権限の違いを組織内での「えらさ」と表現するのもわからなくはありませんが、「えらさ」という言葉にすると人権的に差があるようにも感じられるかもしれないため、私は言わないようにしています。

会社組織でいうなら、上司は部下に対して人権に優位性があるわけではなく、「評価して賃金を決めることができる権利を組織内で与えられている」程度の違いしかありません。(持ってる株式数に大きな違いが無い場合)。なので、同じ社会人でありプロフェッショナル同士である同僚・部下とは礼儀を持って接するのが大切なことです。彼らが隣の席にいるのは、「自分と同じ会社と雇用契約を結んだ」ただそれだけの理由です。

これからどうするか

これは、アドラー心理学の解説書「幸せになる勇気」から学んだ言葉です。

この本は、カウンセリングをする時、多くは「悪いあいつ」の批判や、「かわいそうなわたし」を訴えるだけで終わってしまう。それは本質の解決にはつながらず、聞き流すだけの内容だと、伝えます。

たしかに愚痴や悪口にはなんの意味も価値もありません。家に帰ったら人形にでも向かって言ってればいいでしょう。大事な話題は「これからどうするか」であり、人と会話をする上で見失ってはいけません。

嫌われる勇気幸せになる勇気

両方人気のある本ですが、内容は面白く、私の行動を形作る要因となっています。おすすめします。

そのほか、この場を使って読んで良かった思う良本を紹介しておきます。

あなたの知らない脳

哲学的な何か、あと科学とか

「これは自分の仕事ではない」と言わない

何かの本か記事で読んだのですが、出展を覚えていません。おそらく Amazon とか AWS の組織について書かれた本だったと思うのですが…。

「これは自分の仕事ではない」と言う前にすべきことは、知恵と勇気をふりしぼって、そしてなりふりかまわず課題の解決に向き合うことです。

知恵をうまく使うことで、具体的には既存のツールや手順をうまく組み合わせることで課題の解決は大きく効率化させることができます。

逆に知恵の範囲が狭ければ、人海戦術と根気での解決策しか考えつかないかもしれません。もしその時、「面倒だ」「もっと効率的にできる方法がありそうだ」と感じれば、効率化する方法は間違いなくあります。知恵と勇気をふりしぼって、対応することで、個人や組織の成長につながるでしょう。

ダメじゃなければOK

私の考えた言葉です。デザインワークをする際は、これを思いながら作業をします。

私は自分で行ったデザインを見直す際、ダメな箇所があるかどうかを注意して確認します。良くできた箇所はまったく気にしません。無視します。ダメな所だけを探します。

ダメな箇所に気づいたら、無くなるまで直します。「○○さんに見せたらここ何か言われるかなー」と感じたら、それはダメな箇所であり、直す必要があります。先鋭的な表現とかメッセージ性とかはあってもなくてもどっちでもいいです。ただし、そもそものコンセプトとして「なければダメ」なら無きゃダメです。ダメな所がみつからなければ、そのデザインはひとまずは「OK」です。

納期までにでっちあげる

マンガ「げんしけん」で、コミケの出版物の納期がせまっておりもう無理だと半ばあきらめかけているチームに向かって登場人物が言った言葉です。

この「でっちあげる」という表現が好きで、納期を最優先とし、あきらめずに手段を選ばず達成する意志を感じます。

本来であれば、クオリティを十分に保って、かつ納期(約束)を守るのが一番良いのですが、両立が難しい場合でもあきらめずに、何としてでも約束は守らなければならないと、この作品は伝えています。

げんしけん、新装版出てた

しっかり練習してこい

スーパーストリートファイター4で、ケンが勝利時にいう言葉。つまり、ケンに負けた時に言われる言葉。その言葉はビデオゲームの範囲を超えて、プレイヤーにも向けられているようです。

ビデオゲームに限らず、どんなゲーム(スポーツ)であれ、勝利するために挑戦するわけですが、勝つ可能性を上げるためには練習と研究が重要です。あとは事前準備とか体調管理とかでしょうか。当たり前のことなのですが、たまに忘れかけてしまうこともあります。

例えば、たまたま1ゲームだけうまくいった時、次も同じようにやればうまくいくと思ってしまうことがありますが、そんなことはありません。それは自己評価が高すぎます。勝ったのはたまたまであり、慢心すると足元を掬われるということを「勝って兜の緒を締めよ」ということわざが思い出させてくれます。

「勝った理由は運が良かったから、負けた理由は実力不足」という言葉もあります。もし、勝ちたくてやることであれば、「しっかり練習してこい」と言われないよう練習しましょう。時間を練習に使えば、勝つ可能性を上げることができます。

逆に、練習に時間を使えないのであれば、そのゲームでは勝つことをあきらめているということになります。

Search