20200314のdockerに関する記事は17件です。

djangoとpostgresqlをDockerで接続しようとしたときに「django.db.utils.OperationalError: could not translate host name "db" to address: Name or service not known」というエラーが出る

エラー

Quickstart: Compose and Djangoを見ながらdockerでDjangoの開発環境を構築しようとして、docker-compose upしたときに以下のエラーが出た。

django.db.utils.OperationalError: could not translate host name "db" to address: Name or service not known

setting.py

setting.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'postgres',
        'USER': 'postgres',
        'PASSWORD': 'postgres',
        'HOST': 'db',
        'PORT': 5432,
    }
}

修正前のdokcer-compose.yml

Docker-compose.yml
version: '3'

services:
  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    depends_on:
      - db
  db:
    image: postgres

修正後のdocker-compose.yml

docker-compose.yml
version: '3'

services:
    web:
        build: .
        command: python3 manage.py runserver 0.0.0.0:8000
        volumes:
            - .:/code
        ports:
            - "8000:8000"
        depends_on:
            - db
    db:
        image: postgres
        ports: 
            - "5432"
        environment:
          - POSTGRES_DB=postgres
          - POSTGRES_USER=postgres
          - POSTGRES_PASSWORD=postgres

これで、エラーが解消しました。
Docker内でpostgresを使用するには、データベースユーザー、パスワード、db-nameなどの情報を構成する必要があります。これは、修正後のdokcer-compose.ymlにあるenvironmentにおいてコンテナの環境変数を設定することにより行うことができる。

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

CakePHP4 を docker-compose で動くようにする。

CakePHP4 もリリースされて時間が立ちました。
そろそろどんなものか触っていこうと思います。

でも、そのためには動かさないといけないので、まずは動かすところまでやってみます。

この記事でわかること

事前準備

僕の Mac に入っている Dockerの各種バージョンは以下です。

バージョン
docker Docker version 19.03.5, build 633a0ea
docker-compose docker-compose version 1.25.4, build 8d51620a

CakePHP プロジェクトの作成

ローカル環境に依存したくないで docker を使って CakePHP のプロジェクトも作ります。

CakePHP4 のプロジェクトを作るディレクトリに入れて、一時的なコンテナを立ち上げて入ります。
一時的なコンテナが独自なのは、最低限必要なライブラリを入れているためです。
( https://github.com/katsuhiko/docker-php-fpm-base/blob/master/Dockerfile )

php:7.4-fpm-buster で立ち上げて自身でライブラリ(zip, intlが最低限必要なライブラリかな!?)をインストールしてもOKです。

cd .
docker run --rm -it -v "$(pwd):/home/app" -w /home/app katsuhikonagashima/php-fpm-base:7.4-buster /bin/bash

ここからはコンテナ内での作業になります。

composer を取得して、CakePHP4プロジェクトを作ります。
curl はすでに入っているはずです。
composer.phar は開発中に使うことになるので、作成したプロジェクトにコピーして含めるようにしています。

curl -sS https://getcomposer.org/installer | php

php composer.phar create-project --prefer-dist cakephp/app:4.* cakephp-vue-study

cp composer.phar ./cakephp-vue-study/
exit

ここからは、作成したプロジェクト内での作業になるので移動します。

cd ./cakephp-vue-study/

docker-compose の準備

どこで docker 系のファイルを準備するかですが、
僕は ./docker/local/ 配下に置くのが好きです。
本番にデプロイするファイルとわけることが多いので local というワンクッションをおいてます。
本当は本番・ローカルでファイルを別けなくないんだけど、ややこしくなっちゃうんですよね。。。この点は勉強中です。

php-fpm の準備

./docker/local/php-fpm/Dockerfile を作成します。

zip, mysql, intl, gd を入れています。開発によって追加するライブラリがあると思うので、 php-fpm は Dockerfile を作ります。

FROM php:7.4-fpm-buster

RUN apt-get update

RUN apt-get install -y git libzip-dev zip unzip \
    && docker-php-ext-install zip

RUN docker-php-ext-install pdo_mysql

RUN apt-get install -y libicu-dev \
    && docker-php-ext-configure intl \
    && docker-php-ext-install intl

RUN apt-get install -y libfreetype6-dev libjpeg62-turbo-dev libpng-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install gd

nginx の準備

./docker/local/nginx/default.conf を作成します。

Cookbook ( https://book.cakephp.org/4/en/installation.html#nginx )の内容そのままに近いです。
fastcgi_pass のところの app は、 php-fpm のコンテナ名です。後ほど docker-compose.yml で指定します。

server {
    listen 80;
    listen [::]:80;
    server_name _;

    root  /var/www/html/webroot;
    index index.php;

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_intercept_errors on;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

MySQL の準備

./docker/local/mysql/etc-mysql.cnf を作成します。

マルチバイトの対応しているのみです。昨今絵文字(?とか)も保存したいからね。

[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci

[client]
default-character-set=utf8mb4

docker-compose.yml の作成

./docker-compose.yml を作成します。

networks の設定は趣味みたいな感じです。複数のアプリケーションでDBを共有したいっというときには役立つんですが、現時点では不要ですね。

container_name を指定すると docker exec 等でのコンテナ名指定が固定化されるので好んで指定しています。

php-fpm を app としている点が先程の ./docker/local/nginx/default.conf の記述に関連しているところです。
nginx は、追加でライブラリを入れることはないのでコンテナサイズ重視で alpine にしています。
mysql が 5.7 なのは、実業務では AWS Aurora を使うことが多いためです。

version: '3.5'

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

volumes:
  db-data:
    driver: local

services:
  web:
    image: nginx:1.16-alpine
    container_name: web
    ports:
      - 80:80
    volumes:
      - ./docker/local/nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./:/var/www/html
    networks:
      - frontend
      - backend
  app:
    build: ./docker/local/php-fpm
    container_name: app
    volumes:
      - ./:/var/www/html
    networks:
      - backend
  db:
    image: mysql:5.7
    container_name: db
    ports:
      - 3306:3306
    environment:
      - MYSQL_DATABASE=default
      - MYSQL_USER=default
      - MYSQL_PASSWORD=secret
      - MYSQL_ROOT_PASSWORD=root
    volumes:
      - ./docker/local/mysql/etc-mysql.cnf:/etc/mysql/conf.d/etc-mysql.cnf
      - db-data:/var/lib/mysql
    networks:
      - backend

DB 接続設定

./config/app_local.php のDB接続情報を変更します。
host, username, detabase の箇所を変更しています。

./config/app_local.php はソース管理のリポジトリには含めないファイルになるので ./config/app_local.example.php にも同じ変更を反映しておきましょう。

    'Datasources' => [
        'default' => [
            'host' => 'db',
            'username' => 'default',
            'password' => 'secret',
            'database' => 'default',
            'url' => env('DATABASE_URL', null),
        ],

        'test' => [
            'host' => 'db',
            'username' => 'default',
            'password' => 'secret',
            'database' => 'test_myapp',
        ],
    ],

docker-compose の実行

初回の起動は時間がかかりますが、2回目からの起動からはイメージがキャッシュされるので速いです。

docker-compose up -d

確認

http://localhost へ接続するといつもの CakePHP のトップページが表示されます。

これで開発の準備ができました。

補足) Github へアップする

git init
git add --all
git commit -m "create cakephp4 project."
git remote add origin https://github.com/katsuhiko/cakephp-vue-study.git
git push -u origin master

補足) 別のローカル環境で始めるとき

先にコンテナを起動してから、composer install してライブラリを取り込むのがポイントです。

git clone https://github.com/katsuhiko/cakephp-vue-study.git
cd ./cakephp-vue-study

cp ./config/.env.example ./config/.env
cp ./config/app_local.example.php ./config/app_local.php

docker-compose up -d
docker exec -it app php composer.phar install
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CakePHP4 を docker-compose で動くようにする

CakePHP4 もリリースされて時間が立ちました。
そろそろどんなものか触っていこうと思います。

でも、そのためには動かさないといけないので、まずは動かすところまでやってみます。

この記事でわかること

事前準備

僕の Mac に入っている Dockerの各種バージョンは以下です。

バージョン
docker Docker version 19.03.5, build 633a0ea
docker-compose docker-compose version 1.25.4, build 8d51620a
CakePHP4 4.0.4

CakePHP プロジェクトの作成

ローカル環境に依存したくないで docker を使って CakePHP のプロジェクトも作ります。

CakePHP4 のプロジェクトを作るディレクトリに入れて、一時的なコンテナを立ち上げて入ります。
一時的なコンテナが独自なのは、最低限必要なライブラリを入れているためです。
( https://github.com/katsuhiko/docker-php-fpm-base/blob/master/Dockerfile )

php:7.4-fpm-buster で立ち上げて自身でライブラリ(zip, intlが最低限必要なライブラリかな!?)をインストールしてもOKです。

※ Windows の場合、試してないですが PowerShell を使えば動くと思います(そのために "" で囲んでるんで)。動かない場合、 $(pwd) の箇所を ${pwd} にすると良いのでないかなっと思います。たぶん。

cd .
docker run --rm -it -v "$(pwd):/home/app" -w /home/app katsuhikonagashima/php-fpm-base:7.4-buster /bin/bash

ここからはコンテナ内での作業になります。

composer を取得して、CakePHP4プロジェクトを作ります。
curl はすでに入っているはずです。
composer.phar は開発中に使うことになるので、作成したプロジェクトにコピーして含めるようにしています。

curl -sS https://getcomposer.org/installer | php

php composer.phar create-project --prefer-dist cakephp/app:4.* cakephp-vue-study

cp composer.phar ./cakephp-vue-study/
exit

ここからはコンテナから出て、ホスト側で作成したプロジェクト内での作業になります。
まずは、プロジェクトのディレクトリへ移動します。

cd ./cakephp-vue-study/

docker-compose の準備

どこで docker 系のファイルを準備するかですが、僕は ./docker/local/ 配下に置くのが好きです。
本番へデプロイするファイルとわけることが多いので local というワンクッションをおいてます。
本当は本番・ローカルでファイルを別けたくないんだけど、ややこしくなっちゃうんですよね。。。この点は勉強中です。

php-fpm の準備

./docker/local/php-fpm/Dockerfile を作成します。

zip, mysql, intl, gd を入れています。開発によって追加するライブラリがあると思うので、 php-fpm は Dockerfile を作ります。
buster(Debian) にしているのは、 apt を使うことに慣れているからだけです。ライブラリを追加するときに手間取りたくないからね。

docker/local/php-fpm/Dockerfile
FROM php:7.4-fpm-buster

RUN apt-get update

RUN apt-get install -y git libzip-dev zip unzip \
    && docker-php-ext-install zip

RUN docker-php-ext-install pdo_mysql

RUN apt-get install -y libicu-dev \
    && docker-php-ext-configure intl \
    && docker-php-ext-install intl

RUN apt-get install -y libfreetype6-dev libjpeg62-turbo-dev libpng-dev \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install gd

nginx の準備

./docker/local/nginx/default.conf を作成します。

Cookbook ( https://book.cakephp.org/4/en/installation.html#nginx )の内容そのままに近いです。
fastcgi_pass のところの app は php-fpm コンテナのサービス名です。後ほど docker-compose.yml で指定します。

docker/local/nginx/default.conf
server {
    listen 80;
    listen [::]:80;
    server_name _;

    root  /var/www/html/webroot;
    index index.php;

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_intercept_errors on;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

MySQL の準備

./docker/local/mysql/etc-mysql.cnf を作成します。

マルチバイトの対応をしているのみです。昨今絵文字(?とか)も保存したいですからね。

docker/local/mysql/etc-mysql.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci

[client]
default-character-set=utf8mb4

docker-compose.yml の作成

./docker-compose.yml を作成します。

networks の設定は趣味みたいな感じです。複数のアプリケーションでDBを共有したいっというときには役立つんですが、現時点では不要ですね。

container_name を指定すると docker exec 等でのコンテナ名指定が固定化されるので好んで指定しています。

php-fpm を app としている点が先程の ./docker/local/nginx/default.conf の記述に関連しているところです。
nginx は、追加でライブラリを入れることはないのでコンテナサイズ重視で alpine にしています。
mysql が 5.7 なのは、実業務では AWS Aurora を使うことが多いためです。

docker-compose.yml
version: '3.5'

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

volumes:
  db-data:
    driver: local

services:
  web:
    image: nginx:1.16-alpine
    container_name: web
    ports:
      - 80:80
    volumes:
      - ./docker/local/nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./:/var/www/html
    networks:
      - frontend
      - backend
  app:
    build: ./docker/local/php-fpm
    container_name: app
    volumes:
      - ./:/var/www/html
    networks:
      - backend
  db:
    image: mysql:5.7
    container_name: db
    ports:
      - 3306:3306
    environment:
      - MYSQL_DATABASE=default
      - MYSQL_USER=default
      - MYSQL_PASSWORD=secret
      - MYSQL_ROOT_PASSWORD=root
    volumes:
      - ./docker/local/mysql/etc-mysql.cnf:/etc/mysql/conf.d/etc-mysql.cnf
      - db-data:/var/lib/mysql
    networks:
      - backend

DB 接続設定

./config/app_local.php のDB接続情報を変更します。
host, username, detabase の箇所を変更しています。
host にしている db は mysql コンテナのサービス名です。docker-compose.yml で記載しています。

./config/app_local.php はソース管理のリポジトリには含めないファイルになるので ./config/app_local.example.php にも同じ変更を反映しておきましょう。

config/app_local.php
    'Datasources' => [
        'default' => [
            'host' => 'db',
            'username' => 'default',
            'password' => 'secret',
            'database' => 'default',
            'url' => env('DATABASE_URL', null),
        ],

        'test' => [
            'host' => 'db',
            'username' => 'default',
            'password' => 'secret',
            'database' => 'test_myapp',
        ],
    ],

docker-compose の実行

初回の起動は時間がかかりますが、2回目からの起動からはイメージがキャッシュされるので速いです。

docker-compose up -d

動作確認

http://localhost へアクセスするといつもの CakePHP のトップページが表示されます。

Database のところがきちんと緑色になって接続できていることを確認してください。
これで開発の準備ができました。

補足) Github へアップする

git init
git add --all
git commit -m "create cakephp4 project."
git remote add origin https://github.com/katsuhiko/cakephp-vue-study.git
git push -u origin master

補足) 別のローカル環境で始めるとき

先にコンテナを起動してから、composer install してライブラリを取り込むのがポイントです。
コンテナ名を app 指定にしたから、 docker exec するときスッキリしてるんじゃないかなっと思います。

git clone https://github.com/katsuhiko/cakephp-vue-study.git
cd ./cakephp-vue-study

cp ./config/.env.example ./config/.env
cp ./config/app_local.example.php ./config/app_local.php

docker-compose up -d
docker exec -it app php composer.phar install
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

envoyのgRPC-JSON transcoderをローカルで試す

はじめに

HTTP/JSONからgRPCへのtranscodingを行うには大まかに次の2種類の方法があります。

  • gRPC gatewayを使用する。
  • gRPC-JSON transcoderを使用する。

前者はプラグインとして提供されていてHTTPリバースプロキシを作成することになります。これはgolangにしか対応されていません。

後者についてはgrpc-httpjson-transcodingというライブラリがありIstioGoogle cloud endpointで現在も使用されています。
envoy proxy単体でもgRPC serviceにHTTP/JSONインタフェースを適用することで実行可能です。

今回はenvoy, gRPCサーバをDockerコンテナとして立ててgRPC-JSON transcoderのテストを行います。HTTPリクエストはGET/POSTを試しています。

スクリーンショット 2020-03-14 19.18.34.png

コードはGitHubにあげてあります。

環境

macOS Mojave 10.14.1
docker 18.09.2
docker-compose 1.23.2

実装

ビルド前に用意しておくファイルは次のようになります。envoyについては公式のDockerイメージを使用するので自分でDockerfileは作成しません。

├── docker-compose.yaml
├── pb
│   └── helloworld.proto
├── server
│   ├── Dockerfile
│   └── main.go
└── envoy
    └── envoy.yaml


まずはdocker-compose.yamlです。こちらはgRPCサーバとenvoyを定義しています。
envoyとgRPCサーバの間はコンテナ間通信を行うのでlinksを設定します。

docker-compose.yaml
version: '3.7'
services:
  server:
    build: ./server
    image: grpc-server:latest
    container_name: grpc-server
    volumes:
      - './server:/go/src/server'
      - './pb:/go/src/pb'
    expose:
      - 50051
    command: 
      - /go/src/server/bin/server
  envoy:
    image: envoyproxy/envoy:v1.13.1
    volumes:
      - './envoy:/etc/envoy'
      - './pb:/etc/pb'
    expose:
      - 51051
    ports:
      - 51051:51051
    links:
      - server
volumes:
  data:
    driver: 'local'


次にprotoファイルです。GETとPOSTの2つ分定義しています。

pb/helloworld.proto
syntax = "proto3";

package helloworld;
import "google/api/annotations.proto";

service Greeter {
  rpc GetTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      get: "/hello"
    };
  }
  rpc PostTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      post: "/hello"
      body: "*"
    };
  }
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}


