AWSのEC2上でblenderを動作させてNice DCVを使ってリモート接続する方法

はじめに

blenderなどの3Dアプリケーションを利用する場合、そこそこのマシンスペックが必要になります。
私は、Mac Book(ノートPC)を愛用しているのですが、少々厳しい(デュアルコアだし、GPUも無い)です。
そこで、AWS上のEC2でblenderを動作させ、Nice DCVで接続することにします。

blenderの公式サイトを参考にすると、2022年10月現在のオススメスペックは以下の通りです。

  • 64-bit eight core CPU
  • 32 GB RAM
  • 2560×1440 display
  • Three button mouse or pen+tablet
  • Graphics card with 8 GB RAM

参考:www.blender.org

上記を満たすには、g4dn.2xlarge (8コア、32GbyteのRAM) 程度がちょうど良さそうですね。

設定手順

今回は、手元のmacから接続します。EC2インスタンスは、LinuxWindowsが選択できますが、Linux側を選択します。
(ここは、慣れている方で良いと思います。)

ざっくりとは、以下の手順です。

  1. blenderとNice DCV設定済みのAMIからEC2インスタンスを起動
  2. ユーザ作成 (Nice DCVで接続した時のログイン用兼作業用)
  3. Nice DCVの設定ファイルの書き換え
  4. クライアントのインストール(既にインストール済みでなければ)

EC2インスタンスの起動

  • AMIは、「Nimble Studio Linux Workstation AMI」を選択します。
    • このAMIには、BlenderとNice DCV(サーバ)が最初からインストール済みとなります。
    • スポットインスタンスを選択する場合は、高度なオプションから「スポットインスタンスをリクエスト」にチェックを入れます。
      • ただし、スポットは割り当てられないこともあるので注意。
  • EC2インスタンスでは、Nice DCVで利用する8443ポートを空けておきます。
  • EC2のIAMRoleを設定します。
    • 以下の通り、Nice DCVのライセンスを取得できる様に設定します。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::dcv-license.ap-northeast-1/*"
        }
    ]
}

ユーザの作成

起動したEC2インスタンスにログインします(以下、SSHでログインしている前提)。
centosなので、amazon linuxのデフォルトユーザec2-userではなく、centosユーザでのログインとなります。


(1)ユーザを作成します。

sudo adduser [USER_NAME]

(2)パスワードを変更します。

sudo passwd [USER_NAME]

(3)wheelグループへ追加します。

sudo usermod -aG wheel [USER_NAME]

Nice DCVの設定

nice DCVの設定で、作成したユーザでのログインを許可します。
まずは、permissionファイルの作成します。
作成するpermissionファイル( /etc/dcv/permissions.conf としました)の内容は、以下の通りです。

[permissions]
[USER_NAME] allow builtin
%owner% allow builtin

※[USER_NAME]は、上で作成したユーザ名に置き換えてください。

次に、そのファイルを、設定ファイル (/etc/dcv/dcv.conf)で指定します。

[session-management/automatic-console-session]
permissions-file='/etc/dcv/permissions.conf'

後は、dcvserverを再起動して、上の設定を読み込みます。

sudo systemctl restart dcvserver

Nice DCVクライアントのインストール

以下から、ご自分の端末に合うアプリをインストールしてください。
docs.aws.amazon.com

後は、Nice DCVクライアントを起動して、接続します。
ちなみに、グローバルIPを調べたい場合は、以下が便利です。

curl ifconfig.io

SSHのProxyCommandを利用してEC2インスタンスを自動で起動する設定

毎回、EC2インスタンスの起動・終了を実施するのは面倒です。
そこで、SSHのProxyCommandとSSMエージェントを利用し、sshコマンドを実行すると、自動でEC2が起動する様にします。
また、cron コマンドを利用して、一定時間操作がない場合は、自動で終了する様に設定します。

EC2インスタンスのIAM Roleの設定

  • AWSにて、あらかじめ用意されている「AmazonSSMManagedInstanceCore」を追加します。
    • これで、SSMが利用できる様になります。
    • 注:SSMのエージェントは、上で選択したAMIでは、既にインストール済みのため、特段することはないですが、もしSSMエージェントがない場合は、追加します。

IAMユーザのプロファイルのポリシー

接続にいくユーザの権限として、以下を付与します。
インスタンスの起動や終了、ssm周りの権限を付与しています。
とりあえず、グローバルIPで絞っています。ころころ変わって面倒な場合は、MFA認証を必須にするなどでも良いと思います。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "ec2:StartInstances",
            "Resource": [
                "arn:aws:ec2:ap-northeast-1:[AWS アカウントのID]:instance/[EC2インスタンスのID]",
            ],
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "[接続元のIPアドレス]"  #ここは必要に応じて
                }
            }
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": "ec2:DescribeInstances",
            "Resource": "*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "[接続元のIPアドレス]"
                }
            }
        },
        {
            "Sid": "VisualEditor2",
            "Effect": "Allow",
            "Action": "ec2:StopInstances",
            "Resource": [
                "arn:aws:ec2:ap-northeast-1:[AWSアカウントのID]:instance/[EC2インスタンスのID]",
            ]
        },
        {
            "Sid": "VisualEditor3",
            "Effect": "Allow",
            "Action": "ssm:StartSession",
            "Resource": [
                "arn:aws:ec2:ap-northeast-1:[AWSアカウントのID]:instance/[EC2インスタンスのID]",
                "arn:aws:ssm:ap-northeast-1::document/AWS-StartSSHSession"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssm:TerminateSession",
                "ssm:ResumeSession"
            ],
            "Resource": [
                "arn:aws:ssm:*:*:session/${aws:username}-*"
            ]
        }
    ]
}

SSH ProxyCommandの設定

インスタンス起動のスクリプトの準備

まずは、インスタンスを起動するためのスクリプトを用意します。
ここでは、IAMユーザのプロファイルを「blender」としています。ここは、前の章のポリシーが付いていれば何でも良いです。
渡されたIDのインスタンスが「stopped」状態であれば、起動する様にしています。
(10秒待っているのは、wait_until_running関数だけだと、初回の接続に失敗することがあったためです。)

import time
import boto3
import argparse

class Agent():
    def __init__(self,host):
        self._TARGET_INSTANCE_ID = host
        self._PROFILE_NAME = "blender"
        self._REGION_NAME = "ap-northeast-1"

        self._session = boto3.Session(profile_name=self._PROFILE_NAME)
        self._ec2 = self._session.resource("ec2",region_name=self._REGION_NAME)
        
    def start_instance(self):
        target_instance = self._ec2.Instance(self._TARGET_INSTANCE_ID)
        status = target_instance.state["Name"]

        if status == "running":
            return True
        elif status == "stopped":
            target_instance.start()
            target_instance.wait_until_running()
            time.sleep(10)
            target_instance.reload()
            return target_instance.state["Name"] == "running"
        else:
            return False

    def stop_instance(self):
        target_instance = self._ec2.Instance(self._TARGET_INSTANCE_ID)
        target_instance.stop()
        target_instance.wait_until_stopped()
        target_instance.reload()
        return target_instance.state["Name"] == "stopped"

def setup_arg_parser():
    parser = argparse.ArgumentParser(description="start ec2")
    parser.add_argument("--host",help="EC2 instance ID",required=True)
    return parser
        
def main():
    parser = setup_arg_parser()
    args = parser.parse_args()
    agent = Agent(host=args.host)
    
    if agent.start_instance():
        print("EC2 instance is running")
    else:
        agent.stop_instance()
        print("Failed to start the instance")

if __name__ == "__main__":
    main()

上のpythonスクリプトは、setup_blender.pyとして保存しました。

次に、bashコマンドで、ラップしておきます。
pythonの環境をvirtualenvで設定しているので、環境を呼び出してから、pythonファイルを実行しているだけです。

#!/bin/bash

HOST=$1
source [virtualenvを設定したパス]/bin/activate
python [pythonファイルを置いたパス]/setup_blender.py --host $HOST
.ssh/configの設定
HOST blender_ec2
	   HostName [EC2インスタンスのID]
           User centos
	   Port 22
	   IdentityFile ~/.ssh/[SSHのキー※インスタンス作成時に設定したもの]
	   ProxyCommand sh -c "[パス]/setup.sh %h;  aws ssm start-session --profile blender --region ap-northeast-1 --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"

blenderと言うIAMユーザのプロファイルを作っています。

上記を全て設定すると、以下を実行するだけで、EC2インスタンスの起動を自動で実施してくれます。

ssh blender_ec2

一定時間操作がない場合、cronによりEC2インスタンスを自動で終了する

以下の、シャットダウンスクリプトを、cronで、5分置きに実行する様にしました。

動きとしては、5分して、ローカルからのsshセッションが1本も無ければ・・・
まずは、フラグを立て(/tmpにファイルを作成し)ます。
再び5分して、フラグが立った状態で、なおsshセッションが1本も無ければインスタンスを落とします。
なので、少なくとも5分は待ってくれて、最長でも10分以内に落としてくれる動きを想定しています。

#/bin/bash

SESSION_COUNT=`w -h | grep localhost | wc -l`
THERE_IS_NO_SESSION=/tmp/there_is_no_session

if [ $SESSION_COUNT -gt 0 ] 
then 
 rm -rf $THERE_IS_NO_SESSION
elif [ -e $THERE_IS_NO_SESSION ]
then
 rm -rf $THERE_IS_NO_SESSION
 sudo shutdown -h now
else
 touch $THERE_IS_NO_SESSION
fi

これを、cronに設定します。

 */5 * * * * /usr/local/bin/shutdown.sh

