建立清華大學Node Packaged Modules鏡像

緣由

Hacker News最近有篇文章How to create a private npm.js repository,看完後打算給http://mirror.tuna.tsinghua.edu.cn/搭個源。

搭建

  • sudo aptitude install couchdb
  • sudo vim /etc/couchdb/local.ini修改admin密碼
  • sudo install -d -o couchdb -g couchdb /var/run/couchdb

然後配置CouchDB:

1
2
3
4
5
6
$ sudo vim /etc/couchdb/local.ini
[httpd]
secure_rewrites = false

[couchdb]
database_dir = /mirror/npm/couchdb # 設置couchdb數據庫存放路徑

這裏把數據庫目錄設爲/mirror/npm/couchdb了。 2013年8月17日,同步完成後這個目錄下面的registry.couch有72G。

  • sudo /etc/init.d/couchdb start

然後開始同步,CouchDB的管理是通過HTTP請求來進行的:

1
2
3
curl -X POST http://127.0.0.1:5984/_replicate -d \
'{"source":"http://isaacs.iriscouch.com/registry/", "target":"registry", "create_target":true, "continuous":true}' \
-H "Content-Type: application/json"

注意其中的name/value對:"continuous":true,就是說這個replication過程(同步)是持續的,而非一次性的,不需要像rsync鏡像那樣用cron job定期執行同步命令。

2013年8月17日更新,以下操作似乎不需要:

1
2
3
4
5
6
7
git clone git://github.com/isaacs/npmjs.org.git
cd npmjs.org
sudo npm install -g couchapp
npm install couchapp
npm install semver
couchapp push registry/app.js http://localhost:5984/registry
couchapp push www/app.js http://localhost:5984/registry

獲取同步狀態

使用以下命令:

1
curl -s localhost:5984/_active_task | jq .

顯示同步狀態。其中jq是個強大的JSON數據的命令行處理器,類似於sed。在這個命令中jq的基本過濾器.,起到了pretty printer美化JSON輸出結果的作用。

題外話,在CSS的selector的影響下誕生了HTML快速生成的snippet工具Zen Coding(現在更名爲Emmet),jq給人類似的感覺。

上面的命令有類似下面的JSON輸出結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[
{
"updated_on": 1376700136,
"missing_revisions_found": 1678,
"docs_written": 1678,
"docs_read": 1678,
"doc_write_failures": 0,
"doc_id": null,
"continuous": true,
"checkpointed_source_seq": 620684,
"pid": "<0.1872.0>",
"progress": 99,
"replication_id": "d2d78ebb34eb57384335196827cdc81e+continuous",
"revisions_checked": 12615,
"source": "http://isaacs.iriscouch.com/registry/",
"source_seq": 624071,
"started_on": 1376513978,
"target": "registry",
"type": "replication"
}
]

CouchDB文檔不全,我沒有找到各個字段的含義,下面是個人臆斷:

progress name表示的是進度,最大爲100,是用floor(checkpointed_source_seq * 100 / source_seq)計算出來的。如果是progress達到了100就表明完全達到了官方數據庫的某一歷史版本狀態。如果沒到100,鏡像就處於不一致狀態,可能metadata信息和實際包不一致,但這種不一致性影響比較小,通常不會產生問題。

禁止普通用戶PUT/POST/DELETE

客戶端npm向服務端抓取數據只會用到GET請求,我們的鏡像是官方數據庫的一個slave database,不是權威服務器,也不具有官方服務器的用戶賬戶信息,所以無法提供用戶登錄,而且鏡像也不應該允許用戶上傳,所以PUT/POST/DELETE方法都用不到的。而CouchDB默認是允許任何用戶做修改的,這是個很大的安全風險,需要屏蔽掉這幾個方法。解決方案是給registry這個database的_design/security添加如下驗證函數validate_doc_update

1
2
3
4
5
6
function(newDoc, oldDoc, userCtx, secObj) {
if (! userCtx || ~ userCtx.roles.indexOf('_admin'))
log('Admin change on read-only db: ' + newDoc._id);
else
throw {'forbidden':'This database is read-only'};
}

