Skip to content

Commit 5a88394

Browse files
kolyshkinlifubang
authored andcommitted
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 the way it is, I found commit 9744d72 and traced it to [2], which discusses the actual reasons. It boils down to these two: - HOME is not passed into container as it is set in setupUser by os.Setenv and has no effect on config.Env; - there is a need to deduplication of environment variables. Yet it was decided in [2] to not go ahead with this patch, but later [3] was opened with the carry of this patch, and merged. Now, from what I see: 1. Passing environment to exec is way faster than using os.Setenv and os.Environ (tests show ~20x speed improvement in a simple Go test, and ~3x improvement in real-world test, see below). 2. Setting environment variables in the runc context may result is some ugly side effects (think GODEBUG, LD_PRELOAD, or _LIBCONTAINER_*). 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). We should stick to what we have in order to maintain backward compatibility. So, this patch: - switches to passing env directly to exec; - adds deduplication mechanism to retain backward compatibility; - takes care to set PATH from process.Env in the current process (so that supplied PATH is used to find the binary to execute), also to retain backward compatibility; - adds HOME to process.Env if not set; - ensures any StartContainer CommandHook entries with no environment set explicitly are run with the same environment as before. Thanks to @lifubang who noticed that peculiarity. The benchmark added by the previous commit shows ~3x improvement: │ before │ after │ │ sec/op │ sec/op vs base │ ExecInBigEnv-20 61.53m ± 1% 21.87m ± 16% -64.46% (p=0.000 n=10) [1]: #1983 [2]: docker-archive/libcontainer#418 [3]: docker-archive/libcontainer#432 Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
1 parent 838711b commit 5a88394

File tree

5 files changed

+129
-42
lines changed

5 files changed

+129
-42
lines changed

libcontainer/env.go

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

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: 16 additions & 38 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"
@@ -185,8 +184,8 @@ func startInitialization() (retErr error) {
185184
defer pidfdSocket.Close()
186185
}
187186

188-
// clear the current process's environment to clean any libcontainer
189-
// specific env vars.
187+
// From here on, we don't need current process environment. It is not
188+
// used directly anywhere below this point, but let's clear it anyway.
190189
os.Clearenv()
191190

192191
defer func() {
@@ -209,9 +208,11 @@ func startInitialization() (retErr error) {
209208
}
210209

211210
func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSocket, pidfdSocket, fifoFile, logPipe *os.File) error {
212-
if err := populateProcessEnvironment(config.Env); err != nil {
211+
env, homeSet, err := prepareEnv(config.Env)
212+
if err != nil {
213213
return err
214214
}
215+
config.Env = env
215216

216217
// Clean the RLIMIT_NOFILE cache in go runtime.
217218
// Issue: https://github.com/opencontainers/runc/issues/4195
@@ -225,6 +226,7 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
225226
pidfdSocket: pidfdSocket,
226227
config: config,
227228
logPipe: logPipe,
229+
addHome: !homeSet,
228230
}
229231
return i.Init()
230232
case initStandard:
@@ -236,36 +238,13 @@ func containerInit(t initType, config *initConfig, pipe *syncSocket, consoleSock
236238
config: config,
237239
fifoFile: fifoFile,
238240
logPipe: logPipe,
241+
addHome: !homeSet,
239242
}
240243
return i.Init()
241244
}
242245
return fmt.Errorf("unknown init type %q", t)
243246
}
244247

