php_fpm_docker 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.rubocop.yml +20 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +79 -0
- data/LICENSE.txt +22 -0
- data/README.md +68 -0
- data/Rakefile +21 -0
- data/bin/php_fpm_docker +4 -0
- data/doc/graphs/structure/structure.gliffy +1 -0
- data/doc/graphs/structure/structure.png +0 -0
- data/doc/graphs/structure/structure.svg +1 -0
- data/lib/php_fpm_docker.rb +6 -0
- data/lib/php_fpm_docker/application.rb +314 -0
- data/lib/php_fpm_docker/launcher.rb +309 -0
- data/lib/php_fpm_docker/pool.rb +169 -0
- data/lib/php_fpm_docker/version.rb +5 -0
- data/php_fpm_docker.gemspec +30 -0
- data/spec/helper.rb +19 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/unit/application_spec.rb +197 -0
- data/spec/unit/launcher_spec.rb +339 -0
- data/spec/unit/pool_spec.rb +277 -0
- metadata +172 -0
@@ -0,0 +1,309 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'pathname'
|
3
|
+
require 'inifile'
|
4
|
+
require 'pp'
|
5
|
+
require 'logger'
|
6
|
+
require 'docker'
|
7
|
+
require 'digest'
|
8
|
+
|
9
|
+
module PhpFpmDocker
|
10
|
+
# Represent a single docker image
|
11
|
+
class Launcher # rubocop:disable ClassLength
|
12
|
+
attr_reader :docker_image, :php_cmd_path, :spawn_cmd_path
|
13
|
+
|
14
|
+
def initialize(name) # rubocop:disable MethodLength
|
15
|
+
@name = name
|
16
|
+
|
17
|
+
# Create log dir if needed
|
18
|
+
log_dir = Pathname.new('/var/log/php_fpm_docker')
|
19
|
+
FileUtils.mkdir_p log_dir unless log_dir.directory?
|
20
|
+
|
21
|
+
# Open logger
|
22
|
+
@logger = Logger.new(log_dir.join("#{name}.log"), 'daily')
|
23
|
+
@logger.info(to_s) { 'init' }
|
24
|
+
|
25
|
+
test
|
26
|
+
end
|
27
|
+
|
28
|
+
def test
|
29
|
+
test_directories
|
30
|
+
|
31
|
+
# Parse config
|
32
|
+
parse_config
|
33
|
+
|
34
|
+
# Test docker image
|
35
|
+
test_docker_image
|
36
|
+
|
37
|
+
rescue RuntimeError => e
|
38
|
+
@logger.fatal(to_s) { "Error while init: #{e.message}" }
|
39
|
+
exit 1
|
40
|
+
end
|
41
|
+
|
42
|
+
def run
|
43
|
+
start_pools
|
44
|
+
|
45
|
+
pid = fork do
|
46
|
+
fork_run
|
47
|
+
end
|
48
|
+
Process.detach(pid)
|
49
|
+
pid
|
50
|
+
end
|
51
|
+
|
52
|
+
def fork_run
|
53
|
+
Signal.trap('USR1') do
|
54
|
+
@logger.info(to_s) { 'Signal USR1 received reloading now' }
|
55
|
+
reload_pools
|
56
|
+
end
|
57
|
+
Signal.trap('TERM') do
|
58
|
+
@logger.info(to_s) { 'Signal TERM received stopping me now' }
|
59
|
+
stop_pools
|
60
|
+
exit 0
|
61
|
+
end
|
62
|
+
Kernel.loop do
|
63
|
+
check_pools
|
64
|
+
sleep 1
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def start_pools
|
69
|
+
@pools = {}
|
70
|
+
reload_pools
|
71
|
+
end
|
72
|
+
|
73
|
+
def stop_pools
|
74
|
+
reload_pools({})
|
75
|
+
end
|
76
|
+
|
77
|
+
def create_missing_pool_objects
|
78
|
+
return if @pools.nil?
|
79
|
+
return if @pools_old.nil?
|
80
|
+
(@pools.keys & @pools_old.keys).each do |hash|
|
81
|
+
@pools[hash][:object] = @pools_old[hash][:object]
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def move_existing_pool_objects
|
86
|
+
return if @pools.nil?
|
87
|
+
@pools.keys.each do |hash|
|
88
|
+
pool = @pools[hash]
|
89
|
+
# skip if there's already an object
|
90
|
+
next if pool.key?(:object)
|
91
|
+
pool[:object] = Pool.new(
|
92
|
+
config: pool[:config],
|
93
|
+
name: pool[:name],
|
94
|
+
launcher: self
|
95
|
+
)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def reload_pools(pools = nil)
|
100
|
+
@pools_old = @pools
|
101
|
+
if pools.nil?
|
102
|
+
@pools = pools_from_config
|
103
|
+
else
|
104
|
+
@pools = pools
|
105
|
+
end
|
106
|
+
|
107
|
+
move_existing_pool_objects
|
108
|
+
create_missing_pool_objects
|
109
|
+
|
110
|
+
# Pools to stop
|
111
|
+
pools_action(@pools_old, @pools_old.keys - @pools.keys, :stop)
|
112
|
+
|
113
|
+
# Pools to start
|
114
|
+
pools_action(@pools, @pools.keys - @pools_old.keys, :start)
|
115
|
+
end
|
116
|
+
|
117
|
+
def check_pools
|
118
|
+
'do nothing'
|
119
|
+
end
|
120
|
+
|
121
|
+
def check_pools_n
|
122
|
+
pools_action(@pools, @pools.keys, :check)
|
123
|
+
end
|
124
|
+
|
125
|
+
def pools_action(pools, pools_hashes, action)
|
126
|
+
message = ''
|
127
|
+
if pools_hashes.length > 0
|
128
|
+
message << "Pools to #{action}: "
|
129
|
+
message << pools_hashes.map { |p| pools[p][:name] }.join(', ')
|
130
|
+
pools_hashes.each do |pool_hash|
|
131
|
+
pool = pools[pool_hash]
|
132
|
+
begin
|
133
|
+
pool[:object].send(action)
|
134
|
+
rescue => e
|
135
|
+
@logger.warn(pool[:object].to_s) do
|
136
|
+
"Failed to #{action}: #{e.message}"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
else
|
141
|
+
message << "No pools to #{action}"
|
142
|
+
end
|
143
|
+
@logger.info(to_s) { message }
|
144
|
+
end
|
145
|
+
|
146
|
+
def test_directories
|
147
|
+
# Get config dirs and paths
|
148
|
+
@conf_directory = Pathname.new('/etc/php_fpm_docker/conf.d').join(@name)
|
149
|
+
fail "Config directory '#{@conf_directory}' not found" \
|
150
|
+
unless @conf_directory.directory?
|
151
|
+
|
152
|
+
@pools_directory = @conf_directory.join('pools.d')
|
153
|
+
fail "Pool directory '#{@pools_directory}' not found" \
|
154
|
+
unless @pools_directory.directory?
|
155
|
+
|
156
|
+
@config_path = @conf_directory.join('config.ini')
|
157
|
+
end
|
158
|
+
|
159
|
+
def to_s
|
160
|
+
"<Launcher:#{@name}>"
|
161
|
+
end
|
162
|
+
|
163
|
+
# Get neccessary bind mounts
|
164
|
+
def bind_mounts
|
165
|
+
@ini_file[:main]['bind_mounts'].split(',') || []
|
166
|
+
end
|
167
|
+
|
168
|
+
# Get webs base path
|
169
|
+
def web_path
|
170
|
+
Pathname.new(@ini_file[:main]['web_path'] || '/var/www')
|
171
|
+
end
|
172
|
+
|
173
|
+
# Parse the config file for all pools
|
174
|
+
def parse_config # rubocop:disable MethodLength
|
175
|
+
# Test for file usability
|
176
|
+
fail "Config file '#{@config_path}' not found"\
|
177
|
+
unless @config_path.file?
|
178
|
+
fail "Config file '#{@config_path}' not readable"\
|
179
|
+
unless @config_path.readable?
|
180
|
+
|
181
|
+
@ini_file = IniFile.load(@config_path)
|
182
|
+
|
183
|
+
begin
|
184
|
+
docker_image = @ini_file[:main]['docker_image']
|
185
|
+
@docker_image = Docker::Image.get(docker_image)
|
186
|
+
@logger.info(to_s) do
|
187
|
+
"Docker image id=#{@docker_image.id[0..11]} name=#{docker_image}"
|
188
|
+
end
|
189
|
+
rescue NoMethodError
|
190
|
+
raise 'No docker_image in section main in config found'
|
191
|
+
rescue Docker::Error::NotFoundError
|
192
|
+
raise "Docker_image '#{docker_image}' not found"
|
193
|
+
rescue Excon::Errors::SocketError => e
|
194
|
+
raise "Docker connection could not be established: #{e.message}"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def docker_opts
|
199
|
+
{
|
200
|
+
'Image' => @docker_image.id
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
# Reads config sections from a inifile
|
205
|
+
def pools_config_content_from_file(config_path)
|
206
|
+
ini_file = IniFile.load(config_path)
|
207
|
+
|
208
|
+
ret_val = []
|
209
|
+
ini_file.each_section do |section|
|
210
|
+
ret_val << [section, ini_file[section]]
|
211
|
+
end
|
212
|
+
ret_val
|
213
|
+
end
|
214
|
+
|
215
|
+
# Merges config sections form all inifiles
|
216
|
+
def pools_config_contents
|
217
|
+
ret_val = []
|
218
|
+
|
219
|
+
# Loop over
|
220
|
+
Dir[@pools_directory.join('*.conf').to_s].each do |config_path|
|
221
|
+
ret_val += pools_config_content_from_file(config_path)
|
222
|
+
end
|
223
|
+
ret_val
|
224
|
+
end
|
225
|
+
|
226
|
+
# Hashes configs to detect changes
|
227
|
+
def pools_from_config
|
228
|
+
configs = {}
|
229
|
+
|
230
|
+
pools_config_contents.each do |section|
|
231
|
+
# Hash section name and content
|
232
|
+
d = Digest::SHA2.new(256)
|
233
|
+
hash = d.reset.update(section[0]).update(section[1].to_s).to_s
|
234
|
+
|
235
|
+
configs[hash] = {
|
236
|
+
name: section[0],
|
237
|
+
config: section[1]
|
238
|
+
}
|
239
|
+
end
|
240
|
+
configs
|
241
|
+
end
|
242
|
+
|
243
|
+
# Docker init
|
244
|
+
def test_docker_cmd(cmd) # rubocop:disable MethodLength
|
245
|
+
# retry this block 3 times
|
246
|
+
tries ||= 3
|
247
|
+
|
248
|
+
opts = docker_opts
|
249
|
+
opts['Cmd'] = cmd
|
250
|
+
dict = {}
|
251
|
+
|
252
|
+
# Set timeout
|
253
|
+
Docker.options[:read_timeout] = 2
|
254
|
+
|
255
|
+
cont = Docker::Container.create(opts)
|
256
|
+
cont.start
|
257
|
+
output = cont.attach
|
258
|
+
dict[:ret_val] = cont.wait(5)['StatusCode']
|
259
|
+
cont.delete(force: true)
|
260
|
+
|
261
|
+
dict[:stdout] = output[0].first
|
262
|
+
dict[:stderr] = output[1].first
|
263
|
+
|
264
|
+
# Set timeout
|
265
|
+
Docker.options[:read_timeout] = 15
|
266
|
+
|
267
|
+
@logger.debug(to_s) do
|
268
|
+
"cmd=#{cmd.join(' ')} ret_val=#{dict[:ret_val]}" \
|
269
|
+
" stdout=#{dict[:stdout]} stderr=#{dict[:stderr]}"
|
270
|
+
end
|
271
|
+
|
272
|
+
dict
|
273
|
+
rescue Docker::Error::TimeoutError => e
|
274
|
+
if (tries -= 1) > 0
|
275
|
+
cont.delete(force: true) if cont.nil?
|
276
|
+
@logger.debug(to_s) { 'ran into timeout retry' }
|
277
|
+
retry
|
278
|
+
end
|
279
|
+
raise e
|
280
|
+
end
|
281
|
+
|
282
|
+
# Testing the docker image if i can be used
|
283
|
+
def test_docker_image # rubocop:disable MethodLength
|
284
|
+
# Test possible php commands
|
285
|
+
['php-cgi', 'php5-cgi', 'php', 'php5'].each do |php_cmd|
|
286
|
+
result = test_docker_cmd [:which, php_cmd]
|
287
|
+
|
288
|
+
next unless result[:ret_val] == 0
|
289
|
+
|
290
|
+
php_cmd_path = result[:stdout].strip
|
291
|
+
|
292
|
+
result = test_docker_cmd [php_cmd_path, '-v']
|
293
|
+
|
294
|
+
next unless result[:ret_val] == 0
|
295
|
+
php_version_re = /PHP [A-Za-z0-9\.\-\_]+ \(cgi-fcgi\)/
|
296
|
+
next if php_version_re.match(result[:stdout]).nil?
|
297
|
+
|
298
|
+
@php_cmd_path = php_cmd_path
|
299
|
+
break
|
300
|
+
end
|
301
|
+
fail 'No usable fast-cgi enabled php found in image' if @php_cmd_path.nil?
|
302
|
+
|
303
|
+
# Test if spawn-fcgi exists
|
304
|
+
result = test_docker_cmd [:which, 'spawn-fcgi']
|
305
|
+
fail 'No usable spawn-fcgi found in image' unless result[:ret_val] == 0
|
306
|
+
@spawn_cmd_path = result[:stdout].strip
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
@@ -0,0 +1,169 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'pp'
|
3
|
+
require 'inifile'
|
4
|
+
require 'docker'
|
5
|
+
require 'securerandom'
|
6
|
+
|
7
|
+
module PhpFpmDocker
|
8
|
+
# A pool represent a single isolated PHP web instance
|
9
|
+
class Pool
|
10
|
+
attr_reader :enabled
|
11
|
+
def initialize(opts)
|
12
|
+
@config = opts[:config]
|
13
|
+
@launcher = opts[:launcher]
|
14
|
+
@name = opts[:name]
|
15
|
+
end
|
16
|
+
|
17
|
+
def docker_create_opts
|
18
|
+
volumes = {}
|
19
|
+
bind_mounts.each do |d|
|
20
|
+
volumes[d] = {}
|
21
|
+
end
|
22
|
+
|
23
|
+
{
|
24
|
+
'name' => container_name,
|
25
|
+
'Image' => @launcher.docker_image.id,
|
26
|
+
'Volumes' => volumes,
|
27
|
+
'WorkingDir' => '/'
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def docker_start_opts
|
32
|
+
binds = bind_mounts.map do |d|
|
33
|
+
"#{d}:#{d}"
|
34
|
+
end
|
35
|
+
{
|
36
|
+
'Binds' => binds
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Return web path regexs
|
41
|
+
def web_path_regex
|
42
|
+
[
|
43
|
+
%r{(^#{@launcher.web_path}/clients/client\d+/web\d+)},
|
44
|
+
%r{(^#{@launcher.web_path}/[^/]+)/web$}
|
45
|
+
]
|
46
|
+
end
|
47
|
+
|
48
|
+
def valid_web_paths
|
49
|
+
ret_val = []
|
50
|
+
open_base_dirs.map do |dir|
|
51
|
+
web_path_regex.each do |regex|
|
52
|
+
m = regex.match(dir)
|
53
|
+
ret_val << m[1] unless m.nil?
|
54
|
+
end
|
55
|
+
end
|
56
|
+
ret_val
|
57
|
+
end
|
58
|
+
|
59
|
+
# Find out bind mount paths
|
60
|
+
def bind_mounts
|
61
|
+
ret_val = @launcher.bind_mounts
|
62
|
+
ret_val << File.dirname(@config['listen'])
|
63
|
+
ret_val += valid_web_paths
|
64
|
+
ret_val.uniq
|
65
|
+
end
|
66
|
+
|
67
|
+
def open_base_dirs
|
68
|
+
@config['php_admin_value[open_basedir]'].split(':')
|
69
|
+
end
|
70
|
+
|
71
|
+
def root_dir
|
72
|
+
max(valid_web_paths)
|
73
|
+
end
|
74
|
+
|
75
|
+
def socket_dir
|
76
|
+
File.dirname(@config['listen'])
|
77
|
+
end
|
78
|
+
|
79
|
+
def uid_from_user(user)
|
80
|
+
Etc.getpwnam(user).uid
|
81
|
+
end
|
82
|
+
|
83
|
+
def gid_from_group(group)
|
84
|
+
Etc.getgrnam(group).gid
|
85
|
+
end
|
86
|
+
|
87
|
+
def listen_uid
|
88
|
+
uid_from_user(@config['listen.owner'])
|
89
|
+
end
|
90
|
+
|
91
|
+
def listen_gid
|
92
|
+
gid_from_group(@config['listen.group'])
|
93
|
+
end
|
94
|
+
|
95
|
+
def uid
|
96
|
+
uid_from_user(@config['user'])
|
97
|
+
end
|
98
|
+
|
99
|
+
def gid
|
100
|
+
gid_from_group(@config['group'])
|
101
|
+
end
|
102
|
+
|
103
|
+
# Build the spawn command
|
104
|
+
def spawn_command
|
105
|
+
[
|
106
|
+
@launcher.spawn_cmd_path,
|
107
|
+
'-s', @config['listen'],
|
108
|
+
'-U', listen_uid.to_s,
|
109
|
+
'-G', listen_gid.to_s,
|
110
|
+
'-M', '0660',
|
111
|
+
'-u', uid.to_s,
|
112
|
+
'-g', gid.to_s,
|
113
|
+
'-C', '4',
|
114
|
+
'-n'
|
115
|
+
]
|
116
|
+
end
|
117
|
+
|
118
|
+
def php_command
|
119
|
+
admin_options = []
|
120
|
+
@config.each_key do |key|
|
121
|
+
m = /^php_admin_value\[([^\]]+)\]$/.match(key)
|
122
|
+
next if m.nil?
|
123
|
+
|
124
|
+
admin_options << '-d'
|
125
|
+
admin_options << "#{m[1]}=#{@config[key]}"
|
126
|
+
|
127
|
+
end
|
128
|
+
|
129
|
+
[@launcher.php_cmd_path] + admin_options
|
130
|
+
end
|
131
|
+
|
132
|
+
def start
|
133
|
+
@enabled = true
|
134
|
+
create_opts = docker_create_opts
|
135
|
+
create_opts['Cmd'] = spawn_command + ['--'] + php_command
|
136
|
+
|
137
|
+
@container = Docker::Container.create(create_opts)
|
138
|
+
@container.start(docker_start_opts)
|
139
|
+
end
|
140
|
+
|
141
|
+
def container_name
|
142
|
+
@container_name ||= "#{@name}_#{SecureRandom.hex[0..11]}"
|
143
|
+
end
|
144
|
+
|
145
|
+
def container_running?
|
146
|
+
return false if @container.nil?
|
147
|
+
begin
|
148
|
+
return @container.info['State']['Running']
|
149
|
+
rescue NoMethodError
|
150
|
+
return false
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def check
|
155
|
+
return unless @enabled && !container_running?
|
156
|
+
stop
|
157
|
+
start
|
158
|
+
end
|
159
|
+
|
160
|
+
def stop
|
161
|
+
@enabled = false
|
162
|
+
@container.delete(force: true) unless @container.nil?
|
163
|
+
end
|
164
|
+
|
165
|
+
def to_s
|
166
|
+
"<Pool:#{@name}>"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|