Skip to content

Commit 3739bf2

Browse files
committed
libct: speedup process.Env handling
The current implementation sets all the environment variables passed in Process.Env in the current process, one by one, then uses os.Environ to read those back. As pointed out in [1], this is slow, as runc calls os.Setenv for every variable, and there may be a few thousands of those. Looking into how os.Setenv is implemented, it is indeed slow, especially when cgo is enabled. Looking into why it was implemented, I found commit 9744d72 and traced it to [2], which discusses the actual reasons. At the time were: - HOME is not passed into container as it is set in setupUser by os.Setenv and has no effect on config.Env; - there is no deduplication of environment variables. Yet it was decided to not go ahead with this patch, but later [3] was merged with the carry of this patch. Now, from what I see: 1. Passing environment to exec is way faster than using os.Setenv and os.Environment() (tests show ~20x faster in simple Go test, and 2x faster in real-world test, see below). 2. Setting environment variables in the runc context can result is ugly side effects (think GODEBUG). 3. Nothing in runtime spec says that the environment needs to be deduplicated, or the order of preference (whether the first or the last value of a variable with the same name is to be used). In C (Linux/glibc), the first value is used. In Go, it's the last one. We should probably stick to what we have in order to maintain backward compatibility. This patch: - switches to passing env directly to exec; - adds deduplication mechanism to retain backward compatibility; - sets PATH from process.Env in the current process; - adds HOME to process.Env if not set; - removes os.Clearenv call as it's no longer needed. The benchmark added by the previous commit shows 2x improvement: > name old time/op new time/op delta > ExecInBigEnv-20 61.7ms ± 4% 24.9ms ±14% -59.73% (p=0.000 n=10+10) The remaining questions are: - are there any potential regressions (for example, from not setting values from process.Env to the current process); - should deduplication show warnings (maybe promoted to errors later); - whether a default for PATH (e.g "/bin:/usr/bin" should be added, when PATH is not set. [1]: #1983 [2]: docker-archive/libcontainer#418 [3]: docker-archive/libcontainer#432 Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
1 parent 0ba2092 commit 3739bf2

File tree

5 files changed

+118
-47
lines changed

5 files changed

+118
-47
lines changed

libcontainer/env.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package libcontainer
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"slices"
8+
"strings"
9+
)
10+
11+
// prepareEnv checks supplied environment variables for validity, removes
12+
// duplicates (leaving the last value only), and sets PATH from env, if found.
13+
// Returns the deduplicated environment, and a flag telling if HOME is found.
14+
func prepareEnv(env []string) ([]string, bool, error) {
15+
// Clear the current environment (better be safe than sorry).
16+
os.Clearenv()
17+
18+
if env == nil {
19+
return nil, false, nil
20+
}
21+
// Deduplication code based on dedupEnv from Go 1.22 os/exec.
22+
23+
// Construct the output in reverse order, to preserve the
24+
// last occurrence of each key.
25+
out := make([]string, 0, len(env))
26+
saw := make(map[string]bool, len(env))
27+
for n := len(env); n > 0; n-- {
28+
kv := env[n-1]
29+
i := strings.IndexByte(kv, '=')
30+
if i == -1 {
31+
return nil, false, errors.New("invalid environment variable: missing '='")
32+
}
33+
if i == 0 {
34+
return nil, false, errors.New("invalid environment variable: name cannot be empty")
35+
}
36+
key := kv[:i]
37+
if saw[key] { // Duplicate.
38+
continue
39+
}
40+
saw[key] = true
41+
if strings.IndexByte(kv, 0) >= 0 {
42+
return nil, false, fmt.Errorf("invalid environment variable %q: contains nul byte (\\x00)", key)
43+
}
44+
if key == "PATH" {
45+
// Needs to be set as it is used for binary lookup.
46+
if err := os.Setenv("PATH", kv[5:]); err != nil {
47+
return nil, false, err
48+
}
49+
}
50+
out = append(out, kv)
51+
}
52+
// Restore the original order.
53+
slices.Reverse(out)
54+
55+
return out, saw["HOME"], nil
56+
}