次にgRPCサーバのDockerfileです。alpineをベースイメージにして必要となるものをインストールしています。

server/Dockerfile
FROM alpine:3.8

RUN apk add --no-cache curl git go unzip musl-dev libc6-compat

ENV PROTOBUF_VERSION 3.11.4

RUN curl -sL https://github.com/google/protobuf/releases/download/v${PROTOBUF_VERSION}/protoc-${PROTOBUF_VERSION}-linux-x86_64.zip -o /tmp/protoc.zip && \
    unzip /tmp/protoc.zip -d /tmp && \
    cp -r /tmp/bin /tmp/include /usr/local/ && \
    rm -rf /tmp/*

ENV GOPATH /go
ENV PATH $PATH:$GOPATH/bin:/usr/local/go/bin

RUN mkdir /go && \
    go get -u google.golang.org/grpc && \
    go get -u github.com/golang/protobuf/protoc-gen-go && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger


main.goではprotoファイルに書かれた内容を元にGETとPOSTの関数を定義します。
GETのときにはHello World、POSTのときにはHello <NAME>となるようにしました。
実行には次章で後述するpb.goファイルが必要となります。

server/main.go
package main

import (
    "log"
    "net"

    pb "pb"
    "golang.org/x/net/context"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

const (
    port = ":50051"
)

type server struct{}

func (s *server) GetTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
    return &pb.HelloResponse{Message: "Hello World"}, nil
}

func (s *server) PostTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
    return &pb.HelloResponse{Message: "Hello " + in.Name}, nil
}

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})

    reflection.Register(s)
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}


最後にenvoyの設定ファイルenvoy.yamlです。
コンテナ間通信のため、host名(address)は宛先のコンテナ名であるgrpc-serverを指定しています。

envoy/envoy.yaml
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener1
    address:
      socket_address: { address: 0.0.0.0, port_value: 51051 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
          stat_prefix: grpc_json
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: grpc, timeout: { seconds: 60 } }
          http_filters:
          - name: envoy.grpc_json_transcoder
            config:
              proto_descriptor: "/etc/pb/helloworld.pb"
              services: ["helloworld.Greeter"]
              print_options:
                add_whitespace: true
                always_print_primitive_fields: true
                always_print_enums_as_ints: false
                preserve_proto_field_names: false
          - name: envoy.router

  clusters:
  - name: grpc
    connect_timeout: 1.25s
    type: logical_dns
    lb_policy: round_robin
    dns_lookup_family: V4_ONLY
    http2_protocol_options: {}
    load_assignment:
      cluster_name: grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: grpc-server
                port_value: 50051

サーバ設定

envoyとgRPCサーバを起動する前にpbファイル作成とgolangファイルビルドを行います。

最終的なフォルダ構成は次のようになります。(*)が付いたファイルが新たに作成されるものです。

├── docker-compose.yaml
├── pb
│   ├── helloworld.proto
│   ├── helloworld.pb (*)
│   └── helloworld.pb.go (*)
├── server
│   ├── Dockerfile
│   ├── main.go
│   └── bin
│       └── server (*)
└── envoy
    └── envoy.yaml


まずはgRPCサーバをコンテナとして起動します。

$ docker-compose run --rm server /bin/sh


protoファイルからpbファイルを作成します。これはenvoyで使用されるバイナリファイルです。

# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   -I /go/src/pb/  \
   --include_imports  \
   --include_source_info \
   --descriptor_set_out=/go/src/pb/helloworld.pb \
   helloworld.proto


protoファイルからpb.goファイルを作成します。これはgRPCサーバで使用されます。

# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   --proto_path /go/src/pb \
   --go_out=plugins=grpc:/go/src/pb \
   helloworld.proto


次にgo buildを行いサーバ起動用のバイナリファイルを作成します。

# cd /go/src/server &&  go build -i -v -o bin/server


exitします。コンテナ起動時のオプションに--rmをつけていたためコンテナは自動で削除されます。

# exit

サーバ起動

docker-composeでenvoyとgRPCサーバ両方を起動します。

$ docker-compose up -d

確認

まずはGETリクエストをenvoyに対して送ります。Hello Worldがレスポンスとして返ってくることが確認できます。

$ curl  http://localhost:51051/hello  -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> GET /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 8
< grpc-status: 0
< grpc-message: 
< content-length: 30
< date: Sat, 14 Mar 2020 07:31:18 GMT
< server: envoy
< 
{
 "message": "Hello World"
}
* Connection #0 to host localhost left intact


次にPOSTリクエストを送ります。リクエストボディに含めた{"name":"samskeyti88"}がレスポンスに反映されることが確認できます。

$ curl -X POST http://localhost:51051/hello -d '{"name":"samskeyti88"}' -v
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> POST /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 22
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 22 out of 22 bytes
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 0
< grpc-status: 0
< grpc-message: 
< content-length: 36
< date: Sat, 14 Mar 2020 07:50:14 GMT
< server: envoy
< 
{
 "message": "Hello samskeyti88"
}
* Connection #0 to host localhost left intact

おわりに

envoyのgRPC-JSON transcoderを使用してHTTPリクエストをgRPCに変換できることをDockerコンテナを用いて確認しました。
自分でこの変換を実装するよりははるかに楽にできることが分かります。

参考

gRPC-JSON transcoder
How to build a REST API with gRPC and get the best of two worlds
JimmyCYJ/grpc-transcoding-experiment

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

envoyのgRPC-JSON transcoderをローカルでテストする

はじめに

HTTP/JSONからgRPCへのtranscodingを行うには大まかに次の2種類の方法があります。

  • gRPC gatewayを使用する。
  • gRPC-JSON transcoderを使用する。

前者はプラグインとして提供されていてHTTPリバースプロキシを作成することになります。これはgolangにしか対応されていません。

後者についてはgrpc-httpjson-transcodingというライブラリがありIstioGoogle cloud endpointで現在も使用されています。
envoy proxy単体でもgRPC serviceにHTTP/JSONインタフェースを適用することで実行可能です。

今回はenvoy, gRPCサーバをDockerコンテナとして立ててgRPC-JSON transcoderのテストを行います。HTTPリクエストはGET/POSTを試しています。

スクリーンショット 2020-03-14 19.18.34.png

コードはGitHubにあげてあります。

環境

macOS Mojave 10.14.1
docker 18.09.2
docker-compose 1.23.2

実装

ビルド前に用意しておくファイルは次のようになります。envoyについては公式のDockerイメージを使用するので自分でDockerfileは作成しません。

├── docker-compose.yaml
├── pb
│   └── helloworld.proto
├── server
│   ├── Dockerfile
│   └── main.go
└── envoy
    └── envoy.yaml


まずはdocker-compose.yamlです。こちらはgRPCサーバとenvoyを定義しています。
envoyとgRPCサーバの間はコンテナ間通信を行うのでlinksを設定します。

docker-compose.yaml
version: '3.7'
services:
  server:
    build: ./server
    image: grpc-server:latest
    container_name: grpc-server
    volumes:
      - './server:/go/src/server'
      - './pb:/go/src/pb'
    expose:
      - 50051
    command: 
      - /go/src/server/bin/server
  envoy:
    image: envoyproxy/envoy:v1.13.1
    volumes:
      - './envoy:/etc/envoy'
      - './pb:/etc/pb'
    expose:
      - 51051
    ports:
      - 51051:51051
    links:
      - server
volumes:
  data:
    driver: 'local'


次にprotoファイルです。GETとPOSTの2つ分定義しています。

pb/helloworld.proto
syntax = "proto3";

package helloworld;
import "google/api/annotations.proto";

service Greeter {
  rpc GetTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      get: "/hello"
    };
  }
  rpc PostTest(HelloRequest) returns (HelloResponse) {
    option (google.api.http) = {
      post: "/hello"
      body: "*"
    };
  }
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}


次にgRPCサーバのDockerfileです。alpineをベースイメージにして必要となるものをインストールしています。

server/Dockerfile
FROM alpine:3.8

RUN apk add --no-cache curl git go unzip musl-dev libc6-compat

ENV PROTOBUF_VERSION 3.11.4

RUN curl -sL https://github.com/google/protobuf/releases/download/v${PROTOBUF_VERSION}/protoc-${PROTOBUF_VERSION}-linux-x86_64.zip -o /tmp/protoc.zip && \
    unzip /tmp/protoc.zip -d /tmp && \
    cp -r /tmp/bin /tmp/include /usr/local/ && \
    rm -rf /tmp/*

ENV GOPATH /go
ENV PATH $PATH:$GOPATH/bin:/usr/local/go/bin

RUN mkdir /go && \
    go get -u google.golang.org/grpc && \
    go get -u github.com/golang/protobuf/protoc-gen-go && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway && \
    go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger


main.goではprotoファイルに書かれた内容を元にGETとPOSTの関数を定義します。
GETのときにはHello World、POSTのときにはHello <NAME>となるようにしました。
実行には次章で後述するpb.goファイルが必要となります。

server/main.go
package main

import (
    "log"
    "net"

    pb "pb"
    "golang.org/x/net/context"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

const (
    port = ":50051"
)

type server struct{}

func (s *server) GetTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
    return &pb.HelloResponse{Message: "Hello World"}, nil
}

func (s *server) PostTest(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
    return &pb.HelloResponse{Message: "Hello " + in.Name}, nil
}

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterGreeterServer(s, &server{})

    reflection.Register(s)
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}


最後にenvoyの設定ファイルenvoy.yamlです。
コンテナ間通信のため、host名(address)は宛先のコンテナ名であるgrpc-serverを指定しています。

envoy/envoy.yaml
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener1
    address:
      socket_address: { address: 0.0.0.0, port_value: 51051 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
          stat_prefix: grpc_json
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: grpc, timeout: { seconds: 60 } }
          http_filters:
          - name: envoy.grpc_json_transcoder
            config:
              proto_descriptor: "/etc/pb/helloworld.pb"
              services: ["helloworld.Greeter"]
              print_options:
                add_whitespace: true
                always_print_primitive_fields: true
                always_print_enums_as_ints: false
                preserve_proto_field_names: false
          - name: envoy.router

  clusters:
  - name: grpc
    connect_timeout: 1.25s
    type: logical_dns
    lb_policy: round_robin
    dns_lookup_family: V4_ONLY
    http2_protocol_options: {}
    load_assignment:
      cluster_name: grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: grpc-server
                port_value: 50051

サーバ設定

envoyとgRPCサーバを起動する前にpbファイル作成とgolangファイルビルドを行います。

最終的なフォルダ構成は次のようになります。(*)が付いたファイルが新たに作成されるものです。

├── docker-compose.yaml
├── pb
│   ├── helloworld.proto
│   ├── helloworld.pb (*)
│   └── helloworld.pb.go (*)
├── server
│   ├── Dockerfile
│   ├── main.go
│   └── bin
│       └── server (*)
└── envoy
    └── envoy.yaml


まずはgRPCサーバをコンテナとして起動します。

$ docker-compose run --rm server /bin/sh


protoファイルからpbファイルを作成します。これはenvoyで使用されるバイナリファイルです。

# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   -I /go/src/pb/  \
   --include_imports  \
   --include_source_info \
   --descriptor_set_out=/go/src/pb/helloworld.pb \
   helloworld.proto


protoファイルからpb.goファイルを作成します。これはgRPCサーバで使用されます。

# protoc \
   -I $GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
   --proto_path /go/src/pb \
   --go_out=plugins=grpc:/go/src/pb \
   helloworld.proto


次にgo buildを行いサーバ起動用のバイナリファイルを作成します。

# cd /go/src/server &&  go build -i -v -o bin/server


exitします。コンテナ起動時のオプションに--rmをつけていたためコンテナは自動で削除されます。

# exit

サーバ起動

docker-composeでenvoyとgRPCサーバ両方を起動します。

$ docker-compose up -d

テスト

まずはGETリクエストをenvoyに対して送ります。Hello Worldがレスポンスとして返ってくることが確認できます。

$ curl  http://localhost:51051/hello  -v
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> GET /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 8
< grpc-status: 0
< grpc-message: 
< content-length: 30
< date: Sat, 14 Mar 2020 07:31:18 GMT
< server: envoy
< 
{
 "message": "Hello World"
}
* Connection #0 to host localhost left intact


次にPOSTリクエストを送ります。リクエストボディに含めた{"name":"samskeyti88"}がレスポンスに反映されることが確認できます。

$ curl -X POST http://localhost:51051/hello -d '{"name":"samskeyti88"}' -v
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 51051 (#0)
> POST /hello HTTP/1.1
> Host: localhost:51051
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 22
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 22 out of 22 bytes
< HTTP/1.1 200 OK
< content-type: application/json
< x-envoy-upstream-service-time: 0
< grpc-status: 0
< grpc-message: 
< content-length: 36
< date: Sat, 14 Mar 2020 07:50:14 GMT
< server: envoy
< 
{
 "message": "Hello samskeyti88"
}
* Connection #0 to host localhost left intact

おわりに

envoyのgRPC-JSON transcoderを使用してHTTPリクエストをgRPCに変換できることをDockerコンテナを用いて確認しました。
自分でこの変換を実装するよりははるかに楽にできることが分かります。

参考

gRPC-JSON transcoder
How to build a REST API with gRPC and get the best of two worlds
JimmyCYJ/grpc-transcoding-experiment

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

DockerHub Dockerイメージのタグ一覧を取得する

Dockerイメージの検索は docker search コマンドが用意されているがイメージのタグ情報は検索できないようです...(困惑)

タグを取得するAPIは提供されていたのでそれを使ってDockerイメージのタグ一覧を取得します。

環境

  • Mac

Dockerイメージの検索

$ docker search ubuntu
NAME                                                      DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
ubuntu                                                    Ubuntu is a Debian-based Linux operating sys…   10614               [OK]
dorowu/ubuntu-desktop-lxde-vnc                            Docker image to provide HTML5 VNC interface …   404                                     [OK]
rastasheep/ubuntu-sshd                                    Dockerized SSH service, built on top of offi…   243                                     [OK]
consol/ubuntu-xfce-vnc                                    Ubuntu container with "headless" VNC session…   211                                     [OK]
ubuntu-upstart                                            Upstart is an event-based replacement for th…   106                 [OK]

...

前提条件

  • curl, jq コマンドが必要です。
$ brew install jq

イメージのタグを取得する

ubuntu イメージのタグは下記のAPIから取得できます。

イメージ名のところを引数にしてコマンドを作れば良さそうです。

.bash_profile
function docker-tags {
  curl -s https://registry.hub.docker.com/v1/repositories/$1/tags | jq -r '.[].name'
}

自作した docker-tags コマンドを実行してみる。

$ docker-tags ubuntu
latest
10.04
12.04
12.04.5
12.10
13.04
13.10
14.04
14.04.1
14.04.2
14.04.3
14.04.4
14.04.5

...

良い感じです☺️

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

【Laradock環境構築】Dockerインストール〜Laravelデフォルトページ表示まで②

はじめに

こちらの記事では、Laradockの環境構築として、
Dockerのインストール〜Laravelのデフォルトページ表示までを載せていきます。
※本記事はパート①の続きになります。

【Laradock環境構築】Dockerインストール〜Laravelデフォルトページ表示まで①
https://qiita.com/y-aimi/items/4fc3c65f85a103685238

前提条件

パート①でDockerのインストールが終わっている状態

プロジェクト用のディレクトリを作成

開発するアプリケーション用のディレクトリを作成します。

本記事ではecsiteとします。

以下のコマンドを実行。

//mkdirでディレクトリを作成。cdでecsiteのディレクトリに移動
$ mkdir ecsite
$ cd ecsite

ちなみに、どこでこのコマンドを実行すればいいの?という方は、

// ~ はルートディレクトリを表している
$ cd ~

でルートディレクトに移動すれば、下記の場所にecsiteというフォルダができるので
そちらで問題ないかと思います。

image.png

リポジトリのクローンを作成

LaradockのGithubのリポジトリをクローンします。
クローンしたらlaradockディレクトリに移動します。

以下のコマンドを実行。

$ git clone https://github.com/Laradock/laradock.git
$ cd laradock

試しに、
https://github.com/Laradock/laradock.git
をクリックしてGithubを見てみてください。

ここのクローンを先程作ったecsiteのディレクトリに作っているよー
ということですね!
そして、そのディレクトリに移動したということです。

Laradockの設定ファイル準備

Laradockのenv-example.envとしてコピーします。

/ecsite/laradock/で以下のコマンドを実行してください。

$ cp env-example .env

(cp はコピーという意味です。まんまですね。)

以下のコマンドを実行し、/ecsite/laradock/直下に.envファイルがあることを確認。

$ ls

次に作成した.envファイルをVimを使って編集していきます。
(Vimを使えば、いちいちフォルダを探して、ファイルを探して開いて編集・・・
をしなくてもターミナル上で編集できるのでラクです!)

/ecsite/laradock/で以下のコマンドを実行。

$ vi .env

するとターミナルでこんな画面がでます。

image.png

この画面は/ecsite/laradock/.envの編集画面です。

この画面上で i を押します。

image.png

下部に-- INSERT --と編集できるモードに切り替わるので、
この状態でファイルを編集していきます。

/ecsite/laradock/.envを以下のように編集してください。

### Paths #################################################

# Point to the path of your applications code on your host

# 次の行を編集
APP_CODE_PATH_HOST=../ecsite

# Point to where the `APP_CODE_PATH_HOST` should be in the container
APP_CODE_PATH_CONTAINER=/var/www

APP_CODE_PATH_HOSTの値が、
Laradockで動かすウェブアプリのディレクトリのパスになります。
(つまり、Laradockで動かす時にどのフォルダを使うの?それの場所をここに書いてね。ということです。)

次にescキーを押してコマンドモードに移行し、:wqを押し、
enterキーでファイルへの変更を保存し終了。
(":w"は保存、"q"は終了のVimコマンド→合わせて":wq"コマンドというわけですね)

次にウェブアプリのディレクトリを作っておきます。

以下のコマンドを実行。

$ mkdir ../ecsite

これで、現在のディレクトリは下記のようになっているかと思います。

ecsite
├── ecsite ←今作ったウェブアプリ用のディレクトリ
└── laradock ←序盤にクローンしたlaradock.gitのクローン

mysqlの認証方法の設定

mysqlのバージョンによっては、認証方法がデフォルトでは
なくなってしまっていることがあるので、
※変更の必要ない人は必要ないです。
(ちなみに私も変更しなくても良かったです。)

/ecsite/laradock/ディレクトリ内で以下のコマンドを実行し、vimを起動。

$ vi mysql/my.cnf

先程と同じように、iを押してINSERTモードに切り替え、
/ecsite/laradock/mysql/my.cnfを以下のように編集。

# The MySQL  Client configuration file.
# 
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html

[mysql]

[mysqld]
sql-mode="STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
character-set-server=utf8

# この行を追加(この文が元々ある人もいるかも?)
default_authentication_plugin=mysql_native_password

escキーを押してコマンドモードに移行し、
:wqを入力しenterキーでファイルの変更を保存し終了。

これでmysqlの準備は終了です。

Laradockのコンテナ起動

Laradockを使ってあらかじめ用意されている、
ウェブサーバー(nginx)とデータベースサーバー(mysql)のコンテナを起動します。

/ecsite/laradock/ディレクトリ内で以下のコマンドを実行します。

$ docker-compose up -d nginx mysql

そして、下記のように全部"done"になれば無事起動されています。

image.png

これで、ローカルのウェブサーバーとデータベースが起動しました!

workspaceコンテナのパッケージアップデート

つぎにworkspaceコンテナに入り、パッケージのアップデートをしておきます。

workspaceコンテナは開発に必要なツール等がインストールされているコンテナらしいです。
(Laradockのコンテナを起動すると自動的にworkspaceコンテナも起動します)

以下のコマンドでパッケージをアップデート。

$ docker exec -it laradock_workspace_1 bash
root@hoge:/var/www# apt-get update

(root@hogeの"hoge"は個人で違うかと思います。)

Laravelのセットアップ

最後にlaravelのセットアップをします。
workspaceコンテナに入ったまま、Laravelをインストール。

//こちらを参考にプロジェクト名を入れて実行
root@hoge:/var/www# composer create-project laravel/laravel [プロジェクト名]

//実例①
root@hoge:/var/www# composer create-project laravel/laravel ECsite

//実例②:laravelのバージョンを指定する際はこちらを参考に
root@hoge:/var/www# composer create-project laravel/laravel ECsite "5.8.*"

動作確認

下記のURLでLaravelのデフォルトページが表示されれば環境構築成功です!

http://localhost/

image.png

エラー多発した!!

この環境構築、個人個人によって色んなエラーが出ることが多いです。
(エラーで苦戦して、やっぱLaradock使うのやめよう...と思った人もいるはず)

ちなみに、個人的にあるあるなのが、
Nginx 404not found
でデフォルトページが表示されないエラー。

image.png

おそらく原因は、/ecsite/laradock/.envで編集したこちらが正しくない可能性が高いです。
例えば、ディレクトリの構成を変えていたり、置く場所を変えていたりすると、
ここも変えなければいけません。

重要!

あ、エラーになってしまった!

よし、これを修正したら大丈夫なはず!!

...え、また同じエラー??

...え、しかも何かエラー増えてる?

.........わからん。

............オワタ。詰んだ。

となった方いませんか?
僕がまさにこの状態になって抜け出せなかったので、対処法を!!

以下のコマンドで、コンテナを再起動!!

$ docker-compose stop
$ docker-compose up -d nginx mysql

そしたら、エラー解消しました。
エラー箇所を正しく修正したとしても、再起動しないとエラーのままだったりするので、
困ったら再起動してみましょう。
それでもエラーが出るなら、何か修正点があるかと思いますので、
ボス戦の攻略法を探すような感じで、ググりましょう!

まとめ

環境構築って一番の壁だと思います。

わからないことだらけですし、これができないとその後の開発が何もできないし...

でも、個人的にはこの環境構築に苦戦して乗り越えると、
ただ開発するだけでは得られない知識が得られるので、
良い経験ができているなーと感じてます。

開発中のエラーがボス戦だとしたら、
環境構築のエラーはシークレットボス戦みたいな感じですかね!笑

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

コーディング未経験のPO/PdMのためのRails on Dockerハンズオン vol.11 - Test coding -

はじめに

第11回目ですね。
前回はテストを自動化するためにRSpecSeleniumCapybaraなどを導入しましたね。

今日は今まで作ってきたアプリケーションに対してテストコードをコーディングしていきます。
本当はアプリをコーディングする前にテストをコーディングしてRedのフェーズにするべきなのですが、
まぁ最初なのでご愛嬌ということでGreenの状態から始めましょう。

前回のソースコード

前回のソースコードはこちらに格納してます。今回のハンズオンからやりたい場合はこちらからダウンロードしてください。

どういうふうにテストコード書いてくの?

ここは人によってやりやすいようにでいいと思うのですが、このハンズオンでは基本的には作りたい機能(ユーザーストーリー)ごとにテストファイルを分けて記述していきます。
例えば、今までだと「サインアップ」とか「サインイン」とかそういうやつです。

例えば以下のようにテストシナリオを考えてみます。

1. ユーザーとして、ページにダイレクトアクセスしたい

  1. 未サインインのユーザーが、トップページにダイレクトアクセスしたとき、トップページが表示されること
  2. 未サインインのユーザーが、サインアップページにダイレクトアクセスしたとき、サインアップページが表示されること
  3. 未サインインのユーザーが、サインインページにダイレクトアクセスしたとき、サインインページが表示されること
  4. 未サインインのユーザーが、ユーザー詳細ページにダイレクトアクセスしたとき、ユーザー詳細ページが表示されること
  5. サインイン済のユーザーが、トップページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること
  6. サインイン済のユーザーが、サインアップページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること
  7. サインイン済のユーザーが、サインインページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること
  8. サインイン済のユーザーが、ユーザー詳細ページにダイレクトアクセスしたとき、ユーザー詳細ページが表示されること

2. ユーザーとして、ヘッダーリンクからページ遷移できること

  1. 未サインインのユーザーが、トップページでヘッダーのロゴをクリックしたとき、トップページに遷移すること
  2. 未サインインのユーザーが、トップページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること
  3. 未サインインのユーザーが、トップページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること
  4. 未サインインのユーザーは、トップページでヘッダーに「Profile」リンクが存在しないこと
  5. 未サインインのユーザーは、トップページでヘッダーに「Sign out」リンクが存在しないこと
  6. 未サインインのユーザーが、サインアップページでヘッダーのロゴをクリックしたとき、トップページに遷移すること
  7. 未サインインのユーザーが、サインアップページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること
  8. 未サインインのユーザーが、サインアップページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること
  9. 未サインインのユーザーは、サインアップページでヘッダーに「Profile」リンクが存在しないこと
  10. 未サインインのユーザーは、サインアップページでヘッダーに「Sign out」リンクが存在しないこと
  11. 未サインインのユーザーが、サインインページでヘッダーのロゴをクリックしたとき、トップページに遷移すること
  12. 未サインインのユーザーが、サインインページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること
  13. 未サインインのユーザーが、サインインページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること
  14. 未サインインのユーザーは、サインインページでヘッダーに「Profile」リンクが存在しないこと
  15. 未サインインのユーザーは、サインインページでヘッダーに「Sign out」リンクが存在しないこと
  16. 未サインインのユーザーが、ユーザー詳細ページでヘッダーのロゴをクリックしたとき、トップページに遷移すること
  17. 未サインインのユーザーが、ユーザー詳細ページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること
  18. 未サインインのユーザーが、ユーザー詳細ページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること
  19. 未サインインのユーザーは、ユーザー詳細ページでヘッダーに「Profile」リンクが存在しないこと
  20. 未サインインのユーザーは、ユーザー詳細ページでヘッダーに「Sign out」リンクが存在しないこと
  21. サインイン済のユーザーが、ユーザー詳細ページでヘッダーのロゴをクリックしたとき、そのユーザーのユーザー詳細ページに遷移すること
  22. サインイン済のユーザーは、ユーザー詳細ページでヘッダーに「Home」リンクが存在しないこと
  23. サインイン済のユーザーは、ユーザー詳細ページでヘッダーに「Sign in」リンクが存在しないこと
  24. サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Profile」リンクをクリックしたとき、そのユーザーのユーザー詳細ページに遷移すること
  25. サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Sign out」リンクが存在すること

3. ユーザーとして、サインアップしたい

  1. 未サインインのユーザーが、トップページで「Sign up now!」ボタンを選択したとき、サインアップページに遷移すること
  2. サインアップページで「お名前」を入力できること
  3. サインアップページで「メールアドレス」を入力できること
  4. サインアップページで「パスワード」を入力できること
  5. サインアップページで「パスワード」はマスク化されること
  6. サインアップページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスにチェックをいれたとき、「パスワード」が表示されること
  7. サインアップページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスのチェックを外したとき、「パスワード」がマスク化されること
  8. サインアップページで「お名前」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「お名前」未入力のエラーメッセージが表示されること
  9. サインアップページで「お名前」を51文字以上入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「お名前」文字数超過のエラーメッセージが表示されること
  10. サインアップページで「メールアドレス」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」未入力のエラーメッセージが表示されること
  11. サインアップページで「メールアドレス」を256文字以上入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」文字数超過のエラーメッセージが表示されること
  12. サインアップページで「メールアドレス」を誤ったフォーマットで入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」フォーマットチェックエラーのエラーメッセージが表示されること
  13. サインアップページで「メールアドレス」がすでに登録済みのメールアドレスを入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」重複のエラーメッセージが表示されること
  14. サインアップページで「パスワード」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「パスワード」文字数不足のエラーメッセージが表示されること
  15. サインアップページで「パスワード」を5文字以下で入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「パスワード」文字数不足のエラーメッセージが表示されること
  16. サインアップページで「お名前」「メールアドレス」「パスワード」を正しく入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは成功し、そのユーザーのユーザー詳細ページにサインイン済状態で遷移すること
  17. サインアップに成功したユーザーは、遷移後のユーザー詳細ページで自分の入力した「お名前」を確認できること
  18. サインアップに成功したユーザーは、遷移後のユーザー詳細ページで自分の入力した「メールアドレス」を確認できること
  19. サインアップに成功したユーザーは、遷移後のユーザー詳細ページでウェルカムメッセージを確認できること
  20. サインアップに成功したユーザーは、遷移後のユーザー詳細ページをリロードしたとき、ウェルカムメッセージを確認できなくなること
  21. サインアップページで「登録済みの方はこちら」リンクを選択したとき、サインインページに遷移すること

4. ユーザーとして、サインインしたい

  1. サインインページで「メールアドレス」を入力できること
  2. サインインページで「パスワード」を入力できること
  3. サインインページで「パスワード」はマスク化されること
  4. サインインページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスにチェックをいれたとき、「パスワード」が表示されること
  5. サインインページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスのチェックを外したとき、「パスワード」がマスク化されること
  6. サインインページで「メールアドレス」を入力していないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること
  7. サインインページで「メールアドレス」として登録されていないメールアドレスを入力したユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること
  8. サインインページで「メールアドレス」は正しいが「パスワード」を入力していないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること
  9. サインインページで「メールアドレス」は正しいが「パスワード」が正しくないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること
  10. サインインページで「メールアドレス」「パスワード」に正しい値を入力したユーザーが、「Sign in」ボタンをクリックしたとき、サインイン済状態でそのユーザーのユーザー詳細ページに遷移すること
  11. サインインに成功したユーザーは、遷移後のユーザー詳細ページでサインイン成功メッセージを確認できること
  12. サインインに成功したユーザーは、遷移後のユーザー詳細ページをリロードしたとき、サインイン成功メッセージを確認できなくなること
  13. サインインページで「登録がまだの方はこちら」リンクを選択したとき、サインアップページに遷移すること

5. ユーザーとして、サインアウトしたい

  1. サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Sign out」リンクをクリックしたとき、未サインイン状態になりトップページに遷移すること

6. ユーザーとして、他のユーザーの情報を閲覧したい

  1. ユーザーが、存在するユーザーのユーザー詳細ページにアクセスしようとしたとき、そのユーザーの「お名前」「メールアドレス」を確認できること
  2. ユーザーが、存在しないユーザーのユーザー詳細ページにアクセスしようとしたとき、エラーが発生すること

ざっとあげただけでもこれだけのテストシナリオがあります。
こんなにコード書かなきゃいけないのかよ!と思うかもしれませんが、コードを書かないと少しのリファクタリングの度にこれら全てのテストを手動で行わなければ安心してデプロイできないという修羅の道を選ぶことになります。
今日でテストコードへのハードルを爆下げして気軽にリファクタできるエンジニアをめざしましょう!

テストコードを書いていこう

ここから実際にテストコードを書いていきます。
上でナンバリングで章立てしてましたね。それごとにスペックファイルを作って管理します。

1. ユーザーとして、ページにダイレクトアクセスしたい

ファイル名は01_direct_access_spec.rbにしておきましょう。

$ mkdir spec/system/
$ touch spec/system/01_direct_access_spec.rb
01_direct_access_spec.rb
feature "ユーザーとして、ページにダイレクトアクセスしたい", type: :system do

  background do
    @user1 = User.create(name: "John Smith", email: "john@sample.com", password: "john1234")
    @user2 = User.create(name: "Taro Tanaka", email: "taro@sample.com", password: "taro1234")
  end

  scenario "未サインインのユーザーが、トップページにダイレクトアクセスしたとき、トップページが表示されること" do
    visit root_path
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、サインアップページにダイレクトアクセスしたとき、サインアップページが表示されること" do
    visit sign_up_path
    expect(current_path).to eq sign_up_path
  end

  scenario "未サインインのユーザーが、サインインページにダイレクトアクセスしたとき、サインインページが表示されること" do
    visit sign_in_path
    expect(current_path).to eq sign_in_path
  end

  scenario "未サインインのユーザーが、ユーザー詳細ページにダイレクトアクセスしたとき、ユーザー詳細ページが表示されること" do
    visit user_path(@user1)
    expect(current_path).to eq user_path(@user1)

    visit user_path(@user2)
    expect(current_path).to eq user_path(@user2)
  end

  feature nil, type: :system do

    background do
      visit sign_in_path
      fill_in :user_email, with: @user1.email
      fill_in :user_password, with: @user1.password
      click_on :sign_in_button      
    end

    scenario "サインイン済のユーザーが、トップページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること" do
      visit root_path
      expect(current_path).to eq user_path(@user1)
    end

    scenario "サインイン済のユーザーが、サインアップページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること" do
      visit sign_up_path
      expect(current_path).to eq user_path(@user1)
    end

    scenario "サインイン済のユーザーが、サインインページにダイレクトアクセスしたとき、そのユーザーのユーザー詳細ページが表示されること" do
      visit sign_in_path
      expect(current_path).to eq user_path(@user1)
    end

    scenario "サインイン済のユーザーが、ユーザー詳細ページにダイレクトアクセスしたとき、ユーザー詳細ページが表示されること" do
      visit user_path(@user1)
      expect(current_path).to eq user_path(@user1)

      visit user_path(@user2)
      expect(current_path).to eq user_path(@user2)
    end

  end

end

このテストをパスさせるために、サインインページに少し細工をします。

app/views/sessions/new.html.erb
- <%= form.submit "Sign in", class: "btn btn-primary form-control" %>
+ <%= form.submit "Sign in", class: "btn btn-primary form-control", id: :sign_in_button %>

id: :sign_in_buttonを追記しました。これでid属性を追加できます。

まずはテストがパスするのを体感しましょうか!

$ docker-compose up -d
$ docker-compose exec web ash
# rspec spec/system/01_direct_access_spec.rb
Capybara starting Puma...
* Version 4.3.1 , codename: Mysterious Traveller
* Min threads: 0, max threads: 4
* Listening on tcp://127.0.0.1:45497
........

Finished in 11.85 seconds (files took 5.4 seconds to load)
8 examples, 0 failures

テストパスしましたね!
どんなテストが実行されたのか、ちょっと紹介させてください!

構文

前回も紹介しましたが、RSpecのシステムテストの構文は

feature "test name", type: :system do
  scenario "test scenario" do
    # テストコード
  end
end

です。scenarioは複数あります。

feature内の全てのscenarioに適用する初期条件を記述する場合はbackgroundを使います。

feature "test name", type: :system do
  background do
    # 前提条件
  end

  scenario "test scenario" do
    # テストコード
  end
end

また、featureを入れ子にすることも可能です。これによって特定のscenarioたちにだけ前提条件をつけることも可能です。

feature "test name", type: :system do
  scenario "test scenario" do
    # 前提条件が適用されない
  end

  feature "test detail name", type: :system do
    background do
      # 前提条件
    end

    scenario "test scenario" do
      # 前提条件が適用される    
    end
  end
end

まずはこの構文を身に付けましょう。

background

backgroundは前提条件を定義するためのブロックです。そのファイルのシナリオに共通して行われる処理をここで定義します。
よく使われる場面としては、今回のようにモデルを作っておく、とかですね。
モデルは今までのRubyコードと同じように、User.createUser.newが使えます。また、インスタンス変数にしないとscenario側では参照できないので注意してくださいね。

visit

visitは引数に名前付きルート(xxxx_path)やURLをとって、そこにアクセスします。

visit root_path

これでroot_path、つまり/にアクセスをしようとします。

expect().to

expect().to()内をto以降と検証します。
例えばexpect().to eq xxxxxのようにeqと組み合わせることで()内とxxxxxが等しいことを検証します。
この検証がfalseの場合はそのシナリオをFailureになります。

current_path

current_pathは現在表示されているパス(/とか/users/1とか)を取得します。

expect(current_path).to eq root_path

で、今表示されているページのパスがルートパス、つまりトップページであるかどうかを検証しているのです。

fill_in

fill_ininputに文字を入力する操作を実行します。

fill_in [id], with: [入力したい文字列]

で、ページからid属性が[id]input[入力したい文字列]を入力してくれます。

click_on

click_on<a>または<button>タグをクリックする操作を実行します。

click_on [id]

で、ページからid属性が[id]<a><button>タグをクリックしてくれます。

ここまでを理解すると

background do
  visit sign_in_path
  fill_in :user_email, with: @user1.email
  fill_in :user_password, with: @user1.password
  click_on :sign_in_button
end

が、サインインページにアクセスして@user1でサインインしようとしていることがわかりますね?

こんな感じで操作と検証を組み合わせてテストを自動化していきます!
ではどんどんテストコードを書いていきましょう!!

2. ユーザーとして、ヘッダーリンクからページ遷移できること

ファイル名は02_header_spec.rbでいきましょうか。

# touch spec/system/02_header_spec.rb

また少しidを仕込んでおきましょう。

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

そしてテストシナリオを書きます。

spec/system/02_header_spec.rb
feature "ユーザーとして、ヘッダーリンクからページ遷移できること", type: :system do
  background do
    @user1 = User.create(name: "John Smith", email: "john@sample.com", password: "john1234")
    @user2 = User.create(name: "Taro Tanaka", email: "taro@sample.com", password: "taro1234")
  end

  scenario "未サインインのユーザーが、トップページでヘッダーのロゴをクリックしたとき、トップページに遷移すること" do
    visit root_path
    click_on :header_logo
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、トップページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること" do
    visit root_path
    click_on :header_home_link
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、トップページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること" do
    visit root_path
    click_on :header_sign_in_link
    expect(current_path).to eq sign_in_path
  end

  scenario "未サインインのユーザーは、トップページでヘッダーに「Profile」リンクが存在しないこと" do
    visit root_path
    expect(page).not_to have_selector "#header_profile_link"
  end

  scenario "未サインインのユーザーは、トップページでヘッダーに「Sign out」リンクが存在しないこと" do
    visit root_path
    expect(page).not_to have_selector "#header_sign_out_link"
  end

  scenario "未サインインのユーザーが、サインアップページでヘッダーのロゴをクリックしたとき、トップページに遷移すること" do
    visit sign_up_path
    click_on :header_logo
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、サインアップページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること" do
    visit sign_up_path
    click_on :header_home_link
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、サインアップページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること" do
    visit sign_up_path
    click_on :header_sign_in_link
    expect(current_path).to eq sign_in_path
  end

  scenario "未サインインのユーザーは、サインアップページでヘッダーに「Profile」リンクが存在しないこと" do
    visit sign_up_path
    expect(page).not_to have_selector "#header_profile_link"
  end

  scenario "未サインインのユーザーは、サインアップページでヘッダーに「Sign out」リンクが存在しないこと" do
    visit sign_up_path
    expect(page).not_to have_selector "#header_sign_out_path"
  end

  scenario "未サインインのユーザーが、サインインページでヘッダーのロゴをクリックしたとき、トップページに遷移すること" do
    visit sign_in_path
    click_on :header_logo
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、サインインページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること" do
    visit sign_in_path
    click_on :header_home_link
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、サインインページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること" do
    visit sign_in_path
    click_on :header_sign_in_link
    expect(current_path).to eq sign_in_path
  end

  scenario "未サインインのユーザーは、サインインページでヘッダーに「Profile」リンクが存在しないこと" do
    visit sign_in_path
    expect(page).not_to have_selector "#header_profile_path"
  end

  scenario "未サインインのユーザーは、サインインページでヘッダーに「Sign out」リンクが存在しないこと" do
    visit sign_in_path
    expect(page).not_to have_selector "#header_sign_out_path"
  end

  scenario "未サインインのユーザーが、ユーザー詳細ページでヘッダーのロゴをクリックしたとき、トップページに遷移すること" do
    visit user_path(@user1)
    click_on :header_logo
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、ユーザー詳細ページでヘッダーの「Home」リンクをクリックしたとき、トップページに遷移すること" do
    visit user_path(@user1)
    click_on :header_home_link
    expect(current_path).to eq root_path
  end

  scenario "未サインインのユーザーが、ユーザー詳細ページでヘッダーの「Sign in」リンクをクリックしたとき、サインインページに遷移すること" do
    visit user_path(@user1)
    click_on :header_sign_in_link
    expect(current_path).to eq sign_in_path
  end

  scenario "未サインインのユーザーは、ユーザー詳細ページでヘッダーに「Profile」リンクが存在しないこと" do
    visit user_path(@user1)
    expect(page).not_to have_selector "#header_profile_link"
  end

  scenario "未サインインのユーザーは、ユーザー詳細ページでヘッダーに「Sign out」リンクが存在しないこと" do
    visit user_path(@user1)
    expect(page).not_to have_selector "#header_sign_out_link"
  end

  feature nil, type: :system do

    background do
      visit sign_in_path
      fill_in :user_email, with: @user1.email
      fill_in :user_password, with: @user1.password
      click_on :sign_in_button            
    end

    scenario "サインイン済のユーザーが、ユーザー詳細ページでヘッダーのロゴをクリックしたとき、そのユーザーのユーザー詳細ページに遷移すること" do
      visit user_path(@user2)
      click_on :header_logo
      sleep 1
      expect(current_path).to eq user_path(@user1)
    end

    scenario "サインイン済のユーザーは、ユーザー詳細ページでヘッダーに「Home」リンクが存在しないこと" do
      visit user_path(@user2)
      expect(page).not_to have_selector "#header_home_link"
    end

    scenario "サインイン済のユーザーは、ユーザー詳細ページでヘッダーに「Sign in」リンクが存在しないこと" do
      visit user_path(@user2)
      expect(page).not_to have_selector "#header_sign_in_link"
    end

    scenario "サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Profile」リンクをクリックしたとき、そのユーザーのユーザー詳細ページに遷移すること" do
      visit user_path(@user2)
      click_on :header_profile_link
      expect(current_path).to eq user_path(@user1)
    end

    scenario "サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Sign out」リンクが存在すること" do
      visit user_path(@user2)
      expect(page).to have_selector "#header_sign_out_link"
    end

  end

end

ダイレクトアクセスのテストと似ているところが多いですが、新出のコードを紹介していきます!

page

expect(page)という形で現れましたね。これは今表示されているページ全体のことです。

not_to

expect().not_toという表現がでてきましたね。
toの反対で()not_to以降がアンマッチであることを検証します。
マッチした場合にfalseになり、そのシナリオがFailureになります。

have_selector

have_selectorは指定したタグや属性を持っているかを検証します。
例えば、以下のような要素があるとします。

<h1 id="title" class="main-title">Title</h1>

これをタグ、id属性、class属性でそれぞれhave_selectorで検証しようとすると以下のようになります。

# タグで検証
expect(page).to have_selector("h1")

# id属性で検証
expect(page).to have_selector("#title")

# class属性で検証
expect(page).to have_selector(".main-title")

ページ内や子要素に特定の要素がないかを調べる時に使うので覚えておくとよしです!

sleep

sleepは指定した秒数、次のコードの実行を待つコードです。sleep 1であれば1秒待った後に次の行に進みます。
今回は、アプリケーション側でサインイン状態を確認した後リダイレクトする処理を入れていますが、自動化されたテストがそのままのスピードで検証を進めてしまうとリダイレクト処理が終わる前に検証が完了してしまい、思ったとおりの結果を得られないことがあります。
こういった自体を防ぐために、sleepを挟むことで処理を待たせることも必要になります。

ただし、sleepの使用は最低限にするべきです。なぜならそのせいでテストの実行時間が長くなってしまっては自動化した意味が失われかねないからです。

3. ユーザーとして、サインアップしたい

ファイル名は03_sign_up_spec.rbでいきます!

# touch spec/system/03_sign_up_spec.rb

そして、今回もidを仕込みます。

app/views/static_pages/home.html.erb
- <%= link_to "Sign up now!", sign_up_path, class: "btn btn-lg btn-primary mt-5" %>
+ <%= link_to "Sign up now!", sign_up_path, class: "btn btn-lg btn-primary mt-5", id: :sign_up_link %>
app/views/users/new.html.erb
  <%= form_with model: @user, url: create_user_path, local: true do |form| %>
    ...
    <div class="form-group mt-5">
-     <%= form.submit "Sign up!", class: "form-control btn btn-primary" %>
+     <%= form.submit "Sign up!", class: "form-control btn btn-primary", id: :sign_up_button %>
    </div>
  <% end %>
- <p class="text-center">登録済みの方は<%= link_to "こちら", sign_in_path %></p>
+ <p class="text-center">登録済みの方は<%= link_to "こちら", sign_in_path, id: :sign_in_link %></p>

はい。ではテストコードです。

spec/system/03_sign_up_spec.rb
feature "ユーザーとして、サインアップしたい", type: :system do
  background do
    @user = User.new(name: "John Smith", email: "john@sample.com", password: "john1234")
  end

  scenario "未サインインのユーザーが、トップページで「Sign up now!」ボタンを選択したとき、サインアップページに遷移すること" do
    visit root_path
    click_on :sign_up_link
    expect(current_path).to eq sign_up_path
  end

  scenario "サインアップページで「お名前」を入力できること" do
    visit sign_up_path
    fill_in :user_name, with: @user.name
    expect(find("#user_name").value).to eq @user.name
  end

  scenario "サインアップページで「メールアドレス」を入力できること" do
    visit sign_up_path
    fill_in :user_email, with: @user.email
    expect(find("#user_email").value).to eq @user.email
  end

  scenario "サインアップページで「パスワード」を入力できること" do
    visit sign_up_path
    fill_in :user_password, with: @user.password
    expect(find("#user_password").value).to eq @user.password
  end

  scenario "サインアップページで「パスワード」はマスク化されること" do
    visit sign_up_path
    fill_in :user_password, with: @user.password
    expect(find("#user_password")[:type]).to eq "password"
  end

  scenario "サインアップページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスにチェックをいれたとき、「パスワード」が表示されること" do
    visit sign_up_path
    fill_in :user_password, with: @user.password
    check :visible_password
    expect(find("#user_password")[:type]).to eq "text"
  end

  scenario "サインアップページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスのチェックを外したとき、「パスワード」がマスク化されること" do
    visit sign_up_path
    fill_in :user_password, with: @user.password
    check :visible_password
    uncheck :visible_password
    expect(find("#user_password")[:type]).to eq "password"
  end

  scenario "サインアップページで「お名前」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「お名前」未入力のエラーメッセージが表示されること" do
    error_message = "お名前を入力してください"

    visit sign_up_path
    fill_in :user_name, with: ""
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message
  end

  scenario "サインアップページで「お名前」を51文字以上入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「お名前」文字数超過のエラーメッセージが表示されること" do
    error_message = "お名前は50文字以内で入力してください"

    visit sign_up_path
    fill_in :user_name, with: "a" * 51
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message

    fill_in :user_name, with: "a" * 50
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).not_to eq sign_up_path
    expect(page).not_to have_text error_message
    expect(current_path).to eq user_path(User.find_by(email: @user.email))
  end

  scenario "サインアップページで「メールアドレス」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」未入力のエラーメッセージが表示されること" do
    error_message = "メールアドレスを入力してください"

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: ""
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message
  end

  scenario "サインアップページで「メールアドレス」を256文字以上入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」文字数超過のエラーメッセージが表示されること" do
    error_message = "メールアドレスは255文字以内で入力してください"

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: "a" * 245 + "@sample.com"
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message

    fill_in :user_email, with: "a" * 244 + "@sample.com"
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).not_to eq sign_up_path
    expect(page).not_to have_text error_message
    expect(current_path).to eq user_path(User.find_by(email: "a" * 244 + "@sample.com")) 
  end

  scenario "サインアップページで「メールアドレス」を誤ったフォーマットで入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」フォーマットチェックエラーのエラーメッセージが表示されること" do
    error_message = "メールアドレスは不正な値です"

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: "sample.com"
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message
  end

  scenario "サインアップページで「メールアドレス」がすでに登録済みのメールアドレスを入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「メールアドレス」重複のエラーメッセージが表示されること" do
    error_message = "メールアドレスはすでに存在します"
    @user.save

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: @user.email.upcase
    fill_in :user_password, with: @user.password
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message
  end

  scenario "サインアップページで「パスワード」を入力していないユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「パスワード」文字数不足のエラーメッセージが表示されること" do
    error_message = "パスワードは6文字以上で入力してください"

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: ""
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message
  end

  scenario "サインアップページで「パスワード」を5文字以下で入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは失敗し「パスワード」文字数不足のエラーメッセージが表示されること" do
    error_message = "パスワードは6文字以上で入力してください"

    visit sign_up_path
    fill_in :user_name, with: @user.name
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: "john1"
    click_on :sign_up_button

    expect(current_path).to eq sign_up_path
    expect(page).to have_text error_message

    fill_in :user_password, with: "john12"
    click_on :sign_up_button

    expect(current_path).not_to eq sign_up_path
    expect(page).not_to have_text error_message
    expect(current_path).to eq user_path(User.find_by(email: @user.email))
  end

  feature nil, type: :system do
    background do
      @welcome_message = "サインアップありがとう"

      visit sign_up_path
      fill_in :user_name, with: @user.name
      fill_in :user_email, with: @user.email
      fill_in :user_password, with: @user.password
      click_on :sign_up_button
    end

    scenario "サインアップページで「お名前」「メールアドレス」「パスワード」を正しく入力したユーザーが、「Sign up!」ボタンをクリックしたとき、サインアップは成功し、そのユーザーのユーザー詳細ページにサインイン済状態で遷移すること" do
      expect(current_path).to eq user_path(User.find_by(email: @user.email))
      expect(page).not_to have_selector "#header_sign_in_link"
      expect(page).to have_selector "#header_sign_out_link"
    end

    scenario "サインアップに成功したユーザーは、遷移後のユーザー詳細ページで自分の入力した「お名前」を確認できること" do
      expect(page).to have_text @user.name
    end

    scenario "サインアップに成功したユーザーは、遷移後のユーザー詳細ページで自分の入力した「メールアドレス」を確認できること" do
      expect(page).to have_text @user.email
    end

    scenario "サインアップに成功したユーザーは、遷移後のユーザー詳細ページでウェルカムメッセージを確認できること" do
      expect(page).to have_text @welcome_message
    end

    scenario "サインアップに成功したユーザーは、遷移後のユーザー詳細ページをリロードしたとき、ウェルカムメッセージを確認できなくなること" do
      visit current_path
      expect(page).not_to have_text @welcome_message
    end
  end

  scenario "サインアップページで「登録済みの方はこちら」リンクを選択したとき、サインインページに遷移すること" do
    visit sign_up_path
    click_on :sign_in_link
    expect(current_path).to eq sign_in_path
  end
end

また、はじめましての書き方を紹介していきます。

find

モデルのときにつかったfindとはまた別ですよ。
find()でページの中から()内で指定した要素を取得してくれます。1つ以上該当するものがあるとエラーになってしまうので、id属性に対して使うのが好ましいでしょう。
例えば今回のテストコードでは、

expect(find("#user_name").value).to eq @user.name

のように使っていますが、これでid属性がuser_nameに定義されている要素を取得します。

value

find("#user_name").value

のようにvalueを使っています。これはinputvalue属性を取得しています。
value属性にはinput type="text"などの場合にはテキストボックスにデフォルトで入力しておきたい文字列を入力しておいたりしますが、Capybaraではvalue属性を取得することで今入力されている文字列を取得することができます。

[:type]

find("#user_password")[:type]

のように使っています。Capybaraではvaluetextは要素と.でつなぐことで取得できるのですが、それ以外の属性は[:attribute_name]の形式で取得します。[:type]だとtype属性を取得してきていることになりますね。
今回はpasswordtype属性をjavascriptでtextpasswordを切り替えているので、これでチェックができます。textはマスク化なし、passwordはマスク化ありはHTML5の仕様なので、今回のテストではtype属性が正しく指定されているかを検証しました。

check

checkはチェックボックスにチェックする操作です。
今回は

check :visible_password

の形式でid属性がvisible_passwordのチェックボックスにチェックを入れています。

uncheck

uncheckcheckの反対でチェックボックスからチェックを外す操作です。

have_text

have_textは指定した文字列が存在するかどうかを検証するために使います。

expect(page).to have_text xxxxxxxxxx

と記述することでページのどこかにでもxxxxxxxxxxの文字列が存在しないかを検証します。
pageの箇所をfind()などで限定した要素にすることで、その要素内にxxxxxxxxxxの文字列が存在するかどうかを検証するように範囲を狭めることもできます。

visit current_path

以前お話したようにcurrent_pathは現在のパスです。そこにvisitしているということは...

そう!これはリロード操作ですね。

はい。今回のテストコードで新しく出てきた表現はこのくらいではないでしょうか。
そろそろ慣れてきましたか?一回書き始めると案外それらの組み合わせだけでいろいろなテストを実行できることがわかってきたんじゃないかと思います。
それでは次はサインインのテストコードを記述していきましょう!

4. ユーザーとして、サインインしたい

まずはシナリオファイルの作成から。

# touch spec/system/04_sign_in_spec.rb

そして、必要な箇所にid属性を振ります。

app/views/sessions/new.html.erb
- <p class="text-center">登録がまだの方は<%= link_to "こちら", sign_up_path %></p>
+ <p class="text-center">登録がまだの方は<%= link_to "こちら", sign_up_path, id: :sign_up_link %></p>

以下、テストコードです。今回は目新しい表現はないので、下のコードを見ずに書いてみてもらっても面白いかもしれないです。

spec/system/04_sign_in_spec.rb
feature "ユーザーとして、サインインしたい", type: :system do
  background do
    @user = User.create(name: "John Smith", email: "john@sample.com", password: "john1234")
  end

  scenario "サインインページで「メールアドレス」を入力できること" do
    visit sign_in_path
    fill_in :user_email, with: @user.email
    expect(find("#user_email").value).to eq @user.email
  end

  scenario "サインインページで「パスワード」を入力できること" do
    visit sign_in_path
    fill_in :user_password, with: @user.password
    expect(find("#user_password").value).to eq @user.password
  end

  scenario "サインインページで「パスワード」はマスク化されること" do
    visit sign_in_path
    fill_in :user_password, with: @user.password
    expect(find("#user_password")[:type]).to eq "password"
  end

  scenario "サインインページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスにチェックをいれたとき、「パスワード」が表示されること" do
    visit sign_in_path
    fill_in :user_password, with: @user.password
    check :visible_password
    expect(find("#user_password")[:type]).to eq "text"
  end

  scenario "サインインページで「パスワード」を入力したユーザーが、「パスワードを表示する」チェックボックスのチェックを外したとき、「パスワード」がマスク化されること" do
    visit sign_in_path
    fill_in :user_password, with: @user.password
    check :visible_password
    uncheck :visible_password
    expect(find("#user_password")[:type]).to eq "password"
  end

  scenario "サインインページで「メールアドレス」を入力していないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること" do
    error_message = "メールアドレスまたはパスワードをもう一度確認してください。"

    visit sign_in_path
    fill_in :user_email, with: ""
    fill_in :user_password, with: @user.password
    click_on :sign_in_button

    expect(current_path).to eq sign_in_path
    expect(page).to have_text error_message
  end

  scenario "サインインページで「メールアドレス」として登録されていないメールアドレスを入力したユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること" do
    error_message = "メールアドレスまたはパスワードをもう一度確認してください。"

    visit sign_in_path
    fill_in :user_email, with: "dummy@sample.com"
    fill_in :user_password, with: @user.password
    click_on :sign_in_button

    expect(current_path).to eq sign_in_path
    expect(page).to have_text error_message
  end

  scenario "サインインページで「メールアドレス」は正しいが「パスワード」を入力していないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること" do
    error_message = "メールアドレスまたはパスワードをもう一度確認してください。"

    visit sign_in_path
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: ""
    click_on :sign_in_button

    expect(current_path).to eq sign_in_path
    expect(page).to have_text error_message  
  end

  scenario "サインインページで「メールアドレス」は正しいが「パスワード」が正しくないユーザーが、「Sign in」ボタンをクリックしたとき、サインイン失敗のエラーメッセージが表示されること" do
    error_message = "メールアドレスまたはパスワードをもう一度確認してください。"

    visit sign_in_path
    fill_in :user_email, with: @user.email
    fill_in :user_password, with: @user.password + "a"
    click_on :sign_in_button

    expect(current_path).to eq sign_in_path
    expect(page).to have_text error_message      
  end

  feature nil, type: :system do
    background do
      @sign_in_message = "サインインしました。"

      visit sign_in_path
      fill_in :user_email, with: @user.email
      fill_in :user_password, with: @user.password
      click_on :sign_in_button        
    end

    scenario "サインインページで「メールアドレス」「パスワード」に正しい値を入力したユーザーが、「Sign in」ボタンをクリックしたとき、サインイン済状態でそのユーザーのユーザー詳細ページに遷移すること" do
      expect(current_path).to eq user_path(@user)
      expect(page).not_to have_selector "#header_sign_in_link"
      expect(page).to have_selector "#header_sign_out_link"
    end

    scenario "サインインに成功したユーザーは、遷移後のユーザー詳細ページでサインイン成功メッセージを確認できること" do
      expect(page).to have_text @sign_in_message
    end

    scenario "サインインに成功したユーザーは、遷移後のユーザー詳細ページをリロードしたとき、サインイン成功メッセージを確認できなくなること" do
      visit current_path
      expect(page).not_to have_text @sign_in_message
    end
  end

  scenario "サインインページで「登録がまだの方はこちら」リンクを選択したとき、サインアップページに遷移すること" do
    visit sign_in_path
    click_on :sign_up_link

    expect(current_path).to eq sign_up_path
  end
end

5. ユーザーとして、サインアウトしたい

次はサインアウトについてですね。

# touch spec/system/05_sign_out_spec.rb

そしてコーディング。これも今までのコードの組み合わせで表現可能ですね。

spec/system/05_sign_out_spec.rb
feature "ユーザーとして、サインアウトしたい", type: :system do
  scenario "サインイン済のユーザーが、ユーザー詳細ページでヘッダーの「Sign out」リンクをクリックしたとき、未サインイン状態になりトップページに遷移すること" do
    user = User.create(name: "John Smith", email: "john@sample.com", password: "john1234")

    visit sign_in_path
    fill_in :user_email, with: user.email
    fill_in :user_password, with: user.password
    click_on :sign_in_button

    visit user_path(user)

    click_on :header_sign_out_link

    expect(current_path).to eq root_path
    expect(page).to have_selector "#header_sign_in_link"
    expect(page).not_to have_selector "#header_sign_out_link"
  end
end

コメントアウトなどで説明文を書いたりは省いていますが、今までの内容が理解できていれば何をしているのか想像できると思います。
サインインページにアクセスしてサインインし、ユーザー詳細ページでヘッダーのサインアウトリンクをクリックし、トップページにリダイレクトされたと同時に未サインイン状態になっていることを検証していますね。

6. ユーザーとして、他のユーザーの情報を閲覧したい

まずはシナリオファイルです。

# touch spec/system/06_show_user_info_spec.rb

今回のテストでは、NotFoundのユーザーのユーザー詳細ページを表示しようとした時に、NotFoundのページが表示される、という項目があります。
Railsでは、NotFoundの例外が発生した場合、production環境の場合はデフォルトでNotFound用のページが表示されるようになっています。
test環境でも同じようにNotFoundページが表示されるようにconfigを変更します。

config/environments/test.rb
- config.consider_all_requests_local       = true
+ config.consider_all_requests_local       = false
- config.action_dispatch.show_exceptions = false
+ config.action_dispatch.show_exceptions = true

これで準備完了です。
NotFoundの場合、public/404.htmlが表示されるようになります。
中身を見ると、

<h1>The page you were looking for doesn't exist.</h1>

と記述されているので、この文字列があるかどうかをチェックするようにします。

ではテストコードです。

spec/system/06_show_user_info_spec.rb
feature "ユーザーとして、他のユーザーの情報を閲覧したい", type: :system do
  background do
    @user1 = User.create(name: "John Smith", email: "john@sample.com", password: "john1234")
    @user2 = User.create(name: "Taro Yamada", email: "taro@sample.com", password: "taro1234")    
  end

  scenario "ユーザーが、存在するユーザーのユーザー詳細ページにアクセスしようとしたとき、そのユーザーの「お名前」「メールアドレス」を確認できること" do

    # Before sign in
    visit user_path(@user1)
    expect(page).to have_text @user1.name
    expect(page).to have_text @user1.email
    expect(page).not_to have_text @user2.name
    expect(page).not_to have_text @user2.email

    visit user_path(@user2)
    expect(page).not_to have_text @user1.name
    expect(page).not_to have_text @user1.email
    expect(page).to have_text @user2.name
    expect(page).to have_text @user2.email

    # After sign in
    visit sign_in_path
    fill_in :user_email, with: @user1.email
    fill_in :user_password, with: @user1.password
    click_on :sign_in_button

    visit user_path(@user1)
    expect(page).to have_text @user1.name
    expect(page).to have_text @user1.email
    expect(page).not_to have_text @user2.name
    expect(page).not_to have_text @user2.email

    visit user_path(@user2)
    expect(page).not_to have_text @user1.name
    expect(page).not_to have_text @user1.email
    expect(page).to have_text @user2.name
    expect(page).to have_text @user2.email
  end

  scenario "ユーザーが、存在しないユーザーのユーザー詳細ページにアクセスしようとしたとき、エラーが発生すること" do
    not_found_message = "The page you were looking for doesn't exist."
    not_found_id = @user2.id + 1
    expect{User.find(not_found_id)}.to raise_exception(ActiveRecord::RecordNotFound)

    # Before sign in
    visit user_path(not_found_id)
    expect(page).to have_text not_found_message

    # After sign in
    visit sign_in_path
    fill_in :user_email, with: @user1.email
    fill_in :user_password, with: @user1.password
    click_on :sign_in_button

    visit user_path(not_found_id)
    expect(page).to have_text not_found_message
  end
end

基本的には今までと変わりありませんね。
一つだけexceptionの検証の仕方だけ新出があるのでそれの説明を。

expect{}.to raise_exception()

今までと違うのはexpectの検証ターゲットを()ではなく{}でかこっていることですね。
そしてraise_exceptionの後に期待する例外を記述します。
今回は@user2id+1したidのユーザーを検索しています。idはシーケンシャルに払い出されるので@user2よりも大きいidを持っているユーザーはいないはず。
なのでUser.find(not_found_id)ActiveRecord::RecordNotFoundの例外が発生するはずです。

はい。ここまでで今のところ考えられる全てのテストケースをコーディングしてみました。
テストを実行してみましょう!

# rspec
Capybara starting Puma...
* Version 4.3.1 , codename: Mysterious Traveller
* Min threads: 0, max threads: 4
* Listening on tcp://127.0.0.1:46237
......................................................................

Finished in 1 minute 21.27 seconds (files took 5.82 seconds to load)
70 examples, 0 failures

全てパスしてますね!
もしパスしないテストケースがある場合は、もう一度アプリのコードかテストコードを見返してみてくださいね。

また、あえてエラーになるようにテストコードを書き直してみてエラーになることを確認してみるのも面白いと思います。

さて、では本日はここまでにしましょう!

まとめ

今日は今まで作ってきたアプリに対してテストコードをコーディングしてみました。
これによって今後リファクタリングの都度自動テストを回すだけで全ての動作を確認することができるようになりましたね。

テスト自動化、楽しいですよね??

テスト自動化は新しい機能を作ったり、アプリの仕様自体を変更する時にテストコードも記述する必要があるのでその分稼働が必要になることもあります。
しかし、リファクタリングや新機能開発時のデグレテストを簡略化でき、自動テストをパスしていればデプロイを自信をもって行える安心感を得ることができます。特にアプリが大きくなっていくと、これは稼働以上に嬉しい恩恵です。

今後はこのハンズオンでもTDDで開発を進めていきます!

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

後片付け

では後片付けしていきますー。

前回もお話した通り、RSpecのシステムテストはテストが終わるとDBを勝手にリセットしてくれます。
ので、コンテナを落として終了ですね。

# exit
$ docker-compose down

本日のソースコード

Reference

Other Hands-on Links

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

docker run のオプション

doxker runのオプションメモ

docker run --help

Run a command in a new container

Options:
      --add-host list                  Add a custom host-to-IP mapping
                                       (host:ip)
  -a, --attach list                    Attach to STDIN, STDOUT or STDERR
      --blkio-weight uint16            Block IO (relative weight),
                                       between 10 and 1000, or 0 to
                                       disable (default 0)
      --blkio-weight-device list       Block IO weight (relative device
                                       weight) (default [])
      --cap-add list                   Add Linux capabilities
      --cap-drop list                  Drop Linux capabilities
      --cgroup-parent string           Optional parent cgroup for the
                                       container
      --cidfile string                 Write the container ID to the file
      --cpu-period int                 Limit CPU CFS (Completely Fair
                                       Scheduler) period
      --cpu-quota int                  Limit CPU CFS (Completely Fair
                                       Scheduler) quota
      --cpu-rt-period int              Limit CPU real-time period in
                                       microseconds
      --cpu-rt-runtime int             Limit CPU real-time runtime in
                                       microseconds
  -c, --cpu-shares int                 CPU shares (relative weight)
      --cpus decimal                   Number of CPUs
      --cpuset-cpus string             CPUs in which to allow execution
                                       (0-3, 0,1)
      --cpuset-mems string             MEMs in which to allow execution
                                       (0-3, 0,1)
  -d, --detach                         Run container in background and
                                       print container ID
      --detach-keys string             Override the key sequence for
                                       detaching a container
      --device list                    Add a host device to the container
      --device-cgroup-rule list        Add a rule to the cgroup allowed
                                       devices list
      --device-read-bps list           Limit read rate (bytes per second)
                                       from a device (default [])
      --device-read-iops list          Limit read rate (IO per second)
                                       from a device (default [])
      --device-write-bps list          Limit write rate (bytes per
                                       second) to a device (default [])
      --device-write-iops list         Limit write rate (IO per second)
                                       to a device (default [])
      --disable-content-trust          Skip image verification (default true)
      --dns list                       Set custom DNS servers
      --dns-option list                Set DNS options
      --dns-search list                Set custom DNS search domains
      --domainname string              Container NIS domain name
      --entrypoint string              Overwrite the default ENTRYPOINT
                                       of the image
  -e, --env list                       Set environment variables
      --env-file list                  Read in a file of environment variables
      --expose list                    Expose a port or a range of ports
      --gpus gpu-request               GPU devices to add to the
                                       container ('all' to pass all GPUs)
      --group-add list                 Add additional groups to join
      --health-cmd string              Command to run to check health
      --health-interval duration       Time between running the check
                                       (ms|s|m|h) (default 0s)
      --health-retries int             Consecutive failures needed to
                                       report unhealthy
      --health-start-period duration   Start period for the container to
                                       initialize before starting
                                       health-retries countdown
                                       (ms|s|m|h) (default 0s)
      --health-timeout duration        Maximum time to allow one check to
                                       run (ms|s|m|h) (default 0s)
      --help                           Print usage
  -h, --hostname string                Container host name
      --init                           Run an init inside the container
                                       that forwards signals and reaps
                                       processes
  -i, --interactive                    Keep STDIN open even if not attached
      --ip string                      IPv4 address (e.g., 172.30.100.104)
      --ip6 string                     IPv6 address (e.g., 2001:db8::33)
      --ipc string                     IPC mode to use
      --isolation string               Container isolation technology
      --kernel-memory bytes            Kernel memory limit
  -l, --label list                     Set meta data on a container
      --label-file list                Read in a line delimited file of labels
      --link list                      Add link to another container
      --link-local-ip list             Container IPv4/IPv6 link-local
                                       addresses
      --log-driver string              Logging driver for the container
      --log-opt list                   Log driver options
      --mac-address string             Container MAC address (e.g.,
                                       92:d0:c6:0a:29:33)
  -m, --memory bytes                   Memory limit
      --memory-reservation bytes       Memory soft limit
      --memory-swap bytes              Swap limit equal to memory plus
                                       swap: '-1' to enable unlimited swap
      --memory-swappiness int          Tune container memory swappiness
                                       (0 to 100) (default -1)
      --mount mount                    Attach a filesystem mount to the
                                       container
      --name string                    Assign a name to the container
      --network network                Connect a container to a network
      --network-alias list             Add network-scoped alias for the
                                       container
      --no-healthcheck                 Disable any container-specified
                                       HEALTHCHECK
      --oom-kill-disable               Disable OOM Killer
      --oom-score-adj int              Tune host's OOM preferences (-1000
                                       to 1000)
      --pid string                     PID namespace to use
      --pids-limit int                 Tune container pids limit (set -1
                                       for unlimited)
      --privileged                     Give extended privileges to this
                                       container
  -p, --publish list                   Publish a container's port(s) to
                                       the host
  -P, --publish-all                    Publish all exposed ports to
                                       random ports
      --read-only                      Mount the container's root
                                       filesystem as read only
      --restart string                 Restart policy to apply when a
                                       container exits (default "no")
      --rm                             Automatically remove the container
                                       when it exits
      --runtime string                 Runtime to use for this container
      --security-opt list              Security Options
      --shm-size bytes                 Size of /dev/shm
      --sig-proxy                      Proxy received signals to the
                                       process (default true)
      --stop-signal string             Signal to stop a container
                                       (default "15")
      --stop-timeout int               Timeout (in seconds) to stop a
                                       container
      --storage-opt list               Storage driver options for the
                                       container
      --sysctl map                     Sysctl options (default map[])
      --tmpfs list                     Mount a tmpfs directory
  -t, --tty                            Allocate a pseudo-TTY
      --ulimit ulimit                  Ulimit options (default [])
  -u, --user string                    Username or UID (format:
                                       <name|uid>[:<group|gid>])
      --userns string                  User namespace to use
      --uts string                     UTS namespace to use
  -v, --volume list                    Bind mount a volume
      --volume-driver string           Optional volume driver for the
                                       container
      --volumes-from list              Mount volumes from the specified
                                       container(s)
  -w, --workdir string                 Working directory inside the container
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【MySQL・Docker】cryptography is required for sha256_password or caching_sha2_password

DockerでMySQL8.0のイメージを使用した際、「cryptography is required for sha256_password or caching_sha2_password」エラーが発生したのでその回避策。

原因

デフォルトの認証がcaching_sha2_passwordになっていたのですが、これだと暗号化がうまくいかないのか、復号の際の検証がうまくいかないのか、エラーが発生しているようです。

参考
アプリ制作時に悩まされたエラー集
MYSQL8.0におけるデフォルトの認証プラグインの変更

回避策

認証方式をMySQL8.0.4以前のmysql_native_passwordに戻しましょう。CMDで変更することも可能だと思いますが、今回はmy.cnfを自作して、起動したコンテナにマウントします。

my.cnf
[musqld]
default_authentication_plugin= mysql_native_password
docker-compose.yaml
・・・略
  mysql:
    image: mysql:8.0
    volumes:
      - ./my.cnf:/etc/mysql/conf.d/my.cnf

my.cnfをマウントする方式にしておくことで文字コード問題(utf8,latin問題)などが発生した場合も設定を書いてあげるだけなので簡単に対応することができるようになります。

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

Dockerで構築したUbuntu18.04でip系のコマンドが実行できない場合の対処

Dockerで起動したUbuntuでrootユーザーであるにも関わらず、ip系コマンドを入力して、以下のように実行できない場合。。。

root@d7a6f6a495dd:/# ip netns add helloworld
mount --make-shared /var/run/netns failed: Operation not permitted

docker runのオプションである--privilegedを付与すると権限が与えられる

docker run --privileged -it -d --name ubuntu-test ubuntu:18.04

これでOK

root@d7a6f6a495dd:/# ip netns add helloworld
root@d7a6f6a495dd:/# ip netns list
helloworld
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

dockerでwordpress(SSL)を速攻で立ち上げる

wordpress-logo-stacked-rgb.png

なんでこれを書こうと思ったのか

既にgithubで公開されているものを使ってサクっとやれないのかを探してみると、意外と国内の記事では自力でdocker-compose.ymlを書いていたり、versionが古かったりしていたので、備忘録がてらに残しておきます。

対象読者

  • サーバ1台でwordpressを運用したい個人向け
  • dockerが使える環境にあること

さて、やってみるよ

$ mkdir ~/docker && cd ~/docker
$ git clone https://github.com/evertramos/docker-compose-letsencrypt-nginx-proxy-companion.git nginx-proxy
$ cd nginx-proxy
$ mv .env.sample .env
$ ./start.sh
$ cd ~/docker
$ git clone https://github.com/evertramos/docker-wordpress-letsencrypt.git wordpress-letsencrypt
$ cd wordpress-letsencrypt
$ mv .env.sample .env
$ vim .env
  • 変更したいところは適当に変えていく。特にDBの接続情報や、letsencryptの設定(FQDN/E-Mail)
  • コンテナ名は複数のwordpressを立ち上げたい場合はユニークにする必要がある
  • コメントで丁寧に書かれているのでここでは省略
$ docker-compose up -d

これでおしまい。あとは少し待ってからブラウザで、上で指定したFQDNにアクセスしてあげれば、wordpressの設定画面が表示されます。

日本語化したい場合

ブラウザからログインまで出来るようにした後にwp-cliを使って設定をします。

$ chmod 777 ~docker/data/site/wp-content
$ alias wp="docker-compose run --rm wpcli"
$ wp core language install ja --activate

これでOKです。wp-contentのディレクトリパーミッションを変更しておかないと、languageディレクトの作成に失敗するようです。

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

DockerでMacのDiskがいっぱいになった

現象

気づかぬうちに、MacのDiskが蝕まれていた

Check

homeの下をチェック

sudo du -x -h -d 2 /Users/masato-naka/ | sort -h -r | head -30
...
109G    /Users/masato-naka/
 80G    /Users/masato-naka//Library
 54G    /Users/masato-naka//Library/Containers
 17G    /Users/masato-naka//Library/Caches
...

なぜかLibrary/Containersの下が54Gもある

sudo du -x -h -d 2 /Users/masato-naka//Library/Containers/com.docker.docker/Data | sort -h -r | head -30
 50G    /Users/masato-naka//Library/Containers/com.docker.docker/Data/vms/0
 50G    /Users/masato-naka//Library/Containers/com.docker.docker/Data/vms
 50G    /Users/masato-naka//Library/Containers/com.docker.docker/Data
 60M    /Users/masato-naka//Library/Containers/com.docker.docker/Data/log
 41M    /Users/masato-naka//Library/Containers/com.docker.docker/Data/log/vm
 19M    /Users/masato-naka//Library/Containers/com.docker.docker/Data/log/host
 20K    /Users/masato-naka//Library/Containers/com.docker.docker/Data/tasks

Dockerの仕業だった

以下のコマンドでチェックしてみるとVolumeが大量にありそうだった

docker system df -v

解決方法

以下のコマンドでvolumeをprune

docker volume prune -f
...
Total reclaimed space: 44.06GB

これでもまだ 50Gは消えていない

[Reset disk image] を押してすべて消す

image.png

image.png

sudo du -x -h -d 2 /Users/masato-naka//Library/Containers/com.docker.docker/ | sort -h -r | head -30
 70M    /Users/masato-naka//Library/Containers/com.docker.docker//Data
 70M    /Users/masato-naka//Library/Containers/com.docker.docker/
 63M    /Users/masato-naka//Library/Containers/com.docker.docker//Data/log
164K    /Users/masato-naka//Library/Containers/com.docker.docker//Data/vms
 20K    /Users/masato-naka//Library/Containers/com.docker.docker//Data/tasks

開放できた!

df で確認するとほぼ全部なくなってる!

docker system df -v
Images space usage:

REPOSITORY                           TAG                 IMAGE ID            CREATED             SIZE                SHARED SIZE         UNIQUE SIZE         CONTAINERS
k8s.gcr.io/kube-controller-manager   v1.15.5             1399a72fa1a9        5 months ago        158.8MB             42.32MB             116.5MB             0
k8s.gcr.io/kube-proxy                v1.15.5             cbd7f21fec99        5 months ago        82.41MB             42.32MB             40.09MB             0
k8s.gcr.io/kube-apiserver            v1.15.5             e534b1952a0d        5 months ago        206.9MB             42.32MB             164.6MB             0
k8s.gcr.io/kube-scheduler            v1.15.5             fab2dded59dd        5 months ago        81.11MB             42.32MB             38.79MB             0
k8s.gcr.io/coredns                   1.3.1               eb516548c180        14 months ago       40.3MB              0B                  40.3MB              0
k8s.gcr.io/etcd                      3.3.10              2c4adeb21b4f        15 months ago       258.1MB             0B                  258.1MB             0
k8s.gcr.io/pause                     3.1                 da86e6ba6ca1        2 years ago         742.5kB             0B                  742.5kB             0

Containers space usage:

CONTAINER ID        IMAGE               COMMAND             LOCAL VOLUMES       SIZE                CREATED             STATUS              NAMES

Local Volumes space usage:

VOLUME NAME         LINKS               SIZE

Build cache usage: 0B

CACHE ID            CACHE TYPE          SIZE                CREATED             LAST USED           USAGE               SHARED

参考

ローカル環境の docker を断捨離するためにやること

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

zmq4でFizzBuzzする

初めに

Goのすごい人がついったーでgophernotes の pure go zeromq 対応が入った。とツイートしてた。
すごい人が言ってるんだから使っておいて損無いハズ。

5分ぐらい考えた結果、FizzBuzzすることに決めた。
プログラマならFizzBuzzとか数列とか使いたくなるんだからしょうがない。

できたもの

dekita.png

斬新なFizzBuzz結果

zeromqとは

ブローカーなしのメッセージング。ブローカー不要が何より嬉しい。
どうせ通信相手そんなにいないのに、いちいちサーバ立てたくない。
詳しくはZeroMQ参照。

FizzBuzzとは

よくやる奴。割愛。

【非検証】gophernotesを使おうとしたけど使えなかったので手順だけまとめる

gophernotesとは、Jupyter NotebookのGo言語版的な奴。詳しくはgophernotesを参照。

諸般の事情(Windowsしか持ってない)があるので、Dockerを利用する必要がある。
が、今現在Dockerが利用できる端末がない。
なので環境構築の順番だけでもまとめる。

簡単に利用するためにgopherdata/gophernotesをベースとして自分の使いやすいイメージを作る。

Dockerfile
FROM gopherdata/gophernotes:latest

RUN go get github.com/go-zeromq/zmq4 \
  && mkdir -p /etc/jupyter

COPY ./entrypoint.sh /entrypoint.sh
COPY ./jupyter_notebook_config.py /etc/jupyter/jupyter_notebook_config.py

ENTRYPOINT [ "/entrypoint.sh" ]

entrypoint.sh
jupyter notebook --no-browser --allow-root --ip=0.0.0.0  --config=/etc/jupyter/jupyter_notebook_config.py

configはgopherdata/gophernotesのイメージをいったん走らせてから、コンテナに吐いてもらう。

# コンテナ走らせて中に入る
docker run -it -d gopherdata/gophernotes
docker exec -it ${gophernotesのid} /bin/sh
# config吐く
jupyter noteboopk --generate-config
mv /path/to/jupyter_notebook_config.py /tmp/
exit
# ローカルに落とす
docker cp ${gophernotesのid}:/tmp/jupyter_notebook_config.py ./

手元で試すだけなら、jupyter_notebook_config.pyのtokenを書き変える。
後はコンテナをビルドして実行するだけ

docker build -t miyatamagophernotes:1.0.0 .
docker run -it -p 8888:8888 miyatamagophernotes:1.0.0

zmq4

まずはpub側から

publish側

1秒おきに100までのFizzBuzzを垂れ流す。

package main

import (
    "fmt"
    "time"
    "context"
    "sync"
    "github.com/go-zeromq/zmq4"
)

func main() {
    err := startPublisher()
    if err != nil {
        fmt.Printf("%v", err)
    }
}

func startPublisher() error {
    pub := zmq4.NewPub(context.Background())
    defer pub.Close()

    err := pub.Listen("tcp://*:5563")
    if err != nil {
        return err
    }
    for {
        publishFizzBuzz(pub)
        if err != nil {
            return err
        }
        time.Sleep(time.Second)
    }
    return nil
}

func publishFizzBuzz(publisher zmq4.Socket) {
    getFizzBuzzText := func(num int) string {
        if (num % 15) == 0  {
            return "FizzBuzz"
        }
        if (num % 3) == 0 {
            return "Fizz"
        }
        if (num % 5) == 0 {
            return "Buzz"
        }
        return fmt.Sprintf("%d", num)
    }

    wg := &sync.WaitGroup{}
    for i := 0; i < 100 ; i++ {
        wg.Add(1)
        go func(number int) {
            defer wg.Done()
            msg := zmq4.NewMsgFrom(
                []byte("FizzBuzz"),
                []byte(getFizzBuzzText(number)),
            )
            publisher.Send(msg)
        }(i + 1)
    }
    wg.Wait()
}

続いてsub

Subscribe側

受け取って表示するだけ

package main

import (
    "context"
    "fmt"
    "github.com/go-zeromq/zmq4"
)

func main() {

    err := startSubscribe()
    if err != nil {
        fmt.Printf("%v", err)
    }
}

func startSubscribe() error {
    sub := zmq4.NewSub(context.Background())
    defer sub.Close()

    err := sub.Dial("tcp://localhost:5563")
    if err != nil {
        return err
    }

    err = sub.SetOption(zmq4.OptionSubscribe, "FizzBuzz")
    if err != nil {
        return err
    }

    for {
        msg, err := sub.Recv()
        if err != nil {
            return err
        }
        fmt.Printf("%s\n", msg.Frames[1])
    }
}

ふりかえり

今回も様々な知見を得られた

  • FizzBuzzをgophernotes上のZeroMQでやる必要は全くない
  • gophernotesが利用できなかったので単純にzmq4でFizzBuzzしただけになった
  • 並列処理したおかげでFizzBuzzの結果が正しいかよく分からなくなった
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

既存の開発環境にDockerを導入する

はじめに

今まではAWSのcloud9でrailsアプリを開発していましたが、実際の開発現場で使われているというDockerを開発環境に導入しようと思います。

※導入する際には、以下の記事を参考にさせていただきました。
丁寧すぎるDocker-composeによるrails5 + MySQL on Dockerの環境構築(Docker for Mac)

環境

Ruby 2.5.3
Rails 5.2.4
MySQL 5.7
MacBook Pro

1 自分のアプリのディレクトリ直下にDockerfileを作成する

Dockerfile
FROM ruby:2.5.3
RUN apt-get update -qq && apt-get install -y vim nodejs default-mysql-client
COPY . /fishingshares
ENV APP_HOME /fishingshares
WORKDIR $APP_HOME
RUN bundle install
ADD . $APP_HOME

2 docker-compose.ymlを作成する

docker-compose.yml
version: '3'
services:
  db:
    image: mysql:5.7
    volumes:
      - ./db/mysql_data:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: root
    ports:
      - "3306:3306"

  web:
    build: .
    command: rails s -p 3000 -b '0.0.0.0'
    volumes:
      - .:/fishingshares
    ports:
      - "3000:3000"
    depends_on:
      - db
    links:
      - db

3 database.ymlを修正する

database.yml
default: &default
  adapter: mysql2
  encoding: utf8
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  socket: /var/lib/mysql/mysql.sock
  host: db  

4 dockerコンテナを起動

terminal
$ docker-compose build #コンテナを立ち上げる(立ち上げるのに少し時間かかります)

$ docker-compose up #コンテナを起動する

コンテナを起動して、ターミナルにこんなログが出てきたらうまくいってます。
42e21645ac242315e5be02351fa4a428.png

5 データベースを構築する

terminal
 docker-compose run web rails db:create #コンテナ上にDBを作成する
$ docker-compose run web rails db:migrate # コンテナ上のDBにマイグレーションファイルを反映させる

6 ブラウザでlocalhost:3000にアクセスする

ff817755ba5c7d950856db45e18d7a97.jpg
アプリケーションが無事に表示されました!

メモ1

コンテナを立ち上げた後に、以下のコマンドできちんとコンテナを止める必要があるみたいです。

terminal
$ docker-compose down

このコマンドでコンテナを止めないと、次にコンテナを起動したときに、ブラウザでうまく表示されません。

コンテナを起動してから、ブラウザに表示されない時は、以下のコマンドを実施してください。

terminal
$ rm tmp/pids/server.pid

このコマンドを実施してから、もう一度コンテナを起動すると、ブラウザにうまく表示されると思います。

メモ2

ローカルからMySQLコンテナに接続するコマンド

terminal
$ mysql -u root -p -h localhost -P 3306 --protocol=tcp

終わりに

dockerについては学習を始めてから、まだ数週間で慣れない点も多いですが、使いこなせるように勉強をしていきます。

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

【Docker】コンテナ一括削除のコマンド

はじめに

Dockerのコンテナを削除しようとして調べてたら色々なコマンドが出たので調べてみた。

検索してたら出てきたコマンド

コンテナ一括削除

$ docker rm -f `docker ps -a -q`
$ docker rm `docker ps -a -q`
$ docker rm -f `docker ps -aq`
$ docker ps -aq | xargs docker rm
$ docker container ls -aq | xargs docker container rm -f
$ docker container prune

概ね似たような感じ。
オプションの有無やxargsを使ってるかどうかが違うところか。
pruneは古いバージョンだと使えない。

xargs
「コマンドA | xargs コマンドB」で、コマンドAの実行結果を引数にしてコマンドBを実行する。

公式のドキュメントだとどうなってるか

コンテナの一括削除その1

$ docker rm $(docker ps -a -q)

()の中で全てのコンテナのIDを取得してrmで削除。
 -a:全てのコンテナを取得
 -q:コンテナのIDを取得

コンテナの一括削除その2

$ docker container prune

versionが1.25以上だと使える。$docker versionで確認。

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

【Laradock環境構築】Dockerインストール〜Laravelデフォルトページ表示まで①

はじめに

こちらの記事では、Laradockの環境構築として、
Dockerのインストール〜Laravelのデフォルトページ表示までを載せていきます。
このページでは、パート①ということで、Dockerのインストール方法を載せていきます。
(Mac版しか載せていません)

次回のパート②はこちら↓
https://qiita.com/y-aimi/items/a6b3ee863ec67c8d4b97

Dockerインストール

下記リンクからインストールできます。
https://hub.docker.com/editions/community/docker-ce-desktop-mac

image.png

Sign Upをクリック

image.png

全て入力し、Sign Upをクリック
(プロフィール登録などが出ますが、今回は必要ないので無視)
すると、登録したメールアドレスに確認のメールが届いているのでチェック。

image.png

[Verify email address]をクリック。
すると、Sign Inの画面が表示されるのでSign Inします。

image.png

image.png

Docker.dmg がインストールされたかと思いますので、こちらを開く。
image.png
上記のような画面が出るので、左のクジラのイラストをApplicationsにドラッグ&ドロップ。
(このような画面が出なくてもApplicationに入れれば大丈夫です。)

アプリケーションのDockerを起動。
すると、初回は下記のアラートが出ます。

image.png

これは、
「ネットからインストールしたアプリだから確認のためにパスワード教えてくれない?」
ということです。

問題なければOKをクリック。
そのままMacのパスワードを入力。

すると、このような画面が表示されるので、Sing Inします。
image.png
[Docker Desktop is now up and running!] に変われば大丈夫です。
image.png

無事起動されているかのチェックは画面の上にこののマークがあれば大丈夫です。
image.png
(クジラさんがきちんとコンテナを積んでくれていますね...!!)

まとめ

これで、Dockerのインストールが無事終わりました!
次回は、エラーが起きまくって困ったLaradockの環境構築に行きたいと思います。

次へ

Laradock環境構築】Dockerインストール〜Laravelデフォルトページ表示まで②
https://qiita.com/y-aimi/items/a6b3ee863ec67c8d4b97

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