(/usr/local/bin/にshutdown.shとして置いています。)

おわりに

以上で、mac bookからでも快適にblenderが利用できる様になりました。
(やはりMac bookの1kgを切る重さは捨てられません)

AWS SESを使って独自ドメインのメールアドレスの受信と転送設定を行う

はじめに

(システムのテストなどで)メールアドレスが大量に必要になった時に、AWS SESは1つの解になるかも知れません。

ドメインの取得

S3のバケットの取得

SESの設定からもバケットは作成することができますが、今回は予め作成しておきます。

  • S3のコンソールから、「バケットを作成」を押して、好きな名前をつけます。
  • SESのリージョンとS3のリージョンを一致させる必要はありません。
    • SESは、受信機能の制限によりオレゴンを選択していますが、S3は、東京リージョンを選択しました。
  • 作成したS3のバケットには、以下のバケットポリシーを設定しておきます。
    • 自分のアカウントのSESからのputObject操作を受けつけると言う意味です。
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowSESPuts-1660060428208",
            "Effect": "Allow",
            "Principal": {
                "Service": "ses.amazonaws.com"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::[ここにバケット名]/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceAccount": "[ここにアカウントの番号]"
                },
                "StringLike": {
                    "AWS:SourceArn": "arn:aws:ses:*"
                }
            }
        }
    ]
}

AWS SESの設定

(注意) メールを受信できるリージョンは、限られています。残念ながら22年8月12日現在では、東京リージョンは対象外でした。
詳細は、以下の「E メールの受信」の章をご確認ください。
docs.aws.amazon.com

リージョン選択

今回は、オレゴンで設定していきます。

  • 右上のリージョン選択で、米国西部(オレゴン)を選びます。
    • 左の「Configuration」メニューに「Email receiving」のメニューがあることを確認しておきます。

ドメインの検証

次にドメインの検証をします。

  • 左の「Configuration」メニューから「Verified identities」を選択します。
  • 「create identity」ボタンを押して、「Domain」を選択します。
  • ドメイン名は、ご自身で取得してRoute53に登録してあるものを選びます。
  • 画面下の「Create Identity」ボタンを押すと、CNAMEレコードが、3行ほどRoute53の該当ドメインに追加されます。
  • Identity statusが「Verified」になっていれば、無事に成功です。(Route53でドメインを取得していると、ここまで半自動なので失敗することは少ないはずです。)

