夜明け前の最も暗いとき

技術的なやったことをメモするブログ

Pacemakerによる冗長構成を試す

PacemakerはLinuxで使われる冗長化ソフトウェアです。複数ノードでACT/SBY構成などによる冗長を確保したい場合に使用されます。 今回は仮想環境上に3ノードのクラスターを構成し、Apaceh Webサーバを実行します。

VMを下記の通り作成します。

■cluster1.example.net
 IP:192.168.11.121/24
■cluster2.example.net
 IP:192.168.11.122/24
■cluster3.example.net
 IP:192.168.11.123/24

Pacemakerの構築はこのサイトを参考にさせていただきました。今回は複数ノードあるため、構成管理ツールであるansibleで環境を構築します。 作業用の環境にansibleをインストールし、下記のplaybookをcluster_setup.ymlという名前で作成します。

 - hosts: cluster_nodes
   become: yes
   vars:
           - CLUSTER_PASS: 'cluster_passw0rd'

   tasks:
           - name: Update packages latest
             yum:
                     name: '*'
                     state: latest

           - name: Install pacemaker packages
             yum:
                     name: pacemaker,pcs,fence-agents,httpd
                     state: installed

           - name: Start services
             systemd:
                     name: "{{ item }}"
                     enabled: yes
                     state: started
             with_items:
                     - pcsd

           - name: Enable Corosync and Pacemaker service
             systemd:
                     name: "{{ item }}"
                     enabled: yes
             with_items:
                     - corosync
                     - pacemaker

           - name: Change hacluster password
             user:
                     name: hacluster
                     password: "{{ CLUSTER_PASS | password_hash('sha512') }}"

           - name: Allow Pacemaker Connection
             firewalld:
                     service: "{{ item }}"
                     permanent: yes
                     immediate: yes
                     state: enabled
             with_items:
                     - high-availability
                     - http

           - name: Check hosts file
             shell: "grep 192.168.11.12 /etc/hosts | wc -l"
             register: test_hosts

           - name: Add cluster hosts to /etc/hosts
             lineinfile:
                     path: "/etc/hosts"
                     state: present
                     line: |
                             192.168.11.121    cluster1.example.net
                             192.168.11.122    cluster2.example.net
                             192.168.11.123    cluster3.example.net
             when: test_hosts.stdout == "0"

           - name: Configure HTTPD status page
             copy:
                     dest: "/etc/httpd/conf.d/status.conf"
                     content: |
                             ExtendedStatus On
                             <Location /server-status>
                               SetHandler server-status
                               Require local
                             </Location>

           - name: Make sure Apache server disable by default
             systemd:
                     name: httpd
                     enabled: no
                     state: stopped

           - name: Add Test content
             shell: "echo \"running on `hostname`\" > /var/www/html/test.txt"

inventory.txtを作成し、作業対象のサーバを記述します。

[cluster_nodes]
192.168.11.121
192.168.11.122
192.168.11.123

playbookを実行します。

[jinglan@ansible-server ~]# ansible-playbook -i inventory.txt cluster_setup.yml  --ask-become-pass -vv

しばらくすると環境構築が終わります。結果にfailedが表示されていないことを確かめてください。

次からの作業はクラスタのうち一つにログインして作業します。3つのノードを登録しクラスターとして動作させます。

[root@cluster1 ~]# pcs cluster auth cluster1.example.net cluster2.example.net cluster3.example.net -u hacluster
Password: (playbookで定義したパスワードを入力)
cluster2.example.net: Authorized
cluster1.example.net: Authorized
cluster3.example.net: Authorized

[root@cluster1 ~]# pcs cluster setup --name httpcluster cluster1.example.net cluster2.example.net cluster3.example.net
Destroying cluster on nodes: cluster1.example.net, cluster2.example.net, cluster3.example.net...
cluster3.example.net: Stopping Cluster (pacemaker)...
cluster1.example.net: Stopping Cluster (pacemaker)...
cluster2.example.net: Stopping Cluster (pacemaker)...
cluster1.example.net: Successfully destroyed cluster
cluster2.example.net: Successfully destroyed cluster
cluster3.example.net: Successfully destroyed cluster

Sending 'pacemaker_remote authkey' to 'cluster1.example.net', 'cluster2.example.net', 'cluster3.example.net'
cluster1.example.net: successful distribution of the file 'pacemaker_remote authkey'
cluster2.example.net: successful distribution of the file 'pacemaker_remote authkey'
cluster3.example.net: successful distribution of the file 'pacemaker_remote authkey'
Sending cluster config files to the nodes...
cluster1.example.net: Succeeded
cluster2.example.net: Succeeded
cluster3.example.net: Succeeded

Synchronizing pcsd certificates on nodes cluster1.example.net, cluster2.example.net, cluster3.example.net...
cluster2.example.net: Success
cluster1.example.net: Success
cluster3.example.net: Success
Restarting pcsd on the nodes in order to reload the certificates...
cluster2.example.net: Success
cluster1.example.net: Success
cluster3.example.net: Success

[root@cluster1 ~]# pcs cluster start --all
cluster1.example.net: Starting Cluster (corosync)...
cluster2.example.net: Starting Cluster (corosync)...
cluster3.example.net: Starting Cluster (corosync)...
cluster1.example.net: Starting Cluster (pacemaker)...
cluster3.example.net: Starting Cluster (pacemaker)...
cluster2.example.net: Starting Cluster (pacemaker)...

[root@cluster1 ~]# pcs property set stonith-enabled=false
[root@cluster1 ~]#

クラスターを構成したので現在の状態を確認します。

[root@cluster1 ~]# pcs status
Cluster name: httpcluster
Stack: unknown
Current DC: NONE
Last updated: Sat Jun 27 15:55:09 2020
Last change: Sat Jun 27 15:55:04 2020 by root via cibadmin on cluster1.example.net