245-
// populateProcessEnvironment loads the provided environment variables into the
246-
// current processes's environment.
247-
func populateProcessEnvironment(env []string) error {
248-
for _, pair := range env {
249-
name, val, ok := strings.Cut(pair, "=")
250-
if !ok {
251-
return errors.New("invalid environment variable: missing '='")
252-
}
253-
if name == "" {
254-
return errors.New("invalid environment variable: name cannot be empty")
255-
}
256-
if strings.IndexByte(name, 0) >= 0 {
257-
return fmt.Errorf("invalid environment variable %q: name contains nul byte (\\x00)", name)
258-
}
259-
if strings.IndexByte(val, 0) >= 0 {
260-
return fmt.Errorf("invalid environment variable %q: value contains nul byte (\\x00)", name)
261-
}
262-
if err := os.Setenv(name, val); err != nil {
263-
return err
264-
}
265-
}
266-
return nil
267-
}
268-
269248
// verifyCwd ensures that the current directory is actually inside the mount
270249
// namespace root of the current process.
271250
func verifyCwd() error {
@@ -294,8 +273,8 @@ func verifyCwd() error {
294273

295274
// finalizeNamespace drops the caps, sets the correct user
296275
// and working dir, and closes any leaked file descriptors
297-
// before executing the command inside the namespace
298-
func finalizeNamespace(config *initConfig) error {
276+
// before executing the command inside the namespace.
277+
func finalizeNamespace(config *initConfig, addHome bool) error {
299278
// Ensure that all unwanted fds we may have accidentally
300279
// inherited are marked close-on-exec so they stay out of the
301280
// container
@@ -341,7 +320,7 @@ func finalizeNamespace(config *initConfig) error {
341320
if err := system.SetKeepCaps(); err != nil {
342321
return fmt.Errorf("unable to set keep caps: %w", err)
343322
}
344-
if err := setupUser(config); err != nil {
323+
if err := setupUser(config, addHome); err != nil {
345324
return fmt.Errorf("unable to setup user: %w", err)
346325
}
347326
// Change working directory AFTER the user has been set up, if we haven't done it yet.
@@ -459,8 +438,9 @@ func syncParentSeccomp(pipe *syncSocket, seccompFd int) error {
459438
return readSync(pipe, procSeccompDone)
460439
}
461440

462-
// setupUser changes the groups, gid, and uid for the user inside the container
463-
func setupUser(config *initConfig) error {
441+
// setupUser changes the groups, gid, and uid for the user inside the container,
442+
// and appends user's HOME to config.Env if addHome is true.
443+
func setupUser(config *initConfig, addHome bool) error {
464444
// Set up defaults.
465445
defaultExecUser := user.ExecUser{
466446
Uid: 0,
@@ -541,11 +521,9 @@ func setupUser(config *initConfig) error {
541521
return err
542522
}
543523

544-
// if we didn't get HOME already, set it based on the user's HOME
545-
if envHome := os.Getenv("HOME"); envHome == "" {
546-
if err := os.Setenv("HOME", execUser.Home); err != nil {
547-
return err
548-
}
524+
// If we didn't get HOME already, set it based on the user's HOME.
525+
if addHome {
526+
config.Env = append(config.Env, "HOME="+execUser.Home)
549527
}
550528
return nil
551529
}

libcontainer/setns_init_linux.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type linuxSetnsInit struct {
2525
pidfdSocket *os.File
2626
config *initConfig
2727
logPipe *os.File
28+
addHome bool
2829
}
2930

3031
func (l *linuxSetnsInit) getSessionRingName() string {
@@ -100,7 +101,7 @@ func (l *linuxSetnsInit) Init() error {
100101
return err
101102
}
102103
}
103-
if err := finalizeNamespace(l.config); err != nil {
104+
if err := finalizeNamespace(l.config, l.addHome); err != nil {
104105
return err
105106
}
106107
if err := apparmor.ApplyProfile(l.config.AppArmorProfile); err != nil {
@@ -153,5 +154,5 @@ func (l *linuxSetnsInit) Init() error {
153154
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
154155
return err
155156
}
156-
return system.Exec(name, l.config.Args, os.Environ())
157+
return system.Exec(name, l.config.Args, l.config.Env)
157158
}

libcontainer/standard_init_linux.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type linuxStandardInit struct {
2727
fifoFile *os.File
2828
logPipe *os.File
2929
config *initConfig
30+
addHome bool
3031
}
3132

3233
func (l *linuxStandardInit) getSessionRingParams() (string, uint32, uint32) {
@@ -189,14 +190,22 @@ func (l *linuxStandardInit) Init() error {
189190
return err
190191
}
191192
}
192-
if err := finalizeNamespace(l.config); err != nil {
193+
if err := finalizeNamespace(l.config, l.addHome); err != nil {
193194
return err
194195
}
195196
// finalizeNamespace can change user/group which clears the parent death
196197
// signal, so we restore it here.
197198
if err := pdeath.Restore(); err != nil {
198199
return fmt.Errorf("can't restore pdeath signal: %w", err)
199200
}
201+
202+
// In case we have any StartContainer hooks to run, and they don't
203+
// have environment configured explicitly, make sure they will be run
204+
// with the same environment as container's init.
205+
if h := l.config.Config.Hooks[configs.StartContainer]; len(h) > 0 {
206+
h.SetDefaultEnv(l.config.Env)
207+
}
208+
200209
// Compare the parent from the initial start of the init process and make
201210
// sure that it did not change. if the parent changes that means it died
202211
// and we were reparented to something else so we should just kill ourself
@@ -288,5 +297,5 @@ func (l *linuxStandardInit) Init() error {
288297
if err := utils.UnsafeCloseFrom(l.config.PassedFilesCount + 3); err != nil {
289298
return err
290299
}
291-
return system.Exec(name, l.config.Args, os.Environ())
300+
return system.Exec(name, l.config.Args, l.config.Env)
292301
}

0 commit comments

Comments
 (0)