受信ルールの設定

次に、受信ルールを設定します。

  • 左の「Configuration」メニューから「Email receiving」を選択します。
    • リージョンが非対応の場合は、表示されていません。
  • 「Create rule set」ボタンを押します。
  • 「Create rule」 ボタンを押します。
  • Rule nameは、なんでも良いので、自分にとってわかりやすい名前をつけて、「Next」ボタンを押します。
  • 「Add new recipient condition」ボタンを押して、利用したいメールアドレスを入力します。
  • 次ページでは、「Add new action」から「Deliver to S3 bucket」を選択します。
  • S3 bucketに上で、バケットポリシーを設定したバケットを選択します。(バケットポリシーが間違っているとここで警告を受けます。)
    • prefixは、お好みで。
  • 次ページで、最終確認をして、画面下部の「Create rule」ボタンを押してルールを作成します。

ルールを作った後は、activateします。

  • 先ほど作成したルールを選択して、「Set as active」ボタンを押します。

MXレコードの設定

  • Route53のMXレコードに、以下のリンクで指定されているメールサーバを指定します。
    • 今回は、オレゴン(us-west-2)リージョンで設定しているので、「10 inbound-smtp.us-west-2.amazonaws.com」を設定します。

docs.aws.amazon.com

(受信の場合、CUSTOM MAIL FROMは特に設定する必要はありません。送信の場合でも、MAIL FROMが気にならないのであれば、設定する必要はありません。)

確認

  • 最後に、上で作成したメールアドレスに試験メールを送って、S3へ無事書き込まれているかを確認します。
    • 上手く設定できていれば、指定したバケット(の指定していればprefix)配下に、ファイルが作成されているはずです。
    • (注)「AMAZON_SES_SETUP_NOTIFICATION」と言うファイルは、SESでバケットを指定した時に試験的に書き込んでいるファイルなので、違います。

転送設定

毎回、直接s3のファイルを読みに行くのは面倒なので、転送設定をしておきます。

Lambda関数の作成

  • SESの受信設定を行なったのと同じリージョンで、Lambda関数を作成します。
Lambda関数の設定ロール

最低限、以下の権限を付与したロールを作成します。

  • S3のオブジェクトの読み込み (今回、SESで受信したメールをS3へ書き込むので、読み出す権限が必要になる)
  • SESを用いた送信 (メールを転送するためです)