3 nodes configured
0 resources configured

OFFLINE: [ cluster1.example.net cluster2.example.net cluster3.example.net ]

No resources


Daemon Status:
  corosync: active/disabled
  pacemaker: active/disabled
  pcsd: active/enabled

3つのノードが認識されていますが、リソースが設定されていないためOFFLINE状態になっています。 そのため、クラスターで冗長化するリソースについて設定します。今回は冗長化されたIPアドレス(192.168.11.120)とHTTPサーバを定義します。

[root@cluster1 ~]# pcs resource create VIP ocf:heartbeat:IPaddr2 ip=192.168.11.120 cidr_netmask=24 op monitor interval=10s on-fail="standby" --group httpgroup
[root@cluster1 ~]# pcs resource create HTTPD ocf:heartbeat:apache configfile="/etc/httpd/conf/httpd.conf" statusurl="http://127.0.0.1/server-status" --group httpgroup

HTTPDとVIPは同じリソースグループに属しているので一緒に切り替わります。 再度、pacemakerの状態を表示します。

[root@cluster1 ~]# pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster1.example.net (version 1.1.21-4.el7-f14e36fd43) - partition with quorum
Last updated: Sat Jun 27 10:25:59 2020
Last change: Fri Jun 26 22:00:25 2020 by root via cibadmin on cluster1.example.net

3 nodes configured
2 resources configured