libcontainer/env_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package libcontainer
2+
3+
import (
4+
"slices"
5+
"testing"
6+
)
7+
8+
func TestPrepareEnvDedup(t *testing.T) {
9+
tests := []struct {
10+
env, wantEnv []string
11+
}{
12+
{
13+
env: []string{},
14+
wantEnv: []string{},
15+
},
16+
{
17+
env: []string{"HOME=/root", "FOO=bar"},
18+
wantEnv: []string{"HOME=/root", "FOO=bar"},
19+
},
20+
{
21+
env: []string{"A=a", "A=b", "A=c"},
22+
wantEnv: []string{"A=c"},
23+
},
24+
{
25+
env: []string{"TERM=vt100", "HOME=/home/one", "HOME=/home/two", "TERM=xterm", "HOME=/home/three", "FOO=bar"},
26+
wantEnv: []string{"TERM=xterm", "HOME=/home/three", "FOO=bar"},
27+
},
28+
}
29+
30+
for _, tc := range tests {
31+
env, _, err := prepareEnv(tc.env)
32+
if err != nil {
33+
t.Error(err)
34+
continue
35+
}
36+
if !slices.Equal(env, tc.wantEnv) {
37+
t.Errorf("want %v, got %v", tc.wantEnv, env)
38+
}
39+
}
40+
}

libcontainer/init_linux.go

Lines changed: 14 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"runtime"
1212
"runtime/debug"
1313
"strconv"
14-
"strings"
1514
"syscall"
1615

1716
"github.com/containerd/console"
@@ -196,10 +195,6 @@ func startInitialization() (retErr error) {
196195
dmzExe = os.NewFile(uintptr(dmzFd), "runc-dmz")
197196
}
198197

199-
// clear the current process's environment to clean any libcontainer
200-
// specific env vars.
201-
os.Clearenv()
202-
203198
defer func() {
204199
if err := recover(); err != nil {
205200
if err2, ok := err.(error); ok {
@@ -220,9 +215,11 @@ func startInitialization() (retErr error) {
220215
}
221216

222217
func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSocket, pidfdSocket, fifoFile, logPipe, dmzExe *os.File) error {
223-
if err := populateProcessEnvironment(config.Env); err != nil {
218+
env, homeSet, err := prepareEnv(config.Env)
219+
if err != nil {
224220
return err
225221
}
222+
config.Env = env
226223

227224
// Clean the RLIMIT_NOFILE cache in go runtime.
228225
// Issue: https://github.com/opencontainers/runc/issues/4195
@@ -237,6 +234,7 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
237234
config: config,
238235
logPipe: logPipe,
239236
dmzExe: dmzExe,
237+
addHome: !homeSet,
240238
}
241239
return i.Init()
242240
case initStandard:
@@ -249,37 +247,13 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
249247
fifoFile: fifoFile,
250248
logPipe: logPipe,
251249
dmzExe: dmzExe,
250+
addHome: !homeSet,
252251
}
253252
return i.Init()
254253
}
255254
return fmt.Errorf("unknown init type %q", t)
256255
}
257256