可以執行下面的命令添加上面這段驗證函數:

1
2
curl -X PUT admin:password@localhost:5984/registry/_design/security -d \
'{"validate_doc_update": "function(newDoc, oldDoc, userCtx, secObj) { if (! userCtx || ~ userCtx.roles.indexOf('\''_admin'\'')) log('\''Admin change on read-only db: '\'' + newDoc._id); else throw {'\''forbidden'\'':'\''This database is read-only'\''}; }"}'

使用Nginx做反向代理

CouchDB支持vhost,但爲了讓它和其他鏡像更好地協作,用Nginx做反向代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen [::]:80;
server_name npm.*;
root /mirror/npm/www;
index index.html;

location /registry {
proxy_redirect off;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:5984/registry/_design/app/_rewrite;
# proxy_pass http://127.0.0.1:5984/registry;
}

location /registry/_design {
proxy_redirect off;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:5984/registry/_design;
}
}

其中被#註釋掉的proxy_pass http://127.0.0.1:5984/registry;不可用,不明原因。

fqj1994同學報告並修復了一個問題:metadata返回的url的host是根據請求的Host:首部來決定的,所以需要proxy_set_header Host $host;來讓CouchDB生成正確的url。否則客戶端npm會收到包含127.0.0.1的metadata,試圖從自己機器獲取數據,自然就得不到。

另外,leecade同學提出了一個想法,希望本地源返回404

% curl http://npm.tuna.tsinghua.edu.cn/registry/xxxxxxxx
{"error":"not_found","reason":"document not found"}

時讓Nginx作爲前向代理替客戶端向官方源請求。當有一個包剛剛傳到官方源,本地源尚無相應信息時挺有用。可以在配置中修改locatioon /registrylocation /registry/_design的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
location /registry {
proxy_redirect off;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:5984/registry/_design/app/_rewrite;
# proxy_pass http://127.0.0.1:5984/registry;
proxy_intercept_errors on;
error_page 404 @official;
}

location /registry/_design {
proxy_redirect off;
proxy_set_header Host $host;
proxy_pass http://127.0.0.1:5984/registry/_design;
proxy_intercept_errors on;
error_page 404 @official;
}

並添加:

1
2
3
4
location @official {
proxy_redirect off;
proxy_pass http://registry.npmjs.org;
}

其中proxy_intercept_errors的作用就是讓Nginx解析後端返回的狀態碼>=400的錯誤,這樣error_page就能生效並引導至@official塊。

網頁介紹

然後要設計一個網頁介紹。

jade來做模板引擎無疑是最方便的,不過標籤裏要添加尾部空格比較難以實現。Ruby社區的slimjade的基礎上做了些改進,引入了一些便捷的東西,比jade更加好用。

CSS可以考慮用stylus結合nib插件。

從上游同步時使用代理

在向上遊同步時,偶爾會碰到流量被過濾,同步無法順利進行的情況。我碰到過幾次這樣的情況了,等幾天都同步都不會有進展的,用ls -l查看數據庫的修改日期會發現一直沒有變化。

這個時候需要臨時去除無代理的上游配置,換上有代理的上游配置:

1
2
curl -sX POST admin:password@127.0.0.1:5984/_replicate -d '{"source":"http://isaacs.iriscouch.com/registry/", "target":"registry", "continuous":true, "cancel":true}' -H "Content-Type: application/json"
curl -sX POST admin:password@127.0.0.1:5984/_replicate -d '{"source":"http://isaacs.iriscouch.com/registry/", "target":"registry", "continuous":true, "create_target":true, "proxy":"http://127.0.0.1:xxxx"}' -H "Content-Type: application/json"

第一句是去除原有的無代理上游配置,第二局是添加有代理的上游配置。

過了幾秒鐘後即可再次換上無代理的上游配置:

1
2
curl -sX POST admin:password@127.0.0.1:5984/_replicate -d '{"source":"http://isaacs.iriscouch.com/registry/", "target":"registry", "continuous":true, "cancel":true, "proxy":"http://127.0.0.1:xxxx"}' -H "Content-Type: application/json"
curl -sX POST admin:password@127.0.0.1:5984/_replicate -d '{"source":"http://isaacs.iriscouch.com/registry/", "target":"registry", "continuous":true, "create_target":true}' -H "Content-Type: application/json"

第一句是去除有代理上游配置,第二句是添加無代理的上游配置。

同步腳本

下面的同步腳本會跟蹤registry.couchdb文件的修改時間,以此判斷CouchDB的replication過程是不是卡住了,如果是則臨時掛上代理:

2013年10月16日更新,根據http://wiki.apache.org/couchdb/Replication#Cancel_replication,CouchDB 1.2開始取消同步的方式有所變化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#!/usr/bin/env ruby
require 'net/http'
require 'net/smtp'
require 'json'

LOG_ROOT_DIR = File.expand_path '~mirror/log'
LOG_DIR = File.join LOG_ROOT_DIR, 'npm'
STATUS_FILE = File.join LOG_DIR, 'status.txt'
DB_PATH = '/srv/local/npm/couchdb/registry.couch'
USER = 'admin'
PASSWORD, HTTP_PROXY = JSON.parse(File.read '/srv/local/npm/couchdb/passwd').values_at 'password', 'http_proxy'
WAIT = 30
DEBUG = false

replicate = ->opts=({}) {
req = Net::HTTP::Post.new '/_replicate'
req.basic_auth USER, PASSWORD
if opts[:cancel]
form_data = {'replication_id' => opts[:replication_id], 'cancel' => true}
else
form_data = {'source'=>'http://isaacs.iriscouch.com/registry/',
'target'=>'registry', 'continuous'=>true, 'create_target'=>true}
form_data.update 'proxy'=>HTTP_PROXY if opts[:proxy]
end
req.set_content_type 'application/json'
req.body = form_data.to_json
res = Net::HTTP.new('localhost', 5984).request req
puts res.body if DEBUG
}

cancel_all_replicates = ->sources {
sources.each {|source|
puts "replication: #{source['replication_id']}"
replicate[cancel: true, replication_id: source['replication_id']]
}
}

get = ->path {
req = Net::HTTP::Get.new path
req.basic_auth USER, PASSWORD
Net::HTTP.new('localhost', 5984).request req
}

get_json = ->path {
JSON.parse get[path].body
}

stuck = ->{
Time.new - File.stat(DB_PATH).mtime > 60 * 10
}

write_log = ->log{
puts "write log: #{log}" if DEBUG
File.write STATUS_FILE, log
}

send_email = ->{
name = 'Ray'
from = 'issues.tuna@gmail.com'
to = 'i@maskray.me'
server = 'localhost'
msg = <<E
From: #{name} <#{from}>
To: #{to}
Subject: npm ERROR on #{Time.now}

failed to sync
E

Net::SMTP.start(server) do |smtp|
smtp.send_message msg, from, to
end
}

status = File.readlines(File.join log_dir, 'status.txt')[0].split ',' rescue 'failed,1377760143,-,0,74.5217GB,0,0,0,0,0,0,0,0'
sources = get_json['/_active_tasks']
last_fail = Time.now

loop do
begin
size = get_json['/registry']['disk_size']
failed = ->{
status[0] = 'failed'
status[4] = size
write_log["#{status.join(',')}\n"]
if Time.now - last_fail > 60 * 60 * 24
send_email[]
last_fail = Time.now
end
}

if sources.empty?
replicate[]
elsif stuck[]
cancel_all_replicates[sources]
replicate[proxy: true]
sleep WAIT
if stuck[]
cancel_all_replicates[sources]
failed[]
else
cancel_all_replicates[sources]
replicate[]
end
else
updated_on, _progress = sources[0].values_at 'updated_on', 'progress'
write_log["success,#{updated_on},-,0,#{size},0,0,0,0,0,0,0,0"]
end
rescue => e
$stderr.puts e
ensure
sleep 60
end
end