Online: [ cluster1.example.net cluster2.example.net cluster3.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster1.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster1.example.net

Daemon Status:
  corosync: active/enabled
  pacemaker: active/enabled
  pcsd: active/enabled

Resource Groupの表示に「Started cluster1.example.net」と記載があり、cluster1で実行されていることがわかります。実際にVIPアドレスへHTTPリクエストを飛ばして確認してみます。

$ curl http://192.168.11.120/test.txt
running on cluster1.example.net

応答からcluster1でHTTP処理が行われたことが分かります。 続いてcluster1を疑似的に停止します。その前に、別ターミナルから連続でHTTPリクエストを飛ばし、切り替え状態をモニターします。

$ while : ; do curl http://192.168.11.120/test.txt ; sleep 1; done;
running on cluster1.example.net

VMを実行しているホストにて下記コマンドでVMを停止します。

# virsh suspend cluster1

モニターしているターミナルでは一時疎通不可になったのち、切り替わることが確認できます。

running on cluster1.example.net
running on cluster1.example.net
curl: (7) Failed to connect to 192.168.11.120 port 80: Connection refused
curl: (7) Failed to connect to 192.168.11.120 port 80: Connection refused
running on cluster2.example.net
running on cluster2.example.net
running on cluster2.example.net

次に、cluster3を停止します。

# virsh suspend cluster3

モニター側ではWebサイトからの応答がなくなります。 cluster2側で状態を確認します。

[jinglan@cluster2 ~]$ sudo pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster2.example.net (version 1.1.21-4.el7-f14e36fd43) - partition WITHOUT quorum
Last updated: Sat Jun 27 13:54:09 2020
Last change: Sat Jun 27 13:52:53 2020 by root via cibadmin on cluster1.example.net

3 nodes configured
2 resources configured

Online: [ cluster2.example.net ]
OFFLINE: [ cluster1.example.net cluster3.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Stopped
     HTTPD      (ocf::heartbeat:apache):        Stopped

Daemon Status:
  corosync: active/enabled
  pacemaker: active/enabled
  pcsd: active/enabled

Resource Groupが停止していることが確認できます。3ノード構成ではquorumによりアクティブになるノードが決定されます。これにより、2ノードから疎通ができなくなったノードは冗長構成から切り離されたと判断し、リソースの競合を防ぐため停止します。

停止させたcluster1とcluster3を再開します。

# virsh resume cluster1
# virsh resume cluster3

cluster1とcluster3が復帰したことでリソースはcluster1に戻ります。

[root@cluster1 ~]# pcs status
 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster1.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster1.example.net

特定のホストでリソースを実行したい場合はstandbyを使います。cluster1とcluster2をstandby状態にして、cluster3で実行させるようにします。

[root@cluster1 ~]# pcs cluster standby cluster1.example.net
[root@cluster1 ~]# pcs cluster standby cluster2.example.net
[root@cluster1 ~]# pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster3.example.net (version 1.1.21-4.el7-f14e36fd43) - partition with quorum
Last updated: Sat Jun 27 15:59:00 2020
Last change: Sat Jun 27 15:58:54 2020 by root via cibadmin on cluster1.example.net

3 nodes configured
2 resources configured

Node cluster1.example.net: standby
Node cluster2.example.net: standby
Online: [ cluster3.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster3.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster3.example.net

リソースはcluster3で実行を続けます。なお、この時にcluster3が停止状態になるとリソースの移動先がないために切り替わりが起きません。 standby状態はunstandbyコマンドにより解除することができます。

[root@cluster1 ~]# pcs cluster unstandby --all

standbyによるACT/SBY構成の切り替えはシングル障害点になってしまいます。特定のノードにリソースを移したい場合はresource moveにより切り替えます。

[root@cluster1 ~]# pcs resource move httpgroup cluster1.example.net
[root@cluster1 ~]# pcs status
(略)
 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster1.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster1.example.net

次に、リソース起動に失敗した場合の動作について確認してみます。Apacheの設定ファイルを取り除きます。

[root@cluster1 ~]# cd /etc/httpd/conf/
[root@cluster1 conf]# mv httpd.conf httpd.conf.bak

現在動いているプロセスをkillします。

[root@cluster1 ~]# ps aux | grep httpd
root     24998  0.0  0.3 224080  3468 ?        Ss   Jun27   0:18 /sbin/httpd -DSTATUS -f /etc/httpd/conf/httpd.conf -c PidFile /var/run/httpd.pid

[root@cluster1 ~]# kill 24998

ログを確認すると起動に失敗したため系切り替えが発生したことが分かります。

[root@cluster1 conf]# tail /var/log/messages
Jun 29 22:24:19 cluster1 lrmd[22953]:  notice: HTTPD_start_0:17745:stderr [ ocf-exit-reason:Configuration file /etc/httpd/conf/httpd.conf not found! ]
Jun 29 22:24:19 cluster1 lrmd[22953]:  notice: HTTPD_start_0:17745:stderr [ ocf-exit-reason:environment is invalid, resource considered stopped ]
Jun 29 22:24:19 cluster1 crmd[22956]:  notice: Result of start operation for HTTPD on cluster1.example.net: 5 (not installed)
Jun 29 22:24:19 cluster1 crmd[22956]:  notice: cluster1.example.net-HTTPD_start_0:24 [ ocf-exit-reason:Configuration file /etc/httpd/conf/httpd.conf not found!\nocf-exit-reason:environment is invalid, resource considered stopped\n ]
Jun 29 22:24:19 cluster1 apache(HTTPD)[17773]: ERROR: Configuration file /etc/httpd/conf/httpd.conf not found!
Jun 29 22:24:19 cluster1 apache(HTTPD)[17773]: INFO: environment is invalid, resource considered stopped
Jun 29 22:24:19 cluster1 lrmd[22953]:  notice: HTTPD_stop_0:17773:stderr [ ocf-exit-reason:Configuration file /etc/httpd/conf/httpd.conf not found! ]
Jun 29 22:24:19 cluster1 crmd[22956]:  notice: Result of stop operation for HTTPD on cluster1.example.net: 0 (ok)
Jun 29 22:24:19 cluster1 IPaddr2(VIP)[17800]: INFO: IP status = ok, IP_CIP=
Jun 29 22:24:19 cluster1 crmd[22956]:  notice: Result of stop operation for VIP on cluster1.example.net: 0 (ok)

現在の状態を確認します。

[root@cluster1 conf]# pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster3.example.net (version 1.1.21-4.el7-f14e36fd43) - partition with quorum
Last updated: Mon Jun 29 22:24:27 2020
Last change: Sat Jun 27 16:07:21 2020 by root via crm_resource on cluster1.example.net

3 nodes configured
2 resources configured

Online: [ cluster1.example.net cluster2.example.net cluster3.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster2.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster2.example.net

Failed Resource Actions:
* HTTPD_start_0 on cluster1.example.net 'not installed' (5): call=24, status=complete, exitreason='environment is invalid, resource considered stopped',
    last-rc-change='Mon Jun 29 22:24:19 2020', queued=0ms, exec=36ms

起動に失敗したことがFailed Resource Actionsに表示されています。取り除いたApacheの設定ファイルを元に戻します。

mv httpd.conf.bak httpd.conf

これでcluster1のApacheは正常に起動できるはずです。cluster2とcluster3をstanbyにしてみます。

[root@cluster1 conf]# pcs cluster standby cluster2.example.net
[root@cluster1 conf]# pcs cluster standby cluster3.example.net
[root@cluster1 conf]# pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster3.example.net (version 1.1.21-4.el7-f14e36fd43) - partition with quorum
Last updated: Mon Jun 29 22:34:30 2020
Last change: Mon Jun 29 22:34:13 2020 by root via cibadmin on cluster1.example.net

3 nodes configured
2 resources configured

Node cluster2.example.net: standby
Node cluster3.example.net: standby
Online: [ cluster1.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster1.example.net
     HTTPD      (ocf::heartbeat:apache):        Stopped

Failed Resource Actions:
* HTTPD_start_0 on cluster1.example.net 'not installed' (5): call=24, status=complete, exitreason='environment is invalid, resource considered stopped',
    last-rc-change='Mon Jun 29 22:24:19 2020', queued=0ms, exec=36ms

HTTPDについてはリソースは停止したままです。たとえノードがOnline状態であっても、Failed Resource Actionsとして傷害の記録が残っているものは切り替わりません。そのため、下記コマンドで障害の状態をリセットする必要があります。

[root@cluster1 conf]# pcs resource cleanup
Cleaned up VIP on cluster3.example.net
Cleaned up VIP on cluster2.example.net
Cleaned up VIP on cluster1.example.net
Cleaned up HTTPD on cluster3.example.net
Cleaned up HTTPD on cluster2.example.net
Cleaned up HTTPD on cluster1.example.net
Waiting for 1 reply from the CRMd. OK

[root@cluster1 conf]# pcs status
Cluster name: httpcluster
Stack: corosync
Current DC: cluster3.example.net (version 1.1.21-4.el7-f14e36fd43) - partition with quorum
Last updated: Mon Jun 29 22:41:35 2020
Last change: Mon Jun 29 22:41:30 2020 by hacluster via crmd on cluster1.example.net

3 nodes configured
2 resources configured

Node cluster2.example.net: standby
Node cluster3.example.net: standby
Online: [ cluster1.example.net ]

Full list of resources:

 Resource Group: httpgroup
     VIP        (ocf::heartbeat:IPaddr2):       Started cluster1.example.net
     HTTPD      (ocf::heartbeat:apache):        Started cluster1.example.net

Failed Resource Actionsについては見落としやすいので注意したほうがよいです。

Open vSwitchでパケットロスのトラブルシューティング

Open vSwitch(OVS)を使った仮想マシン上で特定のサイトでWebページの読み込みができない事象が発生しました。 仮想環境の構成は以下の通りです。

f:id:jianlan:20200612175353j:plain

VM側では下記のように外部セグメント(br-outside)と内部セグメント(br-inside)に接続されています。

jinglan@vm-linux:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 52:54:00:bf:71:af brd ff:ff:ff:ff:ff:ff
    inet 172.16.0.8/16 brd 172.31.255.255 scope global enp1s0
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:ff:febf:71af/64 scope link
       valid_lft forever preferred_lft forever
3: enp7s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 52:54:00:ae:d2:98 brd ff:ff:ff:ff:ff:ff
    inet6 2400::feae/64 scope global dynamic mngtmpaddr
       valid_lft 2591933sec preferred_lft 604733sec
    inet6 fe80::d298/64 scope link
       valid_lft forever preferred_lft forever
続きを読む

Zabbixでログ監視環境を構築する

少し前に話題になった入門監視を読みました。

入門 監視 ―モダンなモニタリングのためのデザインパターン

入門 監視 ―モダンなモニタリングのためのデザインパターン

  • 作者:Mike Julian
  • 発売日: 2019/01/17
  • メディア: 単行本(ソフトカバー)

どういった監視項目があるのか、実用的な監視環境や方式についてノウハウを学べる良い書籍だと思います。 この本の中でsyslogについて触れられていました。

昔は、シンプルなsyslog受信サーバである中央ログサーバにすべてのログを送り、Unix系OSで標準的なツール(例えばgrepなど)でログを検索していました。これはログを検索しにくいという点で次善の策でした。この方法でログを保存すると、たいていの場合は誰もログを見ず、活用することもありません。(p. 127)

書かれている通りで、ログを収集してもいちいちログを確認したりはしないものです。とはいえ、再起動でログがリセットされるCisco系装置やサーバでログが失われるようなトラブルが発生したときにはログ収集サーバは有益です。

ここで重要なのは、2度と見られることはないので、ログをsyslogサーバに送ってはいけないということです。その代わり、ログを活用して価値を得られるしっかりしたログ管理システムに送りましょう。(p. 128)

ログ管理システムはクラウドやオンプレなど多岐に渡りますが、今回はZabbixで簡易的な管理・監視環境を構築してみます。

続きを読む

Linuxでターミナルの出力に色を付ける

Linuxのターミナルでは制御文字を使うことで色を付けることができます。

例として、echoコマンドで以下のように文字を出力します。

echo -e "status: NORMAL"

この出力する文字に対し\e[<数字>mで囲むことによって指定した色の出力にできます。

echo -e "status: \e[36mNORMAL\e[0m"

f:id:jianlan:20191110115423j:plain

続きを読む

OpenStackインストールで躓いたところメモ

OpenStackを仮想環境上に構築しました。インストールに手間取ったので対処したときのメモです。

  • OpenStackのインストール
  • タイムアウトの対処
  • ネットワークエラー
  • 外部ネットワークとの通信失敗
続きを読む

Python + Djangoで動画共有サイトを作る Part4

前回の続きです。視聴ページとデプロイをします。

視聴ページ作成

動画を再生する視聴ページを作成します。HTML5ではvideoタグを使うことでブラウザがUIを提供してくれます。

  • video/templates/video/watch.html
{% extends "video/base.html" %}

{% block title %}{{ content.title }} - Video{% endblock %}

{% block header %}
{% load static %}
  <script src="{% static 'video/jquery-3.4.1.min.js' %}"></script>
  <script src="{% static 'video/watch.js' %}"></script>
{% endblock %}

{% block main %}
<br>
<br>

<h2>{{ content.title }}</h2><br>
{{ content.description }}
<hr>
tags:
{% for tag in tags %}
  <a href="../tag/{{ tag.tag.name }}">{{ tag.tag.name }}</a>
{% endfor %}
<br>
<hr>
<video id="video_content" src="/media/video/{{ content.id }}/{{ content.filename}}" poster="/media/video/{{ content.id }}/thumb.jpg" controls>
  動画の再生にはHTML5が必要です。
</video>
<hr>
<br>
{% endblock %}

再生画面で音量を変更した場合、その値を記憶するようにします。

  • video/static/video/watch.js
$(function() {

  function getCookie() {
    var cookie = [];
    if (document.cookie != '') {
      cookie_list = document.cookie.split(';');
      for (var item of cookie_list) {
        [key, value] = item.split('=');
        cookie[key.replace(' ', '')] = decodeURIComponent(value);
      }
    }
    return cookie;
  }

  function saveCookie(_key, _value) {
    document.cookie = _key + '=' + encodeURIComponent(_value) + '; max-age=31536000; path=/';
  }

  $(document).ready(function(){
    cookie = getCookie();
    if (cookie['volume']) {
      $('#video_content').get(0).volume = cookie['volume'];
    }
  });

  $('#video_content').on('volumechange', function() {
    saveCookie('volume', $('#video_content').get(0).volume);
  });

});

音量の値の記憶にはcookieを用います。

  • video/views.py
def watch(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)
    tags = VideoTagList.objects.filter(content_id=content_id).select_related('content')
    return render(request, 'video/watch.html', {'content':content, 'tags':tags})

コメントアウトを外した後にrunserverを実行してWebサイトにアクセスします。動画のリンクをクリックすると再生画面に飛びます。

デプロイ

いままで開発環境だったためrunserverを実行して動かしました。今後はHTTPサーバから呼び出す形で実行します。HTTPサーバとしてはapacheを使用します。

#yum install httpd python36u-mod_wsgi policycoreutils-python

インストールが完了したらデーモンとして起動します。

# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; disabled; vendor preset: disabled)
   Active: inactive (dead)
     Docs: man:httpd(8)
           man:apachectl(8)
#
# sudo systemctl start httpd
# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; disabled; vendor preset: disabled)
   Active: active (running) since Tue 2019-08-27 18:36:40 JST; 2s ago
     Docs: man:httpd(8)
           man:apachectl(8)
 Main PID: 26104 (httpd)
   Status: "Processing requests..."
   CGroup: /system.slice/httpd.service
           tq26104 /usr/sbin/httpd -DFOREGROUND
           tq26105 /usr/sbin/httpd -DFOREGROUND
           tq26106 /usr/sbin/httpd -DFOREGROUND
           tq26107 /usr/sbin/httpd -DFOREGROUND
           tq26108 /usr/sbin/httpd -DFOREGROUND
           mq26109 /usr/sbin/httpd -DFOREGROUND

Aug 27 18:36:40 localhost.localdomain systemd[1]: Starting The Apache HTTP Server...
Aug 27 18:36:40 localhost.localdomain httpd[26104]: AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using l... message
Aug 27 18:36:40 localhost.localdomain systemd[1]: Started The Apache HTTP Server.
Hint: Some lines were ellipsized, use -l to show in full.
#
# sudo systemctl enable httpd
Created symlink from /etc/systemd/system/multi-user.target.wants/httpd.service to /usr/lib/systemd/system/httpd.service.
#

続いて、HTTPサーバでWSGIが動作するように設定を変更します。まずはロケールの設定を変更します。

LANG=ja_JP.utf8

今回は日本語を使用するためロケール文字コードをUTF8に変更します。この設定を入れないと文字をASCIIとして扱うためUnicodeEncodeError: 'ascii' codec can't encode characters in position 40-43: ordinal not in range(128)というエラーが発生します。

続いてHTTPサーバの設定をします。今回は/var/www/django/以下にファイルを設置します。

WSGIScriptAlias / /var/www/django/mysite/mysite/wsgi.py
WSGIPythonPath /var/www/django/mysite/
<Directory "/var/www/django/mysite/video">
    <Files wsgi.py>
        Require all granted
    </Files>
</Directory>

Alias /static/ /var/www/django/mysite/video/static/
<Directory "/var/www/django/mysite/video/static">
  Require all granted
</Directory>

Alias /media/ /storage/
<Directory "/storage/">
  Require all granted
</Directory>

設定が完了したらHTTPサーバを再起動します。

# systemctl restart httpd
# sudo systemctl status httpd
● httpd.service - The Apache HTTP Server
   Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; vendor preset: disabled)
   Active: active (running) since Tue 2019-08-27 18:53:18 JST; 4s ago
     Docs: man:httpd(8)
           man:apachectl(8)
  Process: 26163 ExecStop=/bin/kill -WINCH ${MAINPID} (code=exited, status=0/SUCCESS)
 Main PID: 26167 (httpd)
   Status: "Processing requests..."
   CGroup: /system.slice/httpd.service
           tq26167 /usr/sbin/httpd -DFOREGROUND
           tq26168 /usr/sbin/httpd -DFOREGROUND
           tq26169 /usr/sbin/httpd -DFOREGROUND
           tq26170 /usr/sbin/httpd -DFOREGROUND
           tq26171 /usr/sbin/httpd -DFOREGROUND
           mq26172 /usr/sbin/httpd -DFOREGROUND

Aug 27 18:53:17 localhost.localdomain systemd[1]: Stopped The Apache HTTP Server.
Aug 27 18:53:17 localhost.localdomain systemd[1]: Starting The Apache HTTP Server...
Aug 27 18:53:17 localhost.localdomain httpd[26167]: AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using l... message
Aug 27 18:53:18 localhost.localdomain systemd[1]: Started The Apache HTTP Server.
Hint: Some lines were ellipsized, use -l to show in full.

HTTPサーバの設定ファイルで指定したフォルダに作成したdjangoプロジェクトのファイルを設置します。

# mkdir /var/www/django
# mkdir /var/www/django/mysite
# cp * /var/www/django/mysite/ -R
# chown apache:apache /var/www/django/mysite -R
# ls -l /var/www/django/mysite
/var/www/django/mysite:
total 4
-rwxr-xr-x. 1 apache apache 626 Aug 27 19:01 manage.py
drwxr-xr-x. 3 apache apache  93 Aug 27 19:01 mysite
drwxr-xr-x. 6 apache apache 188 Aug 27 19:01 video
#

また、ストレージについてもHTTPサーバからアクセスできるようにします。

# chown apache:apache -R /storage/video/
# semanage fcontext -a -t httpd_sys_rw_content_t '/storage/video(/.*)?'
# restorecon -v -R /storage/video/

djangoの設定も公開用に書き換えます。

  • mysite/settings.py
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False

ALLOWED_HOSTS = ['192.168.12.9']

djangoではデプロイ時に設定をチェックする機能があります。

$ python3 manage.py check --deploy

必要に応じて警告の内容を修正します。修正後は設定内容を反映するためHTTPサーバを再起動する必要があります。

# systemctl restart httpd

サーバの設定が完了したのでファイアウォールの設定を変更します。

$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client
  ports: 8000/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

$ sudo firewall-cmd --add-service http --zone=public --permanent
success
$ sudo firewall-cmd --reload
success
$ sudo firewall-cmd --info-zone=public
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s3
  sources:
  services: ssh dhcpv6-client http
  ports: 8000/tcp
  protocols:
  masquerade: no
  forward-ports:
  source-ports:
  icmp-blocks:
  rich rules:

今までのrunserverではhttp://192.168.12.9:8000/でアクセスしていましたが、HTTPサーバに配置したのでhttp://192.168.12.9/にアクセスします。 正常に表示・動作できていればOKです。

トラブルシューティング

ファイルが存在するのに500 Internal Errorになる

サーバのエラーに関する情報はerror_logに記録されています。

# tailf /var/log/httpd/error_log
[Tue Aug 27 19:13:30.382093 2019] [core:error] [pid 26296] (13)Permission denied: [client 192.168.12.4:57900] AH00035: access to /media/video/3/thumb.jpg denied (filesystem path '/storage/video/3/thumb.jpg') because search permissions are missing on a component of the path, referer: http://192.168.12.9/video/
[Tue Aug 27 19:13:31.346448 2019] [wsgi:error] [pid 26296] [client 192.168.12.4:57900] Not Found: /favicon.ico

Permission deniedの場合は権限に由来するものです。拒否されたファイルを調査します。

# ls -lZ storage/video/3/
-rw-------. root root unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. root root unconfined_u:object_r:default_t:s0 thumb.jpg

apache(HTTPサーバ)が所有者になっていない場合はchmodコマンドで所有者を変更します。

# chmod apache:apache /storage/video/3/video.mp4
# chmod apache:apache /storage/video/3/thumb.jpg
# ls -lZ /storage/video/3/
-rw-------. apache apache unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. apache apache unconfined_u:object_r:default_t:s0 thumb.jpg

また、SELinuxの権限が異なる場合もアクセスが拒否されます。SELinuxが動作しているかは次のコマンドで確認できます。

# getenforce
Enforcing

EnforcingであればSELinuxが動作しています。SELinuxは一時的に停止することができます。

# setenforce 0
# getenforce
Permissive

PermissiveであればSELinuxによるアクセス拒否は発生しません。アクセスに関するログはaudit.logに記録されています。

# tailf /var/log/httpd/error_log
type=AVC msg=audit(1566901506.108:729): avc:  denied  { getattr } for  pid=26343 comm="httpd" path="/storage/video/3/thumb.jpg" dev="dm-0" ino=17209734 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=1

HTTPサーバのコンテキストsystem_u:system_r:httpd_tではファイルのコンテキストunconfined_u:object_r:default_t:s0を読みことができないため拒否(denied)されています。一時的にファイルのコンテキストを変更してみます。

# chcon -t httpd_sys_content_t /storage/video/3/thumb.jpg
# ls -lZ /storage/video/3/
-rw-------. apache apache unconfined_u:object_r:user_tmp_t:s0 video.mp4
-rw-------. apache apache unconfined_u:object_r:httpd_sys_content_t thumb.jpg

これでコンテキストが変更されたので再度ブラウザからアクセスし、audit.logに拒否の記録が残るかどうか確かめます。問題なく表示された場合はSELinuxの設定を戻します。

# setenforce 1
# getenforce
Enforcing

chconは一時的なものなので新しくSELinuxのルールを作成します。

# semanage fcontext -a -t httpd_sys_rw_content_t '/storage/video(/.*)?'
# semanage fcontext -l | grep /storage/
/storage/video(/.*)?                               all files          system_u:object_r:httpd_sys_rw_content_t:s0

続いて、設定したルールを適用します。

# restorecon -v -R /storage/video/
restorecon reset /storage/video/3/video.mp4 context unconfined_u:object_r:user_tmp_t:s0->unconfined_u:object_r:httpd_sys_rw_content_t:s0

他のファイルのコンテキストもルールに従って再設定されました。

Python + Djangoで動画共有サイトを作る Part3

前回の続きです。前回はトップページを作成しましたので、残りを作っていきます。

タグ検索

タグ検索はURLが/tag/(検索文字列)の場合に検索文字列に合致する動画を返します。 動画の表示ページは前回construct_page関数として作ったので、表示する動画一覧を作って渡します。

  • video/views.py
def tag(request, tag_name, page=0):
    # tag_nameからIDを探し、見つかったIDを基にタグが付いた動画をフィルタする
    tag_id = VideoTagName.objects.filter(name=tag_name).get().id
    filtered_list = VideoTagList.objects.select_related('content').filter(tag=tag_id).order_by('-content__upload_date')

    max_page = filtered_list.count() // 10

    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.content.id, 'title':item.content.title} for item in content_list]

    return construct_page(request, filtered_list.values('content_id'), contents, page, max_page, 'video:tag', tag_name)

urls.pyのコメントアウトを外した後にrunserverを実行してWebサイトにアクセスします。タグのリンクをクリックすると/tag/TAG1のURLに飛び、検索タグに一致する動画が出てきます。

キーワード検索

キーワード検索では検索された文字列を基にデータベースを部分一致検索で探します。

def search(request, search_word, page=0):
    filtered_list = VideoContent.objects.filter(title__contains=search_word).order_by('-upload_date')
    max_page = filtered_list.count() // 10
    content_list = filtered_list[page*10:(page+1)*10]
    contents = [{'id':item.id, 'title':item.title} for item in content_list]

    return construct_page(request, filtered_list.values('id'), contents, page, max_page, 'video:search', search_word)

基本的な処理はタグ検索と同様です。データベースからの検索結果をconstruct_page関数に渡しページを作成します。

また、検索窓からのPOSTを受け持つ処理を実装します。

def search_post(request):
    if hasattr(request, 'POST') and 'search_text' in request.POST.keys():
        if request.POST['search_text'] != "":
            return HttpResponseRedirect(reverse('video:search', args=(request.POST['search_text'],)))

    return HttpResponseRedirect(reverse('video:index'))

検索ワードから検索ページに飛ばすURLへリダイレクトします。

アップロードページ作成

アップロードされたデータはストレージと呼ばれる保存領域に記録します。今回は/storage/video/というフォルダを作成し保存します。実際はデータストレージはWebサーバと別の筐体という構成にすることで可用性を向上させます。

# mkdir /storage/
# mkdir /storage/video
# ls /storage/ -l
total 0
drwxr-xr-x. 2 root root 6 Aug  4 17:32 video
# chown jinglan:jinglan /storage/video/
$ sudo ls /storage/ -l
total 0
drwxr-xr-x. 2 jinglan jinglan 6 Aug  4 17:32 video

フォルダを作成し権限を書き換えました。次に、設定ファイルにこのフォルダをストレージ領域として使うことを記述します。

  • mysite/settings.py
# Media directory
MEDIA_ROOT = '/storage/'
MEDIA_URL = '/media/'

また、URLからアクセスできるようサイトのurls.pyの最後にに追加します。

  • mysite/urls.py
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

保存場所の準備ができたのでアップロード画面を作成します。

  • video/templates/video/upload.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form action="" method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <br>
  <input type="submit" value="アップロード">
</form>

<br>
{% endblock %}

アップロードページのフォームはform.as_pでviews.pyの定義から自動的に作成されます。views.pyでは以下のように汎用ビューを用いたクラスを追加します。

  • video/views.py
from django.urls import reverse, reverse_lazy
from django.views import generic
from django import forms
from django.core.files.storage import default_storage, FileSystemStorage
from django.utils import timezone
from django.conf import settings

import ffmpeg

DATA_DIR = settings.MEDIA_ROOT + 'video/'

class VideoUploadForm(forms.Form):
    file = forms.FileField()

class UploadView(generic.FormView):
    form_class = VideoUploadForm
    template_name = 'video/upload.html'

    def form_valid(self, form):
        upload_filename = form.cleaned_data["file"].name

        content = VideoContent(title=upload_filename, description="", upload_date=timezone.now(), original_name=upload_filename, filename="")
        content.save()

        try:
            storage = FileSystemStorage()
            storage.location = DATA_DIR + str(content.id)
            filename = storage.save(upload_filename, form.cleaned_data["file"])
            make_video_thumb(DATA_DIR + str(content.id) + "/" + filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

        except:
            delete_video(content.id, filename)
            content.delete()
            raise

        else:
            content.filename = filename
            content.save()

            return HttpResponseRedirect(reverse('video:edit', args=(content.id,)))

VideoUploadFormクラスはアップロードページのフォームを規定します。今回はファイルのみです。 正常にユーザからファイルがアップロードされた後にform_validが呼ばれます。 content.save()によりデータベースに新しくレコードを追加され、ユニークなIDが割り当てられます。このIDを基に動画を保存するフォルダを作成し保存します。 正常に保存されたらmake_video_thumb関数によりサムネイルを作成します。

  • video/views.py
def make_video_thumb(src_filename, capture_frame, dst_filename=None):
    probe = ffmpeg.probe(src_filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    nframes = video_info['nb_frames']
    avg_frame_rate = (lambda x: int(x[0])/int(x[1])) (video_info['avg_frame_rate'].split('/'))
    start_position = int(capture_frame)/avg_frame_rate

    if dst_filename == None:
        out_target = 'pipe:'
    else:
        out_target = dst_filename

    im = (
        ffmpeg.input(src_filename, ss=start_position)
        .filter('scale', 200, -1)
        .output(out_target, vframes=1, format='image2', vcodec='mjpeg', loglevel='warning')
        .overwrite_output()
        .run(capture_stdout=True)
    )

    return im

ffmpegを使用し指定されたフレームの画像を生成しています。

もし、アップロード処理中にエラーが発生した場合はexceptが呼ばれ、delete_video関数により動画は削除されます。

  • video/views.py
def delete_video(content_id, video_filename):
    print('remove files at ' + str(content_id) + '/')
    storage = FileSystemStorage()
    storage.location = DATA_DIR
    storage.delete(str(content_id) + '/' + video_filename)
    storage.delete(str(content_id) + '/' + 'thumb.jpg')
    storage.delete(str(content_id) + '/')

正常に動画をアップロードできた場合はHttpResponseRedirectにより編集ページにリダイレクトされます。今回はまだ作成していないのでダミーページを返すようにします。

  • video/views.py
def edit(request, content_id):
    return HttpResponse("dummy")

それでは、urls.pyを編集してからアップロードしてみます。アップロード後はダミーページに飛ばされますがトップページに戻ると動画リストが増えています。

f:id:jianlan:20190805202650j:plain

編集ページ作成

アップロード後に動画の情報を入力する編集ページを作成します。このページでは、動画情報の編集のほかに、サムネイルの変更、タグの追加削除、動画削除機能を提供します。

  • video/templates/video/edit.html
{% extends "video/base.html" %}

{% block header %}
{% load static %}
  <script src="{% static 'video/jquery-3.4.1.min.js' %}"></script>
  <script src="{% static 'video/edit.js' %}"></script>
{% endblock %}

{% block main %}
<h2>編集中: {{ content.id }} - {{ content.title }}</h2>

<h3>VIDEO</h3>
<form action="{% url 'video:update' content.id %}" method="post">
  {% csrf_token %}
  <table border="0">
    <tr><td>thumb:</td>
    <td>
    <img id="thumb" src="{% url 'video:thumb' content.id content.thumb_frame %}">
    <input name="frame" id="thumb_frame" type="range" style="width:500px" min="0" max="{{ video_info.max_frame }}" step="1" value="{{ content.thumb_frame }}">
    <span id="current_thumb_frame">{{ content.thumb_frame }} </span><br>
    </td>
    </tr><tr><td>title:</td>
    <td>
    <input name="title" type="text" size="100" value="{{ content.title }}"><br>
    </td>
    </tr><tr><td>description:</td>
    <td>
    <textarea name="desc" rows="5" cols="100">{{ content.description }}</textarea>
    </td>
    </tr>
  </table>
  <br>
  <input type="submit" value="決定">
</form>
<br>

<hr>

<h3>TAG</h3>
<form action="{% url 'video:update_add_tag' content.id %}" method="post">
  <table border="0">
  {% for tag in tags %}
    <tr><td>{{ tag.tag.name }}</td><td><input type="submit" value="削除" formaction="{% url 'video:update_remove_tag' content.id tag.tag.name %}"></td></tr>
  {% endfor %}
  </table>
  <input type="text" name="tag" value="">
  <input type="submit" value="追加">
  {% csrf_token %}
</form>
<br>
<hr>

<a href="{% url 'video:delete' content.id %}">削除する</a>
<br>
{% endblock %}

サムネイルの取得にはⒿjQueryを使い非同期的に行います。jquery-3.4.1.min.jsを公式サイトからダウンロードしstaitcに配置します。 また、サムネイル取得を実行するedit.jsも作成します。

  • video/static/video/edit.js
$(function() {
  $('#thumb_frame').on('input', function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());
  });

  $('#thumb_frame').change(function() {
    $('#current_thumb_frame').text($('#thumb_frame').val());

    var image_dir = $('#thumb').attr('src').split('/');
    image_dir.pop();
    $('#thumb').attr('src', image_dir.join('/') + '/' + $('#thumb_frame').val());
  });
});

range入力が変更されるたびにサーバにframe情報を送り、サムネイル画像を取得します。

それではviews.pyに編集ページを実装します。

  • video/views.py
def edit(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)

    probe = ffmpeg.probe(DATA_DIR + str(content.id) + "/" + content.filename)
    video_info = next(x for x in probe['streams'] if x['codec_type'] == 'video')
    info = {'max_frame': video_info['nb_frames']}

    tags = VideoTagList.objects.filter(content_id=content_id).select_related('content')

    return render(request, 'video/edit.html', {'content':content, 'video_info':info, 'tags':tags})

urls.pyを編集し、編集ページにアクセスします。ページは表示されますがサムネイルやボタンは動作を実装していないため動きません。

では、まず最初にサムネイルから実装します。サムネイルは/video/edit/(動画ID)/thumb/(フレーム)というURLで読み込まれます。

  • video/views.py
def thumb(request, content_id, frame):
    content = get_object_or_404(VideoContent, pk=content_id)
    im = make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, frame)
    return HttpResponse(im, content_type="image/jpeg")

URLを基にサムネイルを作成し画像であるimage/jpegとして返します。

f:id:jianlan:20190805221028j:plain

これでスライダーを変更したときに動的にサムネイル画像をプレビューできるようになりました。 続いて、変更した値を保存する処理を実装します。

  • video/views.py
def update(request, content_id):
    content = get_object_or_404(VideoContent, pk=content_id)
    content.title = request.POST['title']
    content.thumb_frame = request.POST['frame']
    content.description = request.POST['desc']
    content.save()

    make_video_thumb(DATA_DIR + str(content.id) + "/" + content.filename, content.thumb_frame, DATA_DIR + str(content.id) + "/thumb.jpg")

    return HttpResponseRedirect(reverse('video:index'))

これで編集ページで書き換えた値やサムネイルが一覧に反映されるようになりました。

f:id:jianlan:20190805222058j:plain

今度はタグの編集機能を実装します。

  • video/views.py
def update_add_tag(request, content_id):
    if request.POST["tag"] != "":
        tag = VideoTagName.objects.filter(name=request.POST["tag"])
        if len(tag) == 0:
            tag = VideoTagName(name=request.POST["tag"])
            tag.save()
        else:
            tag = tag[0]

        tag_list = VideoTagList.objects.filter(tag_id=tag.id, content_id=content_id)
        if len(tag_list) == 0:
            tag_list = VideoTagList(tag_id=tag.id, content_id=content_id)
            tag_list.save()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの追加では、まずタグが存在するか確認し存在しない場合はVideoTagNameに追加します。その後、VideoTagListに動画IDとタグIDを紐づけて登録します。

  • video/views.py
def update_remove_tag(request, content_id, tag_name):
    tag = VideoTagName.objects.filter(name=tag_name)
    if len(tag) != 0:
        tag_list = VideoTagList.objects.filter(tag_id=tag[0].id, content_id=content_id)
        tag_list.delete()

    return HttpResponseRedirect(reverse('video:edit', kwargs={'content_id': content_id}))

タグの削除では、動画IDとタグIDが一致するレコードをVideoTagListから削除します。

それでは、実際に動かしてみます。

f:id:jianlan:20190805223522j:plain

編集画面からTAG1と新しいTAG3を付けることができました。

最後に、動画の削除機能を実装します。

  • video/templates/video/delete.html
{% extends "video/base.html" %}

{% block main %}
<br>
<br>

<form method="post">
  {% csrf_token %}
  <img src="/media/video/{{ object.id }}/thumb.jpg"><br>
  {{ object.title }}を削除します。<br><br>
  <input type="submit" value="削除">
</form>

{% endblock %}

削除処理自体は以前に作成したdelete_video関数を利用します。

  • video/views.py
class DeleteView(generic.DeleteView):
    model = VideoContent
    template_name = 'video/delete.html'
    success_url = reverse_lazy('video:index')

    def delete(self, request, *args, **kwargs):
        content_id = self.kwargs['pk']
        filename = VideoContent.objects.filter(id=content_id)[0].filename
        delete_video(content_id, filename)

        return super().delete(request, *args, **kwargs)

f:id:jianlan:20190805224226j:plain

削除するボタンを押すとストレージから動画自体が削除され、データベースから登録が消去されます。

Djangoで動画共有サイトを作る Part4へ続きます。