php_fpm_docker 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|