具体的には、以下の様に設定します。
S3のポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::[バケット名]/[プレフィックス]/*"
        }
    ]
}

SESのポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "ses:SendRawEmail",
                "ses:SendEmail"
            ],
            "Resource": "arn:aws:ses:us-west-2:[アカウントID]:identity/*"
        }
    ]
}

※ses:SendRawEmailの方は、使わなければはずしてください(以下のlambda関数では使っていません。)

Lambda関数の内容

ざっくり以下の内容です。
バケット名」「プレフィックス」「転送先のメールアドレス」となっている部分は、ご自身の内容に置き換えてください。
gmailへ転送してうまくいくことは確認しました。

lambda関数にライブラリを追加しなくて済む様に、デフォルトで入っているemailライブラリを使っています。

import json
import boto3
from botocore.exceptions import ClientError

from email import policy
from email.parser import BytesParser

def parse_eml(eml_data):
    try : 
        msg = BytesParser(policy=policy.default).parsebytes(eml_data)
        
        charset = msg.get_body(preferencelist=("plain","html"))["content-type"].params["charset"]

        # parse text body
        text_content,text_charset = None,None
        text_body = msg.get_body(preferencelist=("plain"))
        if text_body is not None:
            text_content = text_body.get_content()
            text_charset = text_body["content-type"].params["charset"]

        # parse html body
        html_content,html_charset = None,None
        html_body = msg.get_body(preferencelist=("html"))
        if html_body is not None:
            html_content = html_body.get_content()            
            html_charset = html_body["content-type"].params["charset"]
        
        
        return {
            "to" : msg["to"],
            "subject" : msg["subject"],
            "charset" : charset,
            "text_body" : {"Data":text_content,"Charset":text_charset} if text_body is not None else None,
            "html_body" : {"Data":html_content,"Charset":html_charset} if html_body is not None else None
        }
    # TODO : handle error
    except Exception as e:
        return None

def build_body(parsed_data):
    body = {
        "Text" : parsed_data["text_body"],
        "Html" : parsed_data["html_body"]
    }
    
    if parsed_data["text_body"] is None:
        del body["Text"]
    if parsed_data["html_body"] is None:
        del body["Html"]
        
    return body

def lambda_handler(event, context):
    # S3 params
    BUCKET_NAME = "バケット名"
    PREFIX = "プレフィックス"

    # SES params    
    RECIPIENT = "転送先のメールアドレス"
    
    ERROR_CODE =  {
        'statusCode' : 500,
        'body' : json.dumps('Internal Server Error')
    }
    
    try : 
        s3_client = boto3.client("s3")
        ses_client = boto3.client("ses")
        
        for item in event["Records"]:
            source = item["ses"]["mail"]["source"]
            message_id = item["ses"]["mail"]["messageId"]
            
            s3_key = f"{PREFIX}/{message_id}"
            s3_response = s3_client.get_object(
                Bucket = BUCKET_NAME,
                Key = s3_key
            )
            
            eml_data = s3_response["Body"].read()
            
            parsed_data = parse_eml(eml_data)

            ses_client.send_email(
                Source = parsed_data["to"],
                Destination = {
                    "ToAddresses":[RECIPIENT]
                },
                Message={
                    'Subject': {
                        'Data': f'Fw: {parsed_data["subject"]}',
                        'Charset': parsed_data["charset"]
                    },
                    'Body': build_body(parsed_data)
                }
            )
            
            break 
            
    # TODO : Handle Error
    except KeyError:
        return ERROR_CODE
    except ClientError:
        return ERROR_CODE
    else:
        return {
            'statusCode': 200,
            'body': json.dumps('OK')
        }

SESからのLambda関数の呼び出し

  • 上の方で設定したSESのルールのアクション(現状は、S3への書き込みだけ設定されているはず)に、Lambda関数の起動を追加します。
    • SESとLambda関数のリージョンがあっていないと「Invoke AWS Lambda function」時に、Lambda関数が表示されません。

送信先のメールアドレスの認証

自分のアドレスに転送するため、サンドボックスの設定は、解除しません。
そのため、転送先のアドレスをSESから認証しておく必要があります。

  • SESの画面で、create identityから送信先のEmailアドレスを登録しておいてください。

以上です。

Raspberry Pi4でpythonの環境を作ってOpenCVをインストールするまで

Raspberry Piの購入

MacBook(USB-C対応)で作業する前提で記載します。
この場合、必要な物品は以下の4点です。

(最近は、Macを買うと電源などでUSB-Cのケーブルが付いていることが多いので周りを見渡せばあるでしょう)
(Raspberry Piは、USBで結線すれば、給電およびsshのログインが可能です。)
ですので、Raspberry Pi用のケースや電源ケーブルは、お好みで大丈夫です。

Raspberry Piの本体は、amazonなどでも売っていますが、今回は正規代理店のKSY経由で、
Pi4のメモリが8Gbyte版を購入しました。

SDカードの選び方は、以下のブログが詳しいです。
blog.soracom.com

Raspberry Piのセットアップ

以下がスタートページで、ここからRaspberry Pi Imagerをダウンロードします。
www.raspberrypi.org

32bit OSが安定版ですが、8Gbyte版を買った身としては、32bitだとメモリが勿体無いことになるので、以下の64bit版をインストールします(今回は、GUIが要らないのでLiteを選択しました)。
www.raspberrypi.org

ダウンロードしたImagerを開き、「Use custom img」から上でダウンロードした64bit版のRaspberry Piを指定して書き込みます。

Raspberry PiMacの接続

基本設定

SSHでログインするために、Raspberry PiSSHサーバを有効にします。
先ほどRaspberry Pi OSを書き込んだSDカードが、/Volumes/boot配下にmountされているとすると、以下のコマンドで空のファイルを作成します。

touch /Volumes/boot/ssh

WiFiに接続するのであれば、WiFiの設定をして終わりです。
しかし、今回はMacからUSB-Cで有線接続するため、以下の設定(1)(2)を行います。

(1)/Volumes/boot直下のconfig.txtの最後に、以下を追記します。

dtoverlay=dwc2

(2)/Volumes/boot直下のcmdline.txt の途中(rootwaitとquietの間)に以下を追記します。

modules-load=dwc2,g_ether

パスワードログインの廃止と証明書ログイン

初期設定では、以下でコマンドの後(ユーザ名が「pi」、ホストがraspberrypi.local)、
パスワードとして、「raspberry」を入れるとログインできます。

ssh pi@raspberrypi.local

この状態だと、raspberrypiにパケットが届く人が全員ログインできてしまうので、証明書を使った認証に切り替えます。
(隣の席に悪戯っ子が座っていると、slコマンドを入れられてしまったりします)

(1)証明書を作成する
以下のコマンドで、コマンドを実行した直下に、秘密鍵(raspi01)と公開鍵(raspi01.pub)が作成されます。

ssh-keygen -t rsa -b 4096 -f raspi01

※fオプションを外すと、デフォルトの位置(/Users/ユーザ名/.ssh 配下)に作成してくれます。
※passphraseをなしにする場合は、2回enterキーを叩きます。

(2)秘密鍵を配置する
以下で、所定のディレクトリへ移動します。

mv raspi01 raspi01.pub ~/.ssh

※公開鍵を移動させなくても動きますが、無くすと面倒なので忘れない所に保存しておいた方が良いと思います。

秘密鍵の権限を自分だけが読み取れる様に書き換えます。

chmod 600 ~/.ssh/raspi01

(3)公開鍵を配置する
以下のコマンドで、Raspberry Pi側へ公開鍵を配置します。
(パスワードは、変えていなければ「raspberry」です。)

ssh-copy-id -i ~/.ssh/raspi01.pub pi@raspberrypi.local

(4)証明書でログインできることを確認する
以下でログインできれば、問題なく設定できています。

ssh -i ~/.ssh/raspi01 pi@raspberrypi.local

(5)パスワードのログインを無効化する。
(Raspberry Piにログインして)/etc/ssh/sshd_config に以下を追記します。
※ 既に、該当行がコメントされているはずなので、以下の様に書き換えます。

PasswordAuthentication no

普通に開くと、read onlyなのでsudoで実行します。

sudo vi /etc/ssh/sshd_config

その後、Raspberry Piを再起動します。

sudo reboot

(6)パスワードでログイン出来なくなっていることを確認する。

ssh pi@raspberrypi.local

だと、こんな感じに弾かれる様になりました。

pi@raspberrypi.local: Permission denied (publickey).

※外部に公開する時は、ちゃんとセキュリティを固めましょう。あくまで隣人対策です。

ssh configの設定による簡単ログイン

~/.ssh/config に以下を追記します。

HOST pi
           HostName raspberrypi.local
           User pi
	   IdentityFile ~/.ssh/raspi01

次回から、以下のコマンドでラズパイにsshでログイン出来る様になります。

ssh pi

Raspberry Piからの(Macを介した)インターネット接続

(Macを介してインターネットに接続します。)

システム環境設定 > 共有 > インターネット共有 を選び
「RNDIS/Ethernet Gadget」にチェックボックスを入れます。

Python環境の構築

Python環境の構築前に、ラズパイ自体をアップデートします。

sudo apt update
sudo apt upgrade -y
sudo reboot

pythonは、既に入っているはずなので、以下で、pipだけインストール/アップデートします。

sudo apt install python3-pip -y
pip3 install --upgrade pip

jupyterのインストール

pip3 install jupyter

ただ、このままだと、Macからラズパイ上で動いているJupyter-notebookに接続できません。
そこで、以下のコマンドで、configファイルを作成します。

jupyter notebook --generate-config

~/..jupyter/jupyter_notebook_config.py の以下の箇所で、リモートのIPを許可します。

## The IP address the notebook server will listen on.                                                                                                                     
#  Default: 'localhost'                                                                                                                                                   
c.NotebookApp.ip = '*'

後は、いつものコマンドでjupyterを起動します。

jupyter-notebook [jupyterで使うディレクトリ]

virtualenvの設定

virtualenvの環境を構築し、プロジェクトごとにpythonのライブラリの依存関係が混らない様にします。\

pip3 install virtualenv
(sudo reboot) # 必要に応じてリブート

※同じものを指しているので上はpipでも可 (whichコマンドで調べてください)

例えば、PoseNet用の環境だと、以下の様な感じです。(例は、「pose_net」名前で作っています)

virtualenv pose_net
source pose_net/bin/activate

※環境外に出る場合は、deactivateです。

OpenCVのインストール

OpenCVのインストールは、以下が詳しいです。
64bit版なので、こちらを参照します。
qengineering.eu
ドキュメントの前の方は、設定の確認になります。
当該のスクリプト(のDLと実行方法)は、「Installation script」の章にありますので、そちらを実施します。
(注意:当該スクリプトの実行には、1時間以上かかりました)

32bit版の場合は、以下です(こちらは、以前、別の32bit版のラズパイで試して動きました)。
qengineering.eu

python(のインタプリタ)を起動して、OpevCVがimportできれば大丈夫です。

>>> import cv2 

以上で、Macからラズパイ上のJupyterに接続して、画像解析ができる様になりました。

Raspberry Piの終了

SSHで接続した状態で以下のコマンドで終了
> sudo shutdown -h now

SSHの小技集(ログインからポートフォワード、Pythonを利用した自動化まで)

はじめに

MacBook Proとモバイル回線で作業をする様な場合に良く利用するSSHの使い方です。また、サーバー側は、awsの利用を想定しています。

設定編

モバイル回線なので、デフォルトの設定だと頻繁に接続が切れます。
そんな時は、~/.ssh/configに以下の設定を記載します。
これで10秒ごとにパケットを流して接続を維持しようとしてくれます。
(勿論、切られることもあるので、そんな時はscreenなりを活用します。)

ServerAliveInterval 10
TCPKeepAlive yes

基本

ログインしたいサーバが、インターネットに公開されている場合は、以下のように単発のコマンドでログインします。
(証明書での認証を想定しています。)

ssh -i ~/.ssh/<i>foo.pem</i> <i>user</i>@<i>hostname</i>

図にすると以下の様になります。

f:id:marmarossa:20191209003936p:plain
SSH_basic

踏み台の先にあるサーバにログインする

ログインして作業したいサーバが踏み台の先にある場合、一旦、踏み台にログインした後、さらに目的のサーバにログインするのは、面倒なことがあります。そういう場合は、以下の様に
~/.ssh/configファイルに記載しておくことで、自動化されます。
具体的には、ssh target_server とコマンドを打つだけで、自動でJUMP_SERVERを経由してtarget_serverに接続されます。

HOST [target_server]
           HostName [IP address or host name (1)]
           User [user name(1)]
           ProxyCommand ssh -W %h:%p JUMP_SERVER
           IdentityFile ~/.ssh/foo.pem

HOST JUMP_SERVER
           HostName [IP address or hostname (2)]
           User [user name(2)]
           IdentityFile ~/.ssh/bar.pem

※target_serverは、自分の覚えやすい名前に変更してください
※[IP address or host name(1)] は、target_serverのものです。
※[IP address or host name(2)]は、踏み台サーバ(jump server)のものです。
※JUMP_SERVERの所は、字面が一致していれば、なんでも良いです。

踏み台だけをパブリックサブネットに置いて、プライベートサブネット内のワーカーにログインするのは、良く利用するパターンなので、上記の設定で2度ログインする手間を省けます。

ポートフォワード

セキュリティを考慮して、データベース(RDS)やElasticSearchは、外部からは直接接続できない様にすることが普通です。勿論、前述の通り、踏み台を経由して、プライベートサブネット内のマシンにログインして、開発をすることも可能です。しかし、効率面を考慮すると、極力ローカルの慣れたエディタを使いたいケースが多いと思います。
そこでオススメなのが、ポートフォワードです。

例えば、以下の様に、データベースとローカルマシンの間にトンネルを作っておくと、データベースへのアクセスを透過的に扱えます。
これで、踏み台をトンネルして、データベースサーバの5439ポートをローカルの5439ポートへバインドします。
(5439は、Redshiftのデフォルトポートです。適宜バックエンドで動作しているDBのポートに置き換えてください)

ssh -N -i ~/.ssh/[踏み台へ接続するための秘密鍵] -L 5439:[データベースサーバのアドレス]:5439 [ユーザ名]@[踏み台のアドレス]

PythonからSSHで踏み台サーバへログインしてプライベート領域のDBへ接続

上記は、トンネリングはあくまで手動で実施することを想定していました。
ただ、全てをプログラムで制御したい場合も往々にして存在します。
特に、AWS lambdaと組み合わせると、ローカルPCのcronに対するクラウドのcronのごとく、一定間隔で様々な処理が実施できて大変便利です。

では、早速Pythonでのやり方を見ていきたいと思います。

前準備

  • 以下のコマンドで、sshtunnelをインストールします。
    • pip install sshtunnel
  • また、DB接続用のライブラリも好みのものをインストールします(例では、psycopg2)。

コードスニペット

上で出てきた踏み台サーバを経由してバックエンドのDBへアクセスするサンプルは以下の通りです。
必要に応じて、wait(スニペットでwaitのコメントを付けている部分)を入れて接続が整うのを待ってください。

from sshtunnel import SSHTunnelForwarder
import psycopg2 as psy2

server = SSHTunnelForwarder(
    (踏み台サーバのアドレス, 22),
    ssh_host_key=None,
    ssh_username=踏み台サーバのユーザ名,
    ssh_password=None,
    ssh_pkey=踏み台サーバのSSH秘密鍵,
    set_keepalive=10.0,
    remote_bind_address=(データベースのアドレス, 5439)
)
server.start()

con = psy2.connect(
    dbname = データベース名,
    user = データベースのユーザ名,
    password = パスワード,
    host = "localhost",
    port=server.local_bind_port,
    async_=True(非同期) or False(同期) 
)

while True : #wait
    state = con.poll()
    time.sleep(1)
    if state == psy2.extensions.POLL_OK:
	break

cur = con.cursor()
cur.execute(任意のSQL文)
con.close()

その他

  • ファイルのやりとりは、上で述べてきたsshコマンドをsftpコマンドに置き換えることで可能です。

AWS Lambdaでpandasを利用する(Lambda Layers編)

概要

AWSのlambda関数で、外部のライブラリを実行したい場合、昔は、デプロイパッケージに、利用するコードを全て含めてやる必要がありました。
ただ、pandasやscikit-learnなどの重量級のライブラリの場合、毎回アップロードするのは、きついものがあります。
例えば、pandas(依存関係のあるnumpyなども含む)だと、圧縮状態で35Mbyteほどあります。

そこで、AWS Lambda layersの出番です。
これを使うことで、外部ライブラリのアップロードは、1回で済む様になり、自分で書いた部分のみを更新していけば良いことになります。
また、他のlambda関数でも使い回し可能です。
docs.aws.amazon.com

手順

以下、pandasを例にした手順です。

  1. とりあえず、lambda関数の実行環境を確認します。
    • 19年2月現在は、以下の通りでした。
  2. lambdaと実行環境を合わせたEC2を作成します。
  3. EC2へログインして、調べておいたlambdaで実行されるバージョンのpython(今回は、3.7.2)をインストールします。
    • yum list | grep python3 で、出てくれば良いのですが、入っていないのでソースから入れます。
  4. python.orgからソースを取得します。
  5. 解凍して、解凍した先のフォルダへ移動します。
  6. 必要なライブラリをインストールします。
    • yum install gcc openssl-devel bzip2-devel libffi-devel
  7. コンパイルしてインストールします。
    • ./configure --enable-optimizations
    • sudo make altinstall
  8. pandasをインストールするディレクトリ「python/lib/python3.7/site-packages/」を作成します。
    • こうしておくと、以下の理由により、勝手にパスが解決されるので楽です。
      • Lambda Layersは、/opt 配下に、アップロードされたzipファイルを展開します。
      • lambdaは、実行時に、「/opt/python」or 「/opt/python/lib/python3.7/site-packages/」を参照します(他にも見るところはあります)。
  9. 利用したいライブラリ (この場合は、pandas)を上で作ったディレクトリへインストールします。
    • pip3.7 install pandas -t ./python/lib/python3.7/site-packages/
      • 「-t」オプションで、書き出し先を指定しています。
  10. zipで固めます。
  11. これをLambda layersとして、publishしておきます (が、上のAMIのawsコマンドは古いのでアップデートが必要です。)
    • aws lambda publish-layer-version --layer-name [レイヤー名] --zip-file fileb://./hoge.zip --compatible-runtimes python3.7 --region ap-northeast-1 --profile [あなたのプロファイル]
    • レイヤー名は、「hoge」にしています。
  12. 利用したいlambda関数と紐つけます。
    • aws lambda update-function-configuration --function-name [利用したいlambda関数名] --layers arn:aws:lambda:ap-northeast-1:[AWS ID]:layer:hoge:1
    • 「layers」オプションでは、ARNをフルパスで指定します。
    • 最後の数字は、lambda layersのバージョンです(publishするごとに、インクリメントされるので、適宜合わせます)。

以上で、lambdaでpandasを利用する準備が整いました。
最後に、pandasの使用例と実行結果を貼り付けておきます。

import sys;
import json;
import pandas as pd;

def lambda_handler(event,context):
    df = pd.DataFrame([[1,2,3],[4,5,6]]);
    print(df);
    
    return {
        "statusCode": 200,
        "body": json.dumps('Hello pandas!'),
        "version": sys.version,
        "path": sys.path
    };

f:id:marmarossa:20190211235403p:plain
lambda関数の実行結果

MacBook Air(or Pro)でGPUを外付けしChainerを動作させるまでの手順

はじめに

本記事は、大規模な学習はクラウドで、デバックはローカルで、(ついでに小規模な学習も)と言う人向けの記事です。
目的は、Mac(MacBook AirMacBook Pro)で深層学習を行う環境を整えることになります。

グラフィックカード、深層学習用のライブラリ(Chainer)の順で、準備をします。

グラフィックカードの準備

私は、ノート型のMacを使っているので、GPUは外付けにしました。
GPUは、深層学習用のライブラリが動作するNVIDIAのGTXシリーズの中から選びます。
最近、アップルが外付けGPUを発表しましたが、AMD Radeonなので、今回の目的には合いません。
External Graphics Development Kit - Apple Developer

既製品を利用する場合

GTX 1070が搭載されているので、今回の目的にうってつけだと思います。
www.gigabyte.com

自分で組み立てる場合

私が環境を構築した当時、上記の製品は売っていなかったため、パーツを買ってきて組み立てました。
この方法は、System Integrity Protection(SIP)を無効化する必要が出てきます。気にする人は止めておきましょう。

まず、選択するのは以下の3点です。

選ぶ時のポイント

パーツ選びのポイントを解説していきます。

まずは自分の目的に沿った性能が得られるか、を考えます。
私は、画像系の深層学習も手元でちょっと動かしてみたかったので、メモリが8GByteあることと、速度面(pascalアーキテクチャでそこそこ早いもの)と言う理由で、GTX 1070を選択しました。また、akitio thunder2で動作させたかったので、short modelを選んでいます。
具体的には以下の製品を購入しました。
http://www.kuroutoshikou.com/product/graphics_bord/nvidia/gf-gtx1070-e8gb_oc_short/

  • Thunderboltに対応したPCIeの外付けボックス(enclosures)

まずは、thunderboltの2系か3系かを、選択します。
自分の利用しているMacBook Air(もしくはpro)のインタフェースに適合するのが一番良いと思います。ただ、変換コネクタもあるので、そこまで気にする必要もないですね。
(ちなみにMacBookは、そもそもthunderboltに対応していません。)
次に、選択したグラフィックカードが箱に収容できるか、と言うのも大切なポイントです。
私は、自分のMacBook Airがthunderbolt2系だったので、akitio thunder2を選びました。ただし、akitio thunder2は、小さめなので収容できるグラボに制限が出てしまうのと、電源を別に購入しないといけないので、購入する場合は注意してください。

  • 電源

そもそも、PCIeの外付けボックスに、初めから搭載されている電源の出力で賄えるのであれば、購入する必要はありません。
購入する場合、当たり前ですが、グラフィックカードの消費電力を賄えるか、を考えて購入します。
GTX 1070であれば、NVIDIAの公式HPにgraphics card powerが150Wとあるので、以下の252Wの電源を購入しました。
http://www.mouser.jp/ProductDetail/Mean-Well/GST280A12-C6P/?qs=XfZQyRplo5S6bkFXOs4q7w==
また、購入するグラフィックカードの補助給電スロットに合うように、変換コネクタも購入します。
最近のグラボは性能が良いので、PCIeの6ピンが2つもしくは、8ピンになっていると思います。
私の購入したGTX 1070は、PCIeの8ピンでした。
上記の電源であれば、PCIeの6ピンで出力するので、6ピン8ピンの変換ケーブルを購入しました。

組み立て
  • 買ってきたPCIeの外付けボックス、グラフィックカード、電源をつなぎます。
  • System Integrity Protection(SIP)を解除します。
    • Macをシャットダウンします。
    • Macの起動時に「command + Rキー」を押して、リカバリモードに入ります。
    • ターミナルを起動して、以下のコマンドを打ち込むことで無効化できます。
      • csrutil disable
    • 元に戻す時は、以下のコマンドです。
      • csrutil enable
  • Macを再起動し、以下のautomate-eGPU.shを実行します。
  • MacとPCIeのボックスを、thunderboltケーブルで接続します。
  • Macを再起動します。

「このMacについて」から「システムレポート」を呼び出し、グラフィックスカード欄に購入したグラボが記載されていれば認識されています。
f:id:marmarossa:20170903004029j:plain

深層学習用ライブラリ(Chainer)のインストール

大雑把に言うと以下のコンポーネントが必要になります。

  • CUDA toolkit
    • NVIDIAGPUGPGPU目的で使うために必要です。
    • また、XcodeとCommand-Line Toolsも必要になります。
  • cuDNNのインストール
    • GPUを深層学習用に最適化して利用するために必要です。
  • CuPy
    • CUDA上で計算を行うNumPyサブセットです。
  • Chainer
    • 深層強化学習用のライブラリです。

CUDA toolkitのインストール

cuDNNのインストール

  • NVIDIAのサイトからダウンロードします。 (要:ユーザー登録)
  • DLしたファイルを解凍し、必要なファイルをコピーします。(コピー先は、自分がCUDA toolkitをインストールした先に読み換えてください。)
    • tar xzvf (落としたcuDNNの名前).tar (cudaというディレクトリが作成される)
    • cd cuda
    • sudo cp include/* /usr/local/cuda/include/
    • sudo cp lib/* /usr/local/cuda/lib/

環境変数の設定

(ここも、各自のCUDA toolkitの場所に読み換えてください)

  • 以下を、.bash_profileに書いておきます(以下はbashの例)。
    • CUDA_PATH=/usr/local/cuda
    • PATH=$PATH:/usr/local/cuda/bin
    • DYLD_LIBRARY_PATH=/usr/local/cuda/lib:$DYLD_LIBRARY_PATH

CuPyのインストール

  • 以下のサイトを参考に、インストールします。
  • 具体的には、以下のコマンドです。
    • sudo pip install -U setuptools
    • pip install cupy --no-cache-dir
  • 既に、cupyが入っている方は、一度uninstallしてください。

Chainerのインストール

効果測定

Chainerのサンプル(chainer/examples at master · chainer/chainer · GitHub)から、mnistを選んで比較してみました。
サンプルの中のtrain_mnist.pyの実行結果です。
当たり前ですが、最終的に得られる精度に差はありません。
ただ、学習に必要な時間は、CPUが1エポック50秒程度かかっているのに対し、GPUは、5秒程度と10倍ほど高速になっているのが確認できました。

以下がCPUを使った場合の結果です。
# unit: 1000
# Minibatch-size: 100
# epoch: 20

epoch main/accuracy validation/main/accuracy elapsed_time
1 0.942133 0.9699 42.2322
2 0.97755 0.9739 86.9796
3 0.984867 0.9794 131.715
4 0.987483 0.9823 176.681
5 0.990617 0.9802 222.018
6 0.992367 0.98 276.583
7 0.992017 0.9809 324.01
8 0.994883 0.9815 370.21
9 0.995067 0.9807 417.02
10 0.99425 0.984 464.117
11 0.996317 0.9784 511.821
12 0.9959 0.9813 560.13
13 0.996567 0.9839 608.949
14 0.996033 0.9824 659.155
15 0.996433 0.9829 709.139
16 0.997783 0.9819 759.985
17 0.996917 0.9826 811.511
18 0.997167 0.9804 863.433
19 0.9971 0.9753 916.173
20 0.996767 0.982 969.896

こちらがGPUを使った場合。
# unit: 1000
# Minibatch-size: 100
# epoch: 20

epoch main/accuracy validation/main/accuracy elapsed_time
1 0.943417 0.9706 7.06982
2 0.977766 0.971 12.0191
3 0.984982 0.9796 16.8749
4 0.987398 0.9782 21.8097
5 0.991315 0.9788 26.8118
6 0.991865 0.9819 31.9384
7 0.994015 0.9802 36.8659
8 0.992449 0.9815 41.8097
9 0.994632 0.9808 46.9009
10 0.995415 0.9803 51.9401
11 0.995549 0.983 57.0252
12 0.995315 0.9814 62.0933
13 0.997849 0.9811 67.0458
14 0.996182 0.9831 72.1063
15 0.996398 0.978 77.0816
16 0.996449 0.9836 81.9985
17 0.997199 0.9809 87.0473
18 0.996616 0.9826 91.9781
19 0.998866 0.9825 96.8835
20 0.996532 0.9808 101.705

強化学習事始め(Open AIのgymを使って手っ取り早く始める)

数式は、一切挟まない解説(と言うか主に実装)です。
強化学習の中でもQ-learningの一番単純な話です。

数式込みでちゃんと理解したい人は以下の記事をお勧めします。
強化学習とは?(What is Reinforcement Learning?)
また、最新の話題も知りたいよって人は以下の2nd editionが良いと思います。
http://webdocs.cs.ualberta.ca/~sutton/book/the-book.html

概要

強化学習とは、環境(Environment)との相互作用を通してエージェントを学習させる手法です。
ポイントは、プログラムの書き手が事細かにエージェントに指示を出さなくても、勝手に学習してくれる点です。

ざっくり書くと、エージェントの行動(action) に対して,状況(state)が変化し、報酬(reward)が支払われ、
エージェントは、試行錯誤の末、将来貰える報酬が最大になるように学習します。

例題(Open AIのFrozenLake)に基づく解説

今回題材として扱うのは、以下のFrozenLake-v0です。
gym.openai.com
環境(env)は、OpenAI側が引き取ってくれるので、エージェントの実装のみに注力できます。

FrozenLake-v0の問題設定

  • ペンギンがいて、碁盤の目状の氷の上からの脱出を目指します。
  • 碁盤の目は、縦4、横4マスです(state(s)は、16パターン)。
  • ペンギンは、上下左右へ1マス移動できます(=action(a)は、4パターン)。

エージェントの学習

  • Q(s,a)は、64パターン(16*4)になります。
    • Q(s,a)は、ある状態(s)の時にとった行動(a)により得られる(将来に渡る)報酬です。
  • ある状態(s)のもと、ある行動(a)をとることで、報酬が得られた場合は、次回もその行動を取りやすくするために、Q(s,a)は増加します。
  • 逆に、ある行動(a)をとった次の状態(s+1)で得られる報酬(Q(s+1,a))が、現在の状態(s)で得られる報酬(Q(s,a))よりも小さいのであれば、Q(s,a)を減じてその行動を慎むようにします。

実装 (ここを読んで頂くのが一番手っ取り早いかも)

# -*- coding: utf-8 -*-
#!/usr/bin/env python

import gym;
import numpy as np;
import matplotlib.pyplot as plt; #結果をグラフ描画しないなら不要

env = gym.make('FrozenLake-v0'); #環境の読み込み
"""
環境(Environment)は、以下の通り。

SFFF       (S: starting point, safe)
FHFH       (F: frozen surface, safe)
FFFH       (H: hole, fall to your doom)
HFFG       (G: goal, where the frisbee is located)
"""

#状態(state)は、16種、行動(action)は、4種 なので、16行4列
Q = np.zeros([env.observation_space.n,
              env.action_space.n]);

lr = 0.7; #学習率(=learning rate) (小さい(0.0)と遅いし、大きい(1.0)と局所解に陥りやすい)
disct= 0.99; #報酬のディスカウント(discount)

history = []; #グラフにプロットするためなので、グラフ描画しないなら不要

#state :s
#action : a
for i in range(2000): #2,000回ほど繰り返す。
    s = env.reset(); #環境を初期化し、初期状態を返却する (本問題では初期状態は、0)
    done = False;
    
    #学習
    for _ in range(300): # 1episode中の試行は、300回まで
        #選び方は、貪欲アルゴリズム
        #ランダム(np.random.randn)がエージェントの試行錯誤を表す。
        #学習が進むとrandomな行動をとることは慎むように(i+1)で割っている。
        a = np.argmax(Q[s,:] + np.random.randn(1,env.action_space.n)*(1.0/(i+1)));

        #試行が終了した場合は、done = True (goalにたどり着いた or 穴に落ちた)
        #ゴールできた場合は、reward = 1.0、その他は0.0
        s_1,reward,done,info = env.step(a); #今回、infoは利用しない

        #Qテーブルの更新 (ここが、学習の肝)
        Q[s,a] = (1-lr) * Q[s,a] + lr * (reward + disct * np.max(Q[s_1,:]));

        """
        以下のように展開した方が理解しやすい(かも知れない)
        Q[s,a] = Q[s,a] + lr * ( reward + disct * np.max(Q[s_1,:]) - Q[s,a]);

        成功していた場合は、rewardが1.0なので、次回もその行動を取りやすいくなるように、Q[s,a]は、加算される
        もし、とった行動(a)により、将来の報酬(Q[s_1,:])が減るようであれば、
        次回は、その行動は取りづらくなるように、Q[s,a]は減じられる
        (ただし、報酬が発生するようなら加算に転じることもある)
        """
 
        s = s_1;
        
        if done == True:
            break;
        
    history.append(reward); #ゴールできていれば1.0。その他は0.0

plt.plot(history);
plt.show();

結果

結果は、以下の通りです。
横軸は、試行回数(episodeの数)で、縦軸は、成功(1)or失敗(0)です。
はじめの方は、失敗ばかりですが、200回あたりからは、失敗はほぼなくなっていることがわかると思います。
また、700回付近で、スランプ(ちょっと失敗している)が見られますが、以降は安定しています。
f:id:marmarossa:20170326230638p:plain

ランダムな要素ありで学習しているので、得られるグラフは毎回異なります。