258-
// populateProcessEnvironment loads the provided environment variables into the
259-
// current processes's environment.
260-
func populateProcessEnvironment(env []string) error {
261-
for _, pair := range env {
262-
p := strings.SplitN(pair, "=", 2)
263-
if len(p) < 2 {
264-
return errors.New("invalid environment variable: missing '='")
265-
}
266-
name, val := p[0], p[1]
267-
if name == "" {
268-
return errors.New("invalid environment variable: name cannot be empty")
269-
}
270-
if strings.IndexByte(name, 0) >= 0 {
271-
return fmt.Errorf("invalid environment variable %q: name contains nul byte (\\x00)", name)
272-
}
273-
if strings.IndexByte(val, 0) >= 0 {
274-
return fmt.Errorf("invalid environment variable %q: value contains nul byte (\\x00)", name)
275-
}
276-
if err := os.Setenv(name, val); err != nil {
277-
return err
278-
}
279-
}
280-
return nil
281-
}
282-
283257
// verifyCwd ensures that the current directory is actually inside the mount
284258
// namespace root of the current process.
285259
func verifyCwd() error {
@@ -308,8 +282,8 @@ func verifyCwd() error {
308282

309283
// finalizeNamespace drops the caps, sets the correct user
310284
// and working dir, and closes any leaked file descriptors
311-
// before executing the command inside the namespace
312-
func finalizeNamespace(config *initConfig) error {
285+
// before executing the command inside the namespace.
286+
func finalizeNamespace(config *initConfig, addHome bool) error {
313287
// Ensure that all unwanted fds we may have accidentally
314288
// inherited are marked close-on-exec so they stay out of the
315289
// container
@@ -355,7 +329,7 @@ func finalizeNamespace(config *initConfig) error {
355329
if err := system.SetKeepCaps(); err != nil {
356330
return fmt.Errorf("unable to set keep caps: %w", err)
357331
}
358-
if err := setupUser(config); err != nil {
332+
if err := setupUser(config, addHome); err != nil {
359333
return fmt.Errorf("unable to setup user: %w", err)
360334
}
361335
// Change working directory AFTER the user has been set up, if we haven't done it yet.
@@ -473,8 +447,9 @@ func syncParentSeccomp(pipe *syncSocket, seccompFd *os.File) error {
473447
return readSync(pipe, procSeccompDone)
474448
}
475449

476-
// setupUser changes the groups, gid, and uid for the user inside the container
477-
func setupUser(config *initConfig) error {
450+
// setupUser changes the groups, gid, and uid for the user inside the container,
451+
// and appends user's HOME to config.Env if addHome is true.
452+
func setupUser(config *initConfig, addHome bool) error {
478453
// Set up defaults.
479454
defaultExecUser := user.ExecUser{
480455
Uid: 0,
@@ -555,11 +530,9 @@ func setupUser(config *initConfig) error {
555530
return err
556531
}
557532

558-
// if we didn't get HOME already, set it based on the user's HOME
559-
if envHome := os.Getenv("HOME"); envHome == "" {
560-
if err := os.Setenv("HOME", execUser.Home); err != nil {
561-
return err
562-
}
533+
// If we didn't get HOME already, set it based on the user's HOME.
534+
if addHome {
535+
config.Env = append(config.Env, "HOME="+execUser.Home)
563536
}
564537
return nil
565538
}

libcontainer/setns_init_linux.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type linuxSetnsInit struct {
2626
config *initConfig
2727
logPipe *os.File
2828
dmzExe *os.File
29+
addHome bool
2930
}
3031

3132
func (l *linuxSetnsInit) getSessionRingName() string {
@@ -101,7 +102,7 @@ func (l *linuxSetnsInit) Init() error {
101102
return err
102103
}
103104
}
104-
if err := finalizeNamespace(l.config); err != nil {
105+
if err := finalizeNamespace(l.config, l.addHome); err != nil {
105106
return err
106107
}
107108
if err := apparmor.ApplyProfile(l.config.AppArmorProfile); err != nil {
@@ -143,7 +144,7 @@ func (l *linuxSetnsInit) Init() error {
143144

144145
if l.dmzExe != nil {
145146
l.config.Args[0] = name
146-
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, os.Environ())
147+
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, l.config.Env)
147148
}
148149
// Close all file descriptors we are not passing to the container. This is
149150
// necessary because the execve target could use internal runc fds as the
@@ -163,5 +164,5 @@ func (l *linuxSetnsInit) Init() error {
163164
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
164165
return err
165166
}
166-
return system.Exec(name, l.config.Args, os.Environ())
167+
return system.Exec(name, l.config.Args, l.config.Env)
167168
}

libcontainer/standard_init_linux.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type linuxStandardInit struct {
2828
logPipe *os.File
2929
dmzExe *os.File
3030
config *initConfig
31+
addHome bool
3132
}
3233

3334
func (l *linuxStandardInit) getSessionRingParams() (string, uint32, uint32) {
@@ -190,7 +191,7 @@ func (l *linuxStandardInit) Init() error {
190191
return err
191192
}
192193
}
193-
if err := finalizeNamespace(l.config); err != nil {
194+
if err := finalizeNamespace(l.config, l.addHome); err != nil {
194195
return err
195196
}
196197
// finalizeNamespace can change user/group which clears the parent death
@@ -277,7 +278,7 @@ func (l *linuxStandardInit) Init() error {
277278

278279
if l.dmzExe != nil {
279280
l.config.Args[0] = name
280-
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, os.Environ())
281+
return system.Fexecve(l.dmzExe.Fd(), l.config.Args, l.config.Env)
281282
}
282283
// Close all file descriptors we are not passing to the container. This is
283284
// necessary because the execve target could use internal runc fds as the
@@ -297,5 +298,5 @@ func (l *linuxStandardInit) Init() error {
297298
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
298299
return err
299300
}
300-
return system.Exec(name, l.config.Args, os.Environ())
301+
return system.Exec(name, l.config.Args, l.config.Env)
301302
}

0 commit comments

Comments